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

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