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', '