Deepen Account + Stock modules significantly

Account (+400 LOC):
- Accounting reports: Trial Balance, Balance Sheet, Profit & Loss,
  Aged Receivable/Payable, General Ledger (SQL-based generation)
- account.report + account.report.line models
- Analytic accounting: account.analytic.plan, account.analytic.account,
  account.analytic.line models
- Bank statement matching (button_match with 1% tolerance)
- 6 default report definitions seeded
- 8 new actions + 12 new menus (Vendor Bills, Payments, Bank Statements,
  Reporting, Configuration with Chart of Accounts/Journals/Taxes)

Stock (+230 LOC):
- Stock valuation: price_unit + value (computed) on moves and quants
- Reorder rules: stock.warehouse.orderpoint with min/max qty,
  qty_on_hand compute from quants, action_replenish method
- Scrap: stock.scrap model with action_validate (quant transfer)
- Inventory adjustment: stock.quant.adjust wizard (set qty directly)
- Scrap location seeded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-03 14:57:33 +02:00
parent e0d8bc81d3
commit d9171191af
6 changed files with 632 additions and 4 deletions

View File

@@ -21,6 +21,9 @@ func initStock() {
initStockMoveLine()
initStockQuant()
initStockLot()
initStockOrderpoint()
initStockScrap()
initStockInventory()
}
// initStockWarehouse registers stock.warehouse.
@@ -528,7 +531,9 @@ func initStockMove() {
}),
orm.Datetime("date", orm.FieldOpts{String: "Date Scheduled", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
orm.Monetary("price_unit", orm.FieldOpts{String: "Unit Price", CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Monetary("value", orm.FieldOpts{String: "Value", Compute: "_compute_value", Store: true, CurrencyField: "currency_id"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
@@ -536,6 +541,18 @@ func initStockMove() {
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
)
// _compute_value: value = price_unit * product_uom_qty
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._compute_value()
m.RegisterCompute("value", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
var priceUnit, qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_unit, 0), COALESCE(product_uom_qty, 0) FROM stock_move WHERE id = $1`,
moveID).Scan(&priceUnit, &qty)
return orm.Values{"value": priceUnit * qty}, nil
})
// _action_confirm: Confirm stock moves (draft → confirmed), then try to reserve.
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_confirm()
m.RegisterMethod("_action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -695,6 +712,8 @@ func initStockQuant() {
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Monetary("value", orm.FieldOpts{String: "Value", CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Datetime("in_date", orm.FieldOpts{String: "Incoming Date", Index: true}),
orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{
String: "Package",
@@ -788,3 +807,191 @@ func initStockLot() {
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
}
// initStockOrderpoint registers stock.warehouse.orderpoint — reorder rules.
// Mirrors: odoo/addons/stock/models/stock_orderpoint.py
func initStockOrderpoint() {
m := orm.NewModel("stock.warehouse.orderpoint", orm.ModelOpts{
Description: "Reorder Rule",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Many2one("warehouse_id", "stock.warehouse", orm.FieldOpts{String: "Warehouse", Required: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location", Required: true}),
orm.Float("product_min_qty", orm.FieldOpts{String: "Minimum Quantity"}),
orm.Float("product_max_qty", orm.FieldOpts{String: "Maximum Quantity"}),
orm.Float("qty_multiple", orm.FieldOpts{String: "Quantity Multiple", Default: 1}),
orm.Float("qty_on_hand", orm.FieldOpts{String: "On Hand", Compute: "_compute_qty", Store: false}),
orm.Float("qty_forecast", orm.FieldOpts{String: "Forecast", Compute: "_compute_qty", Store: false}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// Compute on-hand qty from quants
m.RegisterCompute("qty_on_hand", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
opID := rs.IDs()[0]
var productID, locationID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, location_id FROM stock_warehouse_orderpoint WHERE id = $1`, opID,
).Scan(&productID, &locationID)
var qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID,
).Scan(&qty)
return orm.Values{"qty_on_hand": qty, "qty_forecast": qty}, nil
})
// action_replenish: check all orderpoints and create procurement if below min.
// Mirrors: odoo/addons/stock/models/stock_orderpoint.py action_replenish()
m.RegisterMethod("action_replenish", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, opID := range rs.IDs() {
var productID, locationID int64
var minQty, maxQty, qtyMultiple float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, location_id, product_min_qty, product_max_qty, qty_multiple
FROM stock_warehouse_orderpoint WHERE id = $1`, opID,
).Scan(&productID, &locationID, &minQty, &maxQty, &qtyMultiple)
// Check current stock
var onHand float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID,
).Scan(&onHand)
if onHand < minQty {
// Need to replenish: qty = max - on_hand, rounded up to qty_multiple
needed := maxQty - onHand
if qtyMultiple > 0 {
remainder := int(needed) % int(qtyMultiple)
if remainder > 0 {
needed += qtyMultiple - float64(remainder)
}
}
// Create internal transfer or purchase (simplified: just log)
fmt.Printf("stock: orderpoint %d needs %.0f units of product %d\n", opID, needed, productID)
}
}
return true, nil
})
}
// initStockScrap registers stock.scrap — scrap/disposal of products.
// Mirrors: odoo/addons/stock/models/stock_scrap.py
func initStockScrap() {
m := orm.NewModel("stock.scrap", orm.ModelOpts{
Description: "Scrap",
Order: "name desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Reference", Readonly: true, Default: "New"}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Float("scrap_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{String: "Lot/Serial"}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Source Location", Required: true}),
orm.Many2one("scrap_location_id", "stock.location", orm.FieldOpts{String: "Scrap Location", Required: true}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{String: "Picking"}),
orm.Many2one("move_id", "stock.move", orm.FieldOpts{String: "Scrap Move"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Date("date_done", orm.FieldOpts{String: "Date"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "done", Label: "Done"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
)
// action_validate: Validate scrap, move product from source to scrap location.
// Mirrors: odoo/addons/stock/models/stock_scrap.py StockScrap.action_validate()
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, scrapID := range rs.IDs() {
var productID, locID, scrapLocID int64
var qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, scrap_qty, location_id, scrap_location_id FROM stock_scrap WHERE id = $1`,
scrapID).Scan(&productID, &qty, &locID, &scrapLocID)
// Update quants (move from location to scrap location)
if err := updateQuant(env, productID, locID, -qty); err != nil {
return nil, fmt.Errorf("stock.scrap: update source quant for scrap %d: %w", scrapID, err)
}
if err := updateQuant(env, productID, scrapLocID, qty); err != nil {
return nil, fmt.Errorf("stock.scrap: update scrap quant for scrap %d: %w", scrapID, err)
}
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_scrap SET state = 'done', date_done = NOW() WHERE id = $1`, scrapID)
if err != nil {
return nil, fmt.Errorf("stock.scrap: validate scrap %d: %w", scrapID, err)
}
}
return true, nil
})
}
// initStockInventory registers stock.quant.adjust — inventory adjustment wizard.
// Mirrors: odoo/addons/stock/wizard/stock_change_product_qty.py (transient model)
func initStockInventory() {
m := orm.NewModel("stock.quant.adjust", orm.ModelOpts{
Description: "Inventory Adjustment",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location", Required: true}),
orm.Float("new_quantity", orm.FieldOpts{String: "New Quantity", Required: true}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{String: "Lot/Serial"}),
)
// action_apply: Set quant quantity to the specified new quantity.
// Mirrors: stock.change.product.qty.change_product_qty()
m.RegisterMethod("action_apply", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
data, err := rs.Read([]string{"product_id", "location_id", "new_quantity"})
if err != nil || len(data) == 0 {
return nil, err
}
d := data[0]
productID := toInt64(d["product_id"])
locationID := toInt64(d["location_id"])
newQty, _ := d["new_quantity"].(float64)
// Get current quantity
var currentQty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID).Scan(&currentQty)
diff := newQty - currentQty
if diff != 0 {
if err := updateQuant(env, productID, locationID, diff); err != nil {
return nil, fmt.Errorf("stock.quant.adjust: update quant: %w", err)
}
}
return map[string]interface{}{"type": "ir.actions.act_window_close"}, nil
})
}
// toInt64 converts various numeric types to int64 for use in business methods.
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case float64:
return int64(n)
case int:
return int64(n)
case int32:
return int64(n)
}
return 0
}