diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go
index 79a3a36..82f5344 100644
--- a/addons/account/models/account_move.go
+++ b/addons/account/models/account_move.go
@@ -205,6 +205,25 @@ func initAccountMove() {
if state != "draft" {
return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state)
}
+
+ // Check partner is set for invoice types
+ var moveType string
+ env.Tx().QueryRow(env.Ctx(), `SELECT move_type FROM account_move WHERE id = $1`, id).Scan(&moveType)
+ if moveType != "entry" {
+ var partnerID *int64
+ env.Tx().QueryRow(env.Ctx(), `SELECT partner_id FROM account_move WHERE id = $1`, id).Scan(&partnerID)
+ if partnerID == nil || *partnerID == 0 {
+ return nil, fmt.Errorf("account: invoice requires a partner")
+ }
+ }
+
+ // Check at least one line exists
+ var lineCount int
+ env.Tx().QueryRow(env.Ctx(), `SELECT count(*) FROM account_move_line WHERE move_id = $1`, id).Scan(&lineCount)
+ if lineCount == 0 {
+ return nil, fmt.Errorf("account: cannot post an entry with no lines")
+ }
+
// Check balanced
var debitSum, creditSum float64
env.Tx().QueryRow(env.Ctx(),
@@ -214,6 +233,34 @@ func initAccountMove() {
if diff < -0.005 || diff > 0.005 {
return nil, fmt.Errorf("account: cannot post unbalanced entry (debit=%.2f, credit=%.2f)", debitSum, creditSum)
}
+
+ // Assign sequence number if name is still "/"
+ var name string
+ env.Tx().QueryRow(env.Ctx(), `SELECT name FROM account_move WHERE id = $1`, id).Scan(&name)
+ if name == "/" || name == "" {
+ // Generate sequence number
+ var journalID int64
+ var journalCode string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT j.id, j.code FROM account_journal j
+ JOIN account_move m ON m.journal_id = j.id WHERE m.id = $1`, id,
+ ).Scan(&journalID, &journalCode)
+
+ // Get next sequence number
+ var nextNum int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM account_move WHERE journal_id = $1`,
+ journalID).Scan(&nextNum)
+
+ // Format: journalCode/YYYY/NNNN
+ year := time.Now().Format("2006")
+ newName := fmt.Sprintf("%s/%s/%04d", journalCode, year, nextNum)
+
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET name = $1, sequence_number = $2 WHERE id = $3`,
+ newName, nextNum, id)
+ }
+
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET state = 'posted' WHERE id = $1`, id); err != nil {
return nil, err
@@ -255,6 +302,23 @@ func initAccountMove() {
return true, nil
})
+ // action_register_payment: opens the payment register wizard.
+ // Mirrors: odoo/addons/account/models/account_move.py action_register_payment()
+ m.RegisterMethod("action_register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "name": "Register Payment",
+ "res_model": "account.payment.register",
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "new",
+ "context": map[string]interface{}{
+ "active_model": "account.move",
+ "active_ids": rs.IDs(),
+ },
+ }, nil
+ })
+
// -- Business Method: create_invoice_with_tax --
// Creates a customer invoice with automatic tax line generation.
// For each product line that carries a tax_id, a separate tax line
@@ -929,6 +993,32 @@ func initAccountMoveLine() {
return true, nil
})
+
+ // remove_move_reconcile: undo reconciliation on selected lines.
+ // Mirrors: odoo/addons/account/models/account_move_line.py remove_move_reconcile()
+ m.RegisterMethod("remove_move_reconcile", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, lineID := range rs.IDs() {
+ // Find partial reconciles involving this line
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_partial_reconcile WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID)
+ // Reset residual to balance
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move_line SET amount_residual = balance, full_reconcile_id = NULL WHERE id = $1`, lineID)
+ }
+ // Clean up orphaned full reconciles
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`)
+ // Update payment states
+ for _, lineID := range rs.IDs() {
+ var moveID int64
+ env.Tx().QueryRow(env.Ctx(), `SELECT move_id FROM account_move_line WHERE id = $1`, lineID).Scan(&moveID)
+ if moveID > 0 {
+ updatePaymentState(env, moveID)
+ }
+ }
+ return true, nil
+ })
}
// initAccountPayment registers account.payment.
diff --git a/addons/crm/models/crm.go b/addons/crm/models/crm.go
index 2c43c9d..ad158d3 100644
--- a/addons/crm/models/crm.go
+++ b/addons/crm/models/crm.go
@@ -105,6 +105,38 @@ func initCRMLead() {
}
return true, nil
})
+
+ // convert_opportunity: alias for convert_to_opportunity
+ m.RegisterMethod("convert_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, id := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE crm_lead SET type = 'opportunity' WHERE id = $1`, id)
+ }
+ return true, nil
+ })
+
+ // action_set_won_rainbowman: set won stage + rainbow effect
+ m.RegisterMethod("action_set_won_rainbowman", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ // Find Won stage
+ var wonStageID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM crm_stage WHERE is_won = true LIMIT 1`).Scan(&wonStageID)
+ if wonStageID == 0 {
+ wonStageID = 4 // fallback
+ }
+ for _, id := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE crm_lead SET stage_id = $1, probability = 100 WHERE id = $2`, wonStageID, id)
+ }
+ return map[string]interface{}{
+ "effect": map[string]interface{}{
+ "type": "rainbow_man",
+ "message": "Congrats, you won this opportunity!",
+ },
+ }, nil
+ })
}
// initCRMStage registers the crm.stage model.
diff --git a/addons/hr/models/hr_contract.go b/addons/hr/models/hr_contract.go
new file mode 100644
index 0000000..4295cc8
--- /dev/null
+++ b/addons/hr/models/hr_contract.go
@@ -0,0 +1,31 @@
+package models
+
+import "odoo-go/pkg/orm"
+
+// initHrContract registers the hr.contract model.
+// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
+func initHrContract() {
+ m := orm.NewModel("hr.contract", orm.ModelOpts{
+ Description: "Employee Contract",
+ Order: "date_start desc",
+ })
+
+ m.AddFields(
+ orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
+ orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
+ orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
+ orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
+ orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
+ orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
+ orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
+ orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
+ orm.Selection("state", []orm.SelectionItem{
+ {Value: "draft", Label: "New"},
+ {Value: "open", Label: "Running"},
+ {Value: "close", Label: "Expired"},
+ {Value: "cancel", Label: "Cancelled"},
+ }, orm.FieldOpts{String: "Status", Default: "draft"}),
+ orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
+ orm.Text("notes", orm.FieldOpts{String: "Notes"}),
+ )
+}
diff --git a/addons/hr/models/init.go b/addons/hr/models/init.go
index 8cd3cdd..8f9e330 100644
--- a/addons/hr/models/init.go
+++ b/addons/hr/models/init.go
@@ -5,4 +5,5 @@ func Init() {
initHREmployee()
initHRDepartment()
initHRJob()
+ initHrContract()
}
diff --git a/addons/project/models/project.go b/addons/project/models/project.go
index 51ce1bc..846dc3d 100644
--- a/addons/project/models/project.go
+++ b/addons/project/models/project.go
@@ -126,6 +126,16 @@ func initProjectTask() {
return true, nil
})
+ // action_blocked: set kanban state to blocked
+ task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, id := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
+ }
+ return true, nil
+ })
+
// Ensure fmt is used
_ = fmt.Sprintf
}
diff --git a/addons/purchase/models/purchase_order.go b/addons/purchase/models/purchase_order.go
index 1ef9be9..9942d3a 100644
--- a/addons/purchase/models/purchase_order.go
+++ b/addons/purchase/models/purchase_order.go
@@ -160,6 +160,16 @@ func initPurchaseOrder() {
return true, nil
})
+ // button_draft: Reset a cancelled PO back to draft (RFQ).
+ // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_draft()
+ m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, poID := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, poID)
+ }
+ return true, nil
+ })
+
// action_create_bill: Generate a vendor bill (account.move in_invoice) from a confirmed PO.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice()
m.RegisterMethod("action_create_bill", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
diff --git a/addons/sale/models/sale_order.go b/addons/sale/models/sale_order.go
index 1d7ee9f..a5947c9 100644
--- a/addons/sale/models/sale_order.go
+++ b/addons/sale/models/sale_order.go
@@ -584,6 +584,68 @@ func initSaleOrder() {
}, nil
})
+ // action_cancel: Cancel a sale order and linked draft invoices.
+ // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_cancel()
+ m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, soID := range rs.IDs() {
+ // Cancel linked draft invoices
+ rows, _ := env.Tx().Query(env.Ctx(),
+ `SELECT id FROM account_move WHERE invoice_origin = (SELECT name FROM sale_order WHERE id = $1) AND state = 'draft'`, soID)
+ for rows.Next() {
+ var invID int64
+ rows.Scan(&invID)
+ env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'cancel' WHERE id = $1`, invID)
+ }
+ rows.Close()
+ env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'cancel' WHERE id = $1`, soID)
+ }
+ return true, nil
+ })
+
+ // action_draft: Reset a cancelled sale order back to quotation.
+ // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_draft()
+ m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, soID := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, soID)
+ }
+ return true, nil
+ })
+
+ // action_view_invoice: Open invoices linked to this sale order.
+ // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_view_invoice()
+ m.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ soID := rs.IDs()[0]
+ var soName string
+ env.Tx().QueryRow(env.Ctx(), `SELECT name FROM sale_order WHERE id = $1`, soID).Scan(&soName)
+
+ // Find invoices linked to this SO
+ rows, _ := env.Tx().Query(env.Ctx(),
+ `SELECT id FROM account_move WHERE invoice_origin = $1`, soName)
+ var invIDs []interface{}
+ for rows.Next() {
+ var id int64
+ rows.Scan(&id)
+ invIDs = append(invIDs, id)
+ }
+ rows.Close()
+
+ if len(invIDs) == 1 {
+ return map[string]interface{}{
+ "type": "ir.actions.act_window", "res_model": "account.move",
+ "res_id": invIDs[0], "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}}, "target": "current",
+ }, nil
+ }
+ return map[string]interface{}{
+ "type": "ir.actions.act_window", "res_model": "account.move",
+ "view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}},
+ "domain": []interface{}{[]interface{}{"id", "in", invIDs}}, "target": "current",
+ }, nil
+ })
+
// action_create_delivery: Generate a stock picking (delivery) from a confirmed sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._action_confirm() → _create_picking()
m.RegisterMethod("action_create_delivery", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go
index 221c91b..dc53fdd 100644
--- a/addons/stock/models/stock.go
+++ b/addons/stock/models/stock.go
@@ -324,6 +324,17 @@ func initStockPicking() {
return true, nil
})
+ // action_cancel: Cancel a picking and all its moves.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_cancel()
+ m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID)
+ env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID)
+ }
+ return true, nil
+ })
+
// button_validate transitions a picking → done via _action_done on its moves.
// Properly updates quants and clears reservations.
// Mirrors: stock.picking.button_validate()
diff --git a/pkg/service/db.go b/pkg/service/db.go
index c785b4b..0c31cbd 100644
--- a/pkg/service/db.go
+++ b/pkg/service/db.go
@@ -580,6 +580,8 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
@@ -622,6 +624,7 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
('invoice.form', 'account.move', 'form', '