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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user