diff --git a/addons/account/models/account_account.go b/addons/account/models/account_account.go index f1e9ba0..f708278 100644 --- a/addons/account/models/account_account.go +++ b/addons/account/models/account_account.go @@ -109,6 +109,7 @@ func initAccountTax() { orm.Boolean("include_base_amount", orm.FieldOpts{String: "Affect Base of Subsequent Taxes"}), orm.Boolean("is_base_affected", orm.FieldOpts{String: "Base Affected by Previous Taxes", Default: true}), orm.Many2many("children_tax_ids", "account.tax", orm.FieldOpts{String: "Children Taxes"}), + orm.Many2one("parent_tax_id", "account.tax", orm.FieldOpts{String: "Parent Tax Group"}), orm.Char("description", orm.FieldOpts{String: "Label on Invoices"}), orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}), orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}), diff --git a/addons/account/models/account_company.go b/addons/account/models/account_company.go new file mode 100644 index 0000000..6103a9a --- /dev/null +++ b/addons/account/models/account_company.go @@ -0,0 +1,27 @@ +package models + +import "odoo-go/pkg/orm" + +// initAccountCompanyExtension extends res.company with accounting lock date fields. +// Mirrors: odoo/addons/account/models/company.py ResCompany (extends res.company) +// +// In Python Odoo: +// +// class ResCompany(models.Model): +// _inherit = 'res.company' +// period_lock_date = fields.Date(...) +// fiscalyear_lock_date = fields.Date(...) +// tax_lock_date = fields.Date(...) +// +// Lock dates prevent posting journal entries before a certain date: +// - period_lock_date: blocks non-adviser users +// - fiscalyear_lock_date: blocks all users +// - tax_lock_date: blocks tax-related entries +func initAccountCompanyExtension() { + c := orm.ExtendModel("res.company") + c.AddFields( + orm.Date("period_lock_date", orm.FieldOpts{String: "Lock Date for Non-Advisers"}), + orm.Date("fiscalyear_lock_date", orm.FieldOpts{String: "Lock Date for All Users"}), + orm.Date("tax_lock_date", orm.FieldOpts{String: "Tax Lock Date"}), + ) +} diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go index adbd800..3df8cd4 100644 --- a/addons/account/models/account_move.go +++ b/addons/account/models/account_move.go @@ -126,6 +126,13 @@ func initAccountMove() { }), ) + // -- Multi-Currency -- + m.AddFields( + orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{ + String: "Company Currency", Related: "company_id.currency_id", + }), + ) + // -- Amounts (Computed) -- m.AddFields( orm.Monetary("amount_untaxed", orm.FieldOpts{String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), @@ -133,6 +140,7 @@ func initAccountMove() { orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_residual", orm.FieldOpts{String: "Amount Due", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_total_in_currency_signed", orm.FieldOpts{String: "Total in Currency Signed", Compute: "_compute_amount", Store: true}), + orm.Monetary("amount_total_signed", orm.FieldOpts{String: "Total Signed", Compute: "_compute_amount", Store: true, CurrencyField: "company_currency_id"}), ) // -- Invoice specific -- @@ -176,11 +184,24 @@ func initAccountMove() { total := untaxed + tax + // amount_total_signed: total in company currency (sign depends on move type) + // For customer invoices/receipts the sign is positive, for credit notes negative. + var moveType string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID, + ).Scan(&moveType) + + sign := 1.0 + if moveType == "out_refund" || moveType == "in_refund" { + sign = -1.0 + } + return orm.Values{ - "amount_untaxed": untaxed, - "amount_tax": tax, - "amount_total": total, - "amount_residual": total, // Simplified: residual = total until payments + "amount_untaxed": untaxed, + "amount_tax": tax, + "amount_total": total, + "amount_residual": total, // Simplified: residual = total until payments + "amount_total_signed": total * sign, }, nil } @@ -188,6 +209,7 @@ func initAccountMove() { m.RegisterCompute("amount_tax", computeAmount) m.RegisterCompute("amount_total", computeAmount) m.RegisterCompute("amount_residual", computeAmount) + m.RegisterCompute("amount_total_signed", computeAmount) // -- Business Methods: State Transitions -- // Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel() @@ -206,6 +228,24 @@ func initAccountMove() { return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state) } + // Check lock dates + // Mirrors: odoo/addons/account/models/account_move.py _check_fiscalyear_lock_date() + var moveDate, periodLock, fiscalLock, taxLock *string + env.Tx().QueryRow(env.Ctx(), + `SELECT m.date::text, c.period_lock_date::text, c.fiscalyear_lock_date::text, c.tax_lock_date::text + FROM account_move m JOIN res_company c ON c.id = m.company_id WHERE m.id = $1`, id, + ).Scan(&moveDate, &periodLock, &fiscalLock, &taxLock) + + if fiscalLock != nil && moveDate != nil && *moveDate <= *fiscalLock { + return nil, fmt.Errorf("account: cannot post entry dated %s, fiscal year is locked until %s", *moveDate, *fiscalLock) + } + if periodLock != nil && moveDate != nil && *moveDate <= *periodLock { + return nil, fmt.Errorf("account: cannot post entry dated %s, period is locked until %s", *moveDate, *periodLock) + } + if taxLock != nil && moveDate != nil && *moveDate <= *taxLock { + return nil, fmt.Errorf("account: cannot post entry dated %s, tax return is locked until %s", *moveDate, *taxLock) + } + // 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) diff --git a/addons/account/models/account_recurring.go b/addons/account/models/account_recurring.go new file mode 100644 index 0000000..abfaf9f --- /dev/null +++ b/addons/account/models/account_recurring.go @@ -0,0 +1,116 @@ +package models + +import "odoo-go/pkg/orm" + +// initAccountRecurring registers account.move.recurring — recurring entry templates. +// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature) +// +// Allows defining templates that automatically generate journal entries +// on a schedule (daily, weekly, monthly, quarterly, yearly). +func initAccountRecurring() { + m := orm.NewModel("account.move.recurring", orm.ModelOpts{ + Description: "Recurring Entry", + Order: "name", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Selection("period", []orm.SelectionItem{ + {Value: "daily", Label: "Daily"}, + {Value: "weekly", Label: "Weekly"}, + {Value: "monthly", Label: "Monthly"}, + {Value: "quarterly", Label: "Quarterly"}, + {Value: "yearly", Label: "Yearly"}, + }, orm.FieldOpts{String: "Period", Required: true, Default: "monthly"}), + orm.Date("date_next", orm.FieldOpts{String: "Next Date", Required: true}), + orm.Date("date_end", orm.FieldOpts{String: "End Date"}), + orm.Many2one("template_move_id", "account.move", orm.FieldOpts{String: "Template Entry"}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "running", Label: "Running"}, + {Value: "done", Label: "Done"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + ) + + // action_start: draft -> running + m.RegisterMethod("action_start", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_move_recurring SET state = 'running' WHERE id = $1 AND state = 'draft'`, id) + } + return true, nil + }) + + // action_done: running -> done + m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_move_recurring SET state = 'done' WHERE id = $1`, id) + } + return true, nil + }) + + // action_generate: create journal entries from the template and advance next date. + // Mirrors: odoo/addons/account/models/account_move.py _cron_recurring_entries() + m.RegisterMethod("action_generate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, recID := range rs.IDs() { + var templateID *int64 + var dateNext, period string + var dateEnd *string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT template_move_id, date_next::text, period, date_end::text + FROM account_move_recurring WHERE id = $1 AND state = 'running'`, recID, + ).Scan(&templateID, &dateNext, &period, &dateEnd) + if err != nil { + continue // not running or not found + } + + if templateID == nil || *templateID == 0 { + continue + } + + // Check if past end date + if dateEnd != nil && dateNext > *dateEnd { + env.Tx().Exec(env.Ctx(), + `UPDATE account_move_recurring SET state = 'done' WHERE id = $1`, recID) + continue + } + + // Copy the template move with the next date + templateRS := env.Model("account.move").Browse(*templateID) + newMove, err := templateRS.Copy(orm.Values{"date": dateNext}) + if err != nil { + continue + } + _ = newMove + + // Advance next date based on period + var interval string + switch period { + case "daily": + interval = "1 day" + case "weekly": + interval = "7 days" + case "monthly": + interval = "1 month" + case "quarterly": + interval = "3 months" + case "yearly": + interval = "1 year" + default: + interval = "1 month" + } + + env.Tx().Exec(env.Ctx(), + `UPDATE account_move_recurring SET date_next = date_next + $1::interval WHERE id = $2`, + interval, recID) + } + return true, nil + }) +} diff --git a/addons/account/models/account_tax_calc.go b/addons/account/models/account_tax_calc.go index 7b5ff66..fd47530 100644 --- a/addons/account/models/account_tax_calc.go +++ b/addons/account/models/account_tax_calc.go @@ -1,6 +1,10 @@ package models -import "odoo-go/pkg/orm" +import ( + "fmt" + + "odoo-go/pkg/orm" +) // TaxResult holds the result of computing a tax on an amount. type TaxResult struct { @@ -13,6 +17,9 @@ type TaxResult struct { // ComputeTax calculates tax for a given base amount. // Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() +// +// Supports amount_type: percent, fixed, division, group. +// For group taxes, iterates children in sequence order and sums their results. func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResult, error) { var name string var amount float64 @@ -27,6 +34,12 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu return nil, err } + // Handle group taxes: iterate child taxes and sum their amounts. + // Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() group branch + if amountType == "group" { + return computeGroupTax(env, taxID, name, baseAmount) + } + var taxAmount float64 switch amountType { case "percent": @@ -45,7 +58,99 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu } } - // Find the tax account (from repartition lines) + accountID := findTaxAccount(env, taxID) + + return &TaxResult{ + TaxID: taxID, + TaxName: name, + Amount: taxAmount, + Base: baseAmount, + AccountID: accountID, + }, nil +} + +// ComputeTaxes calculates multiple taxes on a base amount, returning all results. +// Mirrors: odoo/addons/account/models/account_tax.py AccountTax.compute_all() +func ComputeTaxes(env *orm.Environment, taxIDs []int64, baseAmount float64) ([]*TaxResult, error) { + var results []*TaxResult + for _, taxID := range taxIDs { + result, err := ComputeTax(env, taxID, baseAmount) + if err != nil { + return nil, fmt.Errorf("account: compute tax %d: %w", taxID, err) + } + results = append(results, result) + } + return results, nil +} + +// computeGroupTax handles amount_type='group': reads child taxes and sums their amounts. +// Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() for groups +func computeGroupTax(env *orm.Environment, parentID int64, parentName string, baseAmount float64) (*TaxResult, error) { + // Read child taxes via the many2many relation table + // The ORM creates: account_tax_children_tax_ids_rel (or similar) + // We query the children_tax_ids M2M relationship + childRows, err := env.Tx().Query(env.Ctx(), + `SELECT ct.id FROM account_tax ct + JOIN account_tax_account_tax_children_tax_ids_rel rel ON rel.account_tax_id2 = ct.id + WHERE rel.account_tax_id1 = $1 + ORDER BY ct.sequence, ct.id`, parentID) + if err != nil { + // Fallback: try with parent_tax_id if the M2M table doesn't exist + childRows, err = env.Tx().Query(env.Ctx(), + `SELECT id FROM account_tax WHERE parent_tax_id = $1 ORDER BY sequence, id`, parentID) + if err != nil { + return &TaxResult{ + TaxID: parentID, TaxName: parentName, Amount: 0, Base: baseAmount, + }, nil + } + } + defer childRows.Close() + + var totalTax float64 + var lastAccountID int64 + currentBase := baseAmount + + for childRows.Next() { + var childID int64 + if err := childRows.Scan(&childID); err != nil { + continue + } + + childResult, err := ComputeTax(env, childID, currentBase) + if err != nil { + continue + } + totalTax += childResult.Amount + + if childResult.AccountID > 0 { + lastAccountID = childResult.AccountID + } + + // Check if this child tax affects the base of subsequent taxes + var includeBase bool + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(include_base_amount, false) FROM account_tax WHERE id = $1`, childID, + ).Scan(&includeBase) + if includeBase { + currentBase += childResult.Amount + } + } + + if lastAccountID == 0 { + lastAccountID = findTaxAccount(env, parentID) + } + + return &TaxResult{ + TaxID: parentID, + TaxName: parentName, + Amount: totalTax, + Base: baseAmount, + AccountID: lastAccountID, + }, nil +} + +// findTaxAccount looks up the account for a tax from its repartition lines. +func findTaxAccount(env *orm.Environment, taxID int64) int64 { var accountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(account_id, 0) FROM account_tax_repartition_line @@ -60,11 +165,5 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu ).Scan(&accountID) } - return &TaxResult{ - TaxID: taxID, - TaxName: name, - Amount: taxAmount, - Base: baseAmount, - AccountID: accountID, - }, nil + return accountID } diff --git a/addons/account/models/init.go b/addons/account/models/init.go index 1093382..c8cdd49 100644 --- a/addons/account/models/init.go +++ b/addons/account/models/init.go @@ -15,4 +15,6 @@ func Init() { initAccountTaxReport() initAccountReportLine() initAccountAnalytic() + initAccountRecurring() + initAccountCompanyExtension() } diff --git a/addons/product/models/product.go b/addons/product/models/product.go index 6da96f9..6d1d5db 100644 --- a/addons/product/models/product.go +++ b/addons/product/models/product.go @@ -198,19 +198,63 @@ func initProductPricelist() { orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 16}), orm.One2many("item_ids", "product.pricelist.item", "pricelist_id", orm.FieldOpts{String: "Pricelist Rules"}), + orm.Many2one("country_group_id", "res.country.group", orm.FieldOpts{String: "Country Group"}), ) + // get_product_price: returns the price for a product in this pricelist. + // Mirrors: odoo/addons/product/models/product_pricelist.py Pricelist._get_product_price() + m.RegisterMethod("get_product_price", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 2 { + return float64(0), nil + } + env := rs.Env() + pricelistID := rs.IDs()[0] + productID, _ := args[0].(float64) + qty, _ := args[1].(float64) + if qty <= 0 { + qty = 1 + } + + // Find matching pricelist item + var price float64 + err := env.Tx().QueryRow(env.Ctx(), ` + SELECT CASE + WHEN pi.compute_price = 'fixed' THEN pi.fixed_price + WHEN pi.compute_price = 'percentage' THEN pt.list_price * (1 - pi.percent_price / 100) + ELSE pt.list_price + END + FROM product_pricelist_item pi + JOIN product_product pp ON pp.id = $2 + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pi.pricelist_id = $1 + AND (pi.product_id = $2 OR pi.product_id IS NULL) + AND (pi.min_quantity <= $3 OR pi.min_quantity IS NULL) + ORDER BY pi.sequence, pi.id LIMIT 1`, + pricelistID, int64(productID), qty, + ).Scan(&price) + + if err != nil { + // Fallback to list price + env.Tx().QueryRow(env.Ctx(), + `SELECT pt.list_price FROM product_product pp JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE pp.id = $1`, + int64(productID)).Scan(&price) + } + return price, nil + }) + // product.pricelist.item — Price rules orm.NewModel("product.pricelist.item", orm.ModelOpts{ Description: "Pricelist Rule", - Order: "applied_on, min_quantity desc, categ_id desc, id desc", + Order: "sequence, id", }).AddFields( orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{ String: "Pricelist", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), - orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product"}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product Variant"}), + orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}), orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}), orm.Float("min_quantity", orm.FieldOpts{String: "Min. Quantity"}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}), orm.Selection("applied_on", []orm.SelectionItem{ {Value: "3_global", Label: "All Products"}, {Value: "2_product_category", Label: "Product Category"}, diff --git a/addons/purchase/models/init.go b/addons/purchase/models/init.go index 35411be..68184c0 100644 --- a/addons/purchase/models/init.go +++ b/addons/purchase/models/init.go @@ -3,4 +3,5 @@ package models func Init() { initPurchaseOrder() initPurchaseOrderLine() + initPurchaseAgreement() } diff --git a/addons/purchase/models/purchase_agreement.go b/addons/purchase/models/purchase_agreement.go new file mode 100644 index 0000000..9774d16 --- /dev/null +++ b/addons/purchase/models/purchase_agreement.go @@ -0,0 +1,81 @@ +package models + +import "odoo-go/pkg/orm" + +// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line. +// Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py + +func initPurchaseAgreement() { + m := orm.NewModel("purchase.requisition", orm.ModelOpts{ + Description: "Purchase Agreement", + Order: "name desc", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Reference", Readonly: true, Default: "New"}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}), + orm.Selection("type_id", []orm.SelectionItem{ + {Value: "blanket", Label: "Blanket Order"}, + {Value: "purchase", Label: "Purchase Tender"}, + }, orm.FieldOpts{String: "Agreement Type", Default: "blanket"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "ongoing", Label: "Confirmed"}, + {Value: "in_progress", Label: "Bid Selection"}, + {Value: "open", Label: "Bid Selection"}, + {Value: "done", Label: "Closed"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Date("date_end", orm.FieldOpts{String: "Agreement Deadline"}), + orm.One2many("line_ids", "purchase.requisition.line", "requisition_id", orm.FieldOpts{String: "Lines"}), + ) + + // action_confirm: draft → ongoing + m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id) + } + return true, nil + }) + + // action_done: close the agreement + m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id) + } + return true, nil + }) + + // action_cancel + m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id) + } + return true, nil + }) + + initPurchaseRequisitionLine() +} + +func initPurchaseRequisitionLine() { + orm.NewModel("purchase.requisition.line", orm.ModelOpts{ + Description: "Purchase Agreement Line", + Order: "id", + }).AddFields( + orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{ + String: "Agreement", Required: true, OnDelete: orm.OnDeleteCascade, + }), + orm.Many2one("product_id", "product.product", orm.FieldOpts{ + String: "Product", Required: true, + }), + orm.Float("product_qty", orm.FieldOpts{String: "Quantity"}), + orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}), + ) +} diff --git a/addons/purchase/models/purchase_order.go b/addons/purchase/models/purchase_order.go index 9942d3a..bcdd4ec 100644 --- a/addons/purchase/models/purchase_order.go +++ b/addons/purchase/models/purchase_order.go @@ -199,7 +199,7 @@ func initPurchaseOrder() { // Read PO lines to generate invoice lines rows, err := env.Tx().Query(env.Ctx(), - `SELECT COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0) + `SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0) FROM purchase_order_line WHERE order_id = $1 ORDER BY sequence, id`, poID) if err != nil { @@ -207,6 +207,7 @@ func initPurchaseOrder() { } type poLine struct { + id int64 name string qty float64 price float64 @@ -215,7 +216,7 @@ func initPurchaseOrder() { var lines []poLine for rows.Next() { var l poLine - if err := rows.Scan(&l.name, &l.qty, &l.price, &l.discount); err != nil { + if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount); err != nil { rows.Close() return nil, err } @@ -258,6 +259,13 @@ func initPurchaseOrder() { companyID, journalID) } + // Update qty_invoiced on PO lines + for _, l := range lines { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`, + l.qty, l.id) + } + billIDs = append(billIDs, billID) // Update PO invoice_status diff --git a/addons/sale/models/init.go b/addons/sale/models/init.go index 925444a..edfcb5d 100644 --- a/addons/sale/models/init.go +++ b/addons/sale/models/init.go @@ -6,6 +6,7 @@ func Init() { initSaleOrder() initSaleOrderLine() initResPartnerSaleExtension() + initSaleMargin() } // initResPartnerSaleExtension extends res.partner with sale-specific fields. diff --git a/addons/sale/models/sale_margin.go b/addons/sale/models/sale_margin.go new file mode 100644 index 0000000..9b87131 --- /dev/null +++ b/addons/sale/models/sale_margin.go @@ -0,0 +1,68 @@ +package models + +import "odoo-go/pkg/orm" + +// initSaleMargin adds margin fields to sale.order.line and sale.order. +// Mirrors: odoo/addons/sale_margin/models/sale_order_line.py + sale_order.py + +func initSaleMargin() { + // -- Extend sale.order.line with margin fields -- + line := orm.ExtendModel("sale.order.line") + line.AddFields( + orm.Float("purchase_price", orm.FieldOpts{String: "Cost Price"}), + orm.Monetary("margin", orm.FieldOpts{ + String: "Margin", Compute: "_compute_margin", Store: true, CurrencyField: "currency_id", + }), + orm.Float("margin_percent", orm.FieldOpts{ + String: "Margin (%)", Compute: "_compute_margin", Store: true, + }), + ) + + computeLineMargin := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + var subtotal, cost float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(price_subtotal, 0), COALESCE(purchase_price, 0) * COALESCE(product_uom_qty, 0) + FROM sale_order_line WHERE id = $1`, lineID, + ).Scan(&subtotal, &cost) + margin := subtotal - cost + pct := float64(0) + if subtotal > 0 { + pct = margin / subtotal * 100 + } + return orm.Values{"margin": margin, "margin_percent": pct}, nil + } + line.RegisterCompute("margin", computeLineMargin) + line.RegisterCompute("margin_percent", computeLineMargin) + + // -- Extend sale.order with total margin -- + so := orm.ExtendModel("sale.order") + so.AddFields( + orm.Monetary("margin", orm.FieldOpts{ + String: "Margin", Compute: "_compute_margin", Store: true, CurrencyField: "currency_id", + }), + orm.Float("margin_percent", orm.FieldOpts{ + String: "Margin (%)", Compute: "_compute_margin", Store: true, + }), + ) + + computeSOMargin := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + soID := rs.IDs()[0] + var margin, untaxed float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(margin), 0), COALESCE(SUM(price_subtotal), 0) + FROM sale_order_line WHERE order_id = $1 + AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, + soID, + ).Scan(&margin, &untaxed) + pct := float64(0) + if untaxed > 0 { + pct = margin / untaxed * 100 + } + return orm.Values{"margin": margin, "margin_percent": pct}, nil + } + so.RegisterCompute("margin", computeSOMargin) + so.RegisterCompute("margin_percent", computeSOMargin) +} diff --git a/addons/sale/models/sale_order.go b/addons/sale/models/sale_order.go index a5947c9..abc128e 100644 --- a/addons/sale/models/sale_order.go +++ b/addons/sale/models/sale_order.go @@ -748,6 +748,134 @@ func initSaleOrder() { } return pickingIDs, nil }) + + // action_create_down_payment: Create a deposit invoice for a percentage of the SO total. + // Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py + m.RegisterMethod("action_create_down_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + soID := rs.IDs()[0] + percentage := float64(10) // Default 10% + if len(args) > 0 { + if p, ok := args[0].(float64); ok { + percentage = p + } + } + + var total float64 + var partnerID, companyID, currencyID, journalID int64 + var soName string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(amount_total, 0), partner_id, company_id, currency_id, + COALESCE(journal_id, 0), COALESCE(name, '') + FROM sale_order WHERE id = $1`, soID, + ).Scan(&total, &partnerID, &companyID, ¤cyID, &journalID, &soName) + if err != nil { + return nil, fmt.Errorf("sale: read SO %d for down payment: %w", soID, err) + } + + downAmount := total * percentage / 100 + + // Find sales journal if not set on SO + if journalID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_journal + WHERE type = 'sale' AND active = true AND company_id = $1 + ORDER BY sequence, id LIMIT 1`, companyID, + ).Scan(&journalID) + } + if journalID == 0 { + journalID = 1 + } + + // Create deposit invoice + invoiceRS := env.Model("account.move") + inv, err := invoiceRS.Create(orm.Values{ + "move_type": "out_invoice", + "partner_id": partnerID, + "company_id": companyID, + "currency_id": currencyID, + "journal_id": journalID, + "invoice_origin": soName, + "date": time.Now().Format("2006-01-02"), + }) + if err != nil { + return nil, fmt.Errorf("sale: create down payment invoice for SO %d: %w", soID, err) + } + moveID := inv.ID() + + // Find revenue account + var revenueAccountID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_account WHERE code = '8300' AND company_id = $1 LIMIT 1`, + companyID).Scan(&revenueAccountID) + if revenueAccountID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_account + WHERE account_type LIKE 'income%%' AND company_id = $1 + ORDER BY code LIMIT 1`, companyID).Scan(&revenueAccountID) + } + + // Find receivable account + var receivableAccountID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_account + WHERE account_type = 'asset_receivable' AND company_id = $1 + ORDER BY code LIMIT 1`, companyID).Scan(&receivableAccountID) + if receivableAccountID == 0 { + receivableAccountID = revenueAccountID + } + + lineRS := env.Model("account.move.line") + + // Down payment product line (credit) + _, err = lineRS.Create(orm.Values{ + "move_id": moveID, + "name": fmt.Sprintf("Down payment of %.0f%%", percentage), + "quantity": 1.0, + "price_unit": downAmount, + "account_id": revenueAccountID, + "company_id": companyID, + "journal_id": journalID, + "currency_id": currencyID, + "partner_id": partnerID, + "display_type": "product", + "debit": 0.0, + "credit": downAmount, + "balance": -downAmount, + }) + if err != nil { + return nil, fmt.Errorf("sale: create down payment line: %w", err) + } + + // Receivable line (debit) + _, err = lineRS.Create(orm.Values{ + "move_id": moveID, + "name": "/", + "quantity": 1.0, + "account_id": receivableAccountID, + "company_id": companyID, + "journal_id": journalID, + "currency_id": currencyID, + "partner_id": partnerID, + "display_type": "payment_term", + "debit": downAmount, + "credit": 0.0, + "balance": downAmount, + "amount_residual": downAmount, + "amount_residual_currency": downAmount, + }) + if err != nil { + return nil, fmt.Errorf("sale: create down payment receivable line: %w", err) + } + + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "account.move", + "res_id": moveID, + "view_mode": "form", + "target": "current", + }, nil + }) } // initSaleOrderLine registers sale.order.line — individual line items on a sales order. diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go index f6ce7a4..39d9167 100644 --- a/addons/stock/models/stock.go +++ b/addons/stock/models/stock.go @@ -21,9 +21,12 @@ func initStock() { initStockMoveLine() initStockQuant() initStockLot() + initStockRoute() + initStockRule() initStockOrderpoint() initStockScrap() initStockInventory() + initProductStockExtension() } // initStockWarehouse registers stock.warehouse. @@ -388,6 +391,87 @@ func initStockPicking() { } return true, nil }) + + // action_return creates a reverse transfer (return picking) with swapped locations. + // Copies all done moves from the original picking to the return picking. + // Mirrors: odoo/addons/stock/wizard/stock_picking_return.py + m.RegisterMethod("action_return", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + pickingID := rs.IDs()[0] + + // Read original picking + var partnerID, pickTypeID, locID, locDestID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(partner_id,0), picking_type_id, location_id, location_dest_id + FROM stock_picking WHERE id = $1`, pickingID, + ).Scan(&partnerID, &pickTypeID, &locID, &locDestID) + if err != nil { + return nil, fmt.Errorf("stock: read picking %d for return: %w", pickingID, err) + } + + // Create return picking (swap source and destination) + returnRS := env.Model("stock.picking") + returnVals := orm.Values{ + "name": fmt.Sprintf("Return of %d", pickingID), + "picking_type_id": pickTypeID, + "location_id": locDestID, // Swap! + "location_dest_id": locID, // Swap! + "company_id": int64(1), + "state": "draft", + "scheduled_date": time.Now().Format("2006-01-02"), + } + if partnerID > 0 { + returnVals["partner_id"] = partnerID + } + + returnPicking, err := returnRS.Create(returnVals) + if err != nil { + return nil, fmt.Errorf("stock: create return picking for %d: %w", pickingID, err) + } + + // Copy moves with swapped locations + moveRows, err := env.Tx().Query(env.Ctx(), + `SELECT product_id, product_uom_qty, product_uom FROM stock_move + WHERE picking_id = $1 AND state = 'done'`, pickingID) + if err != nil { + return nil, fmt.Errorf("stock: read moves for return of picking %d: %w", pickingID, err) + } + defer moveRows.Close() + + moveRS := env.Model("stock.move") + for moveRows.Next() { + var prodID int64 + var qty float64 + var uomID int64 + if err := moveRows.Scan(&prodID, &qty, &uomID); err != nil { + return nil, fmt.Errorf("stock: scan move for return of picking %d: %w", pickingID, err) + } + _, err := moveRS.Create(orm.Values{ + "name": fmt.Sprintf("Return: product %d", prodID), + "product_id": prodID, + "product_uom_qty": qty, + "product_uom": uomID, + "location_id": locDestID, + "location_dest_id": locID, + "picking_id": returnPicking.ID(), + "company_id": int64(1), + "state": "draft", + "date": time.Now(), + }) + if err != nil { + return nil, fmt.Errorf("stock: create return move for picking %d: %w", pickingID, err) + } + } + + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "stock.picking", + "res_id": returnPicking.ID(), + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + }) } // updateQuant adjusts the on-hand quantity for a product at a location. @@ -805,7 +889,23 @@ func initStockLot() { String: "Company", Required: true, Index: true, }), orm.Text("note", orm.FieldOpts{String: "Description"}), + orm.Date("expiration_date", orm.FieldOpts{String: "Expiration Date"}), + orm.Date("use_date", orm.FieldOpts{String: "Best Before Date"}), + orm.Date("removal_date", orm.FieldOpts{String: "Removal Date"}), + orm.Float("product_qty", orm.FieldOpts{String: "Quantity", Compute: "_compute_qty"}), ) + + // Compute lot quantity from quants. + // Mirrors: odoo/addons/stock/models/stock_lot.py StockLot._product_qty() + m.RegisterCompute("product_qty", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lotID := rs.IDs()[0] + var qty float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(quantity), 0) FROM stock_quant WHERE lot_id = $1`, lotID, + ).Scan(&qty) + return orm.Values{"product_qty": qty}, nil + }) } // initStockOrderpoint registers stock.warehouse.orderpoint — reorder rules. @@ -981,6 +1081,75 @@ func initStockInventory() { }) } +// initStockRule registers stock.rule — procurement/push/pull rules. +// Mirrors: odoo/addons/stock/models/stock_rule.py +func initStockRule() { + m := orm.NewModel("stock.rule", orm.ModelOpts{ + Description: "Stock Rule", + Order: "sequence, id", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Selection("action", []orm.SelectionItem{ + {Value: "pull", Label: "Pull From"}, + {Value: "push", Label: "Push To"}, + {Value: "pull_push", Label: "Pull & Push"}, + {Value: "buy", Label: "Buy"}, + {Value: "manufacture", Label: "Manufacture"}, + }, orm.FieldOpts{String: "Action", Required: true}), + orm.Many2one("location_src_id", "stock.location", orm.FieldOpts{String: "Source Location"}), + orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{String: "Destination Location"}), + orm.Many2one("route_id", "stock.route", orm.FieldOpts{String: "Route"}), + orm.Many2one("picking_type_id", "stock.picking.type", orm.FieldOpts{String: "Operation Type"}), + orm.Many2one("warehouse_id", "stock.warehouse", orm.FieldOpts{String: "Warehouse"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 20}), + orm.Integer("delay", orm.FieldOpts{String: "Delay (days)"}), + orm.Selection("procure_method", []orm.SelectionItem{ + {Value: "make_to_stock", Label: "Take From Stock"}, + {Value: "make_to_order", Label: "Trigger Another Rule"}, + {Value: "mts_else_mto", Label: "Take from Stock, if unavailable, Trigger Another Rule"}, + }, orm.FieldOpts{String: "Supply Method", Default: "make_to_stock"}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + ) + _ = m +} + +// initStockRoute registers stock.route — inventory routes linking rules. +// Mirrors: odoo/addons/stock/models/stock_route.py +func initStockRoute() { + m := orm.NewModel("stock.route", orm.ModelOpts{ + Description: "Inventory Route", + Order: "sequence, id", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Route", Required: true, Translate: true}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 0}), + orm.One2many("rule_ids", "stock.rule", "route_id", orm.FieldOpts{String: "Rules"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Boolean("product_selectable", orm.FieldOpts{String: "Applicable on Product", Default: true}), + orm.Boolean("product_categ_selectable", orm.FieldOpts{String: "Applicable on Product Category"}), + orm.Boolean("warehouse_selectable", orm.FieldOpts{String: "Applicable on Warehouse"}), + orm.Many2many("warehouse_ids", "stock.warehouse", orm.FieldOpts{String: "Warehouses"}), + ) + _ = m +} + +// initProductStockExtension extends product.template with stock-specific fields. +// Mirrors: odoo/addons/stock/models/product.py (tracking, route_ids) +func initProductStockExtension() { + pt := orm.ExtendModel("product.template") + pt.AddFields( + orm.Selection("tracking", []orm.SelectionItem{ + {Value: "none", Label: "No Tracking"}, + {Value: "lot", Label: "By Lots"}, + {Value: "serial", Label: "By Unique Serial Number"}, + }, orm.FieldOpts{String: "Tracking", Default: "none"}), + orm.Many2many("route_ids", "stock.route", orm.FieldOpts{String: "Routes"}), + ) +} + // toInt64 converts various numeric types to int64 for use in business methods. func toInt64(v interface{}) int64 { switch n := v.(type) {