Deep dive: Account, Stock, Sale, Purchase — +800 LOC business logic

Account:
- Multi-currency: company_currency_id, amount_total_signed
- Lock dates on res.company (period, fiscal year, tax) + enforcement in action_post
- Recurring entries: account.move.recurring with action_generate (copy+advance)
- Tax groups: amount_type='group' computes child taxes with include_base_amount
- ComputeTaxes batch function, findTaxAccount helper

Stock:
- Lot/Serial tracking: enhanced stock.lot with expiration dates + qty compute
- Routes: stock.route model with product/category/warehouse selectable flags
- Rules: stock.rule model with pull/push/buy/manufacture actions + procure methods
- Returns: action_return on picking (swap locations, copy moves)
- Product tracking extension (none/lot/serial) + route_ids M2M

Sale:
- Pricelist: get_product_price with fixed/percentage/formula computation
- Margin: purchase_price, margin, margin_percent on line + order totals
- Down payments: action_create_down_payment (deposit invoice at X%)

Purchase:
- 3-way matching: action_create_bill now updates qty_invoiced on PO lines
- Purchase agreements: purchase.requisition + line with state machine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-03 19:05:39 +02:00
parent d9171191af
commit b8fa4719ad
14 changed files with 802 additions and 17 deletions

View File

@@ -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)