Files
goodie/addons/stock/models/stock.go
Marc bdb97f98ad Massive module expansion: Stock, CRM, HR — +2895 LOC
Stock (1193→2867 LOC):
- Valuation layers (FIFO consumption, product valuation history)
- Landed costs (split by equal/qty/cost/weight/volume, validation)
- Stock reports (by product, by location, move history, valuation)
- Forecasting (on_hand + incoming - outgoing per product)
- Batch transfers (confirm/assign/done with picking delegation)
- Barcode interface (scan product/lot/package/location, qty increment)

CRM (233→1113 LOC):
- Sales teams with dashboard KPIs (opportunity count/amount/unassigned)
- Team members with lead capacity + round-robin auto-assignment
- Lead extended: activities, UTM tracking, scoring, address fields
- Lead methods: merge, duplicate, schedule activity, set priority/stage
- Pipeline analysis (stages, win rate, conversion, team/salesperson perf)
- Partner onchange (auto-populate contact from partner)

HR (223→520 LOC):
- Leave management: hr.leave.type, hr.leave, hr.leave.allocation
  with full approval workflow (draft→confirm→validate/refuse)
- Attendance: check in/out with computed worked_hours
- Expenses: hr.expense + hr.expense.sheet with state machine
- Skills/Resume: skill types, employee skills, resume lines
- Employee extensions: skills, attendance, leave count links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:21:52 +02:00

1174 lines
45 KiB
Go

package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initStock registers all stock models.
// Mirrors: odoo/addons/stock/models/stock_warehouse.py,
// stock_location.py, stock_picking.py, stock_move.py,
// stock_move_line.py, stock_quant.py, stock_lot.py
func initStock() {
initStockWarehouse()
initStockLocation()
initStockPickingType()
initStockPicking()
initStockMove()
initStockMoveLine()
initStockQuant()
initStockLot()
initStockRoute()
initStockRule()
initStockOrderpoint()
initStockScrap()
initStockInventory()
initProductStockExtension()
initStockValuationLayer()
initStockLandedCost()
initStockReport()
initStockForecast()
initStockPickingBatch()
initStockPickingBatchExtension()
initStockBarcode()
}
// initStockWarehouse registers stock.warehouse.
// Mirrors: odoo/addons/stock/models/stock_warehouse.py
func initStockWarehouse() {
m := orm.NewModel("stock.warehouse", orm.ModelOpts{
Description: "Warehouse",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Warehouse", Required: true, Index: true}),
orm.Char("code", orm.FieldOpts{String: "Short Name", Required: true, Size: 5}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Address",
}),
orm.Many2one("lot_stock_id", "stock.location", orm.FieldOpts{
String: "Location Stock", Required: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("wh_input_stock_loc_id", "stock.location", orm.FieldOpts{
String: "Input Location",
}),
orm.Many2one("wh_output_stock_loc_id", "stock.location", orm.FieldOpts{
String: "Output Location",
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
// initStockLocation registers stock.location.
// Mirrors: odoo/addons/stock/models/stock_location.py
func initStockLocation() {
m := orm.NewModel("stock.location", orm.ModelOpts{
Description: "Location",
Order: "complete_name, id",
RecName: "complete_name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Location Name", Required: true, Translate: true}),
orm.Char("complete_name", orm.FieldOpts{
String: "Full Location Name", Compute: "_compute_complete_name", Store: true,
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("usage", []orm.SelectionItem{
{Value: "supplier", Label: "Vendor Location"},
{Value: "view", Label: "View"},
{Value: "internal", Label: "Internal Location"},
{Value: "customer", Label: "Customer Location"},
{Value: "inventory", Label: "Inventory Loss"},
{Value: "production", Label: "Production"},
{Value: "transit", Label: "Transit Location"},
}, orm.FieldOpts{String: "Location Type", Required: true, Default: "internal", Index: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Parent Location", Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2one("removal_strategy_id", "product.removal", orm.FieldOpts{
String: "Removal Strategy",
}),
orm.Boolean("scrap_location", orm.FieldOpts{String: "Is a Scrap Location?"}),
orm.Boolean("return_location", orm.FieldOpts{String: "Is a Return Location?"}),
orm.Char("barcode", orm.FieldOpts{String: "Barcode", Index: true}),
)
}
// initStockPickingType registers stock.picking.type.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPickingType
func initStockPickingType() {
m := orm.NewModel("stock.picking.type", orm.ModelOpts{
Description: "Picking Type",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Operation Type", Required: true, Translate: true}),
orm.Selection("code", []orm.SelectionItem{
{Value: "incoming", Label: "Receipt"},
{Value: "outgoing", Label: "Delivery"},
{Value: "internal", Label: "Internal Transfer"},
}, orm.FieldOpts{String: "Type of Operation", Required: true}),
orm.Char("sequence_code", orm.FieldOpts{String: "Code", Required: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("warehouse_id", "stock.warehouse", orm.FieldOpts{
String: "Warehouse", OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("default_location_src_id", "stock.location", orm.FieldOpts{
String: "Default Source Location",
}),
orm.Many2one("default_location_dest_id", "stock.location", orm.FieldOpts{
String: "Default Destination Location",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Boolean("show_operations", orm.FieldOpts{String: "Show Detailed Operations"}),
orm.Boolean("show_reserved", orm.FieldOpts{String: "Show Reserved"}),
)
}
// initStockPicking registers stock.picking — the transfer order.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking
func initStockPicking() {
m := orm.NewModel("stock.picking", orm.ModelOpts{
Description: "Transfer",
Order: "priority desc, scheduled_date asc, id desc",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{
String: "Reference", Default: "/", Required: true, Index: true, Readonly: true,
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "waiting", Label: "Waiting Another Operation"},
{Value: "confirmed", Label: "Waiting"},
{Value: "assigned", Label: "Ready"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Compute: "_compute_state", Store: true, Index: true}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Urgent"},
}, orm.FieldOpts{String: "Priority", Default: "0", Index: true}),
orm.Many2one("picking_type_id", "stock.picking.type", orm.FieldOpts{
String: "Operation Type", Required: true, Index: true,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Source Location", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "Destination Location", Required: true, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Contact", Index: true,
}),
orm.Datetime("scheduled_date", orm.FieldOpts{String: "Scheduled Date", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
orm.Datetime("date_done", orm.FieldOpts{String: "Date of Transfer", Readonly: true}),
orm.One2many("move_ids", "stock.move", "picking_id", orm.FieldOpts{
String: "Stock Moves",
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Text("note", orm.FieldOpts{String: "Notes"}),
orm.Char("origin", orm.FieldOpts{String: "Source Document", Index: true}),
)
// --- BeforeCreate hook: auto-generate picking reference ---
// Mirrors: stock.picking._create_sequence() / ir.sequence
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
if name == "" || name == "/" {
vals["name"] = fmt.Sprintf("WH/IN/%05d", time.Now().UnixNano()%100000)
}
return nil
}
// --- Business methods: stock move workflow ---
// action_confirm transitions a picking from draft → confirmed.
// Confirms all associated stock moves via _action_confirm (which also reserves).
// Mirrors: stock.picking.action_confirm()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM stock_picking WHERE id = $1`, id).Scan(&state)
if err != nil {
return nil, fmt.Errorf("stock: cannot read picking %d: %w", id, err)
}
if state != "draft" {
return nil, fmt.Errorf("stock: can only confirm draft pickings (picking %d is %q)", id, state)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_picking SET state = 'confirmed' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("stock: confirm picking %d: %w", id, err)
}
// Confirm all draft moves via _action_confirm (which also tries to reserve)
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM stock_move WHERE picking_id = $1 AND state = 'draft'`, id)
if err != nil {
return nil, fmt.Errorf("stock: read draft moves for picking %d: %w", id, err)
}
var moveIDs []int64
for rows.Next() {
var mid int64
if err := rows.Scan(&mid); err != nil {
rows.Close()
return nil, fmt.Errorf("stock: scan move for picking %d: %w", id, err)
}
moveIDs = append(moveIDs, mid)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("stock: iterate moves for picking %d: %w", id, err)
}
if len(moveIDs) > 0 {
moveRS := env.Model("stock.move").Browse(moveIDs...)
moveModel := orm.Registry.Get("stock.move")
if moveModel != nil {
if confirmMethod, ok := moveModel.Methods["_action_confirm"]; ok {
if _, err := confirmMethod(moveRS); err != nil {
return nil, fmt.Errorf("stock: confirm moves for picking %d: %w", id, err)
}
}
}
}
// Update picking state based on move states after reservation
var allAssigned bool
err = env.Tx().QueryRow(env.Ctx(),
`SELECT NOT EXISTS(
SELECT 1 FROM stock_move
WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
)`, id).Scan(&allAssigned)
if err != nil {
return nil, fmt.Errorf("stock: check move states for picking %d: %w", id, err)
}
if allAssigned {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("stock: update picking %d to assigned: %w", id, err)
}
}
}
return true, nil
})
// action_assign reserves stock for all confirmed/partially_available moves on the picking.
// Mirrors: stock.picking.action_assign()
m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
// Get moves that need reservation
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available')`, pickingID)
if err != nil {
return nil, fmt.Errorf("stock: read moves for assign picking %d: %w", pickingID, err)
}
var moveIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("stock: scan move for picking %d: %w", pickingID, err)
}
moveIDs = append(moveIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("stock: iterate moves for picking %d: %w", pickingID, err)
}
if len(moveIDs) > 0 {
moveRS := env.Model("stock.move").Browse(moveIDs...)
moveModel := orm.Registry.Get("stock.move")
if moveModel != nil {
if assignMethod, ok := moveModel.Methods["_action_assign"]; ok {
if _, err := assignMethod(moveRS); err != nil {
return nil, fmt.Errorf("stock: assign moves for picking %d: %w", pickingID, err)
}
}
}
}
// Update picking state based on move states
var allAssigned bool
err = env.Tx().QueryRow(env.Ctx(),
`SELECT NOT EXISTS(
SELECT 1 FROM stock_move
WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
)`, pickingID).Scan(&allAssigned)
if err != nil {
return nil, fmt.Errorf("stock: check move states for picking %d: %w", pickingID, err)
}
if allAssigned {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, pickingID)
}
if err != nil {
return nil, fmt.Errorf("stock: update picking %d state: %w", pickingID, err)
}
}
return true, nil
})
// action_cancel: Cancel a picking and all its moves.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_cancel()
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID)
env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID)
}
return true, nil
})
// button_validate transitions a picking → done via _action_done on its moves.
// Properly updates quants and clears reservations.
// Mirrors: stock.picking.button_validate()
m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
// Get all non-cancelled moves for this picking
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM stock_move WHERE picking_id = $1 AND state != 'cancel'`, pickingID)
if err != nil {
return nil, fmt.Errorf("stock: read moves for picking %d: %w", pickingID, err)
}
var moveIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("stock: scan move for picking %d: %w", pickingID, err)
}
moveIDs = append(moveIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("stock: iterate moves for picking %d: %w", pickingID, err)
}
if len(moveIDs) == 0 {
continue
}
// Call _action_done on all moves
moveRS := env.Model("stock.move").Browse(moveIDs...)
moveModel := orm.Registry.Get("stock.move")
if moveModel != nil {
if doneMethod, ok := moveModel.Methods["_action_done"]; ok {
if _, err := doneMethod(moveRS); err != nil {
return nil, fmt.Errorf("stock: action_done for picking %d: %w", pickingID, err)
}
}
}
// Update picking state
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_picking SET state = 'done', date_done = NOW() WHERE id = $1`, pickingID)
if err != nil {
return nil, fmt.Errorf("stock: validate picking %d: %w", pickingID, err)
}
}
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.
// If no quant row exists yet it inserts one; otherwise it updates in place.
func updateQuant(env *orm.Environment, productID, locationID int64, delta float64) error {
var exists bool
err := env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(SELECT 1 FROM stock_quant WHERE product_id = $1 AND location_id = $2)`,
productID, locationID).Scan(&exists)
if err != nil {
return err
}
if exists {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_quant SET quantity = quantity + $1 WHERE product_id = $2 AND location_id = $3`,
delta, productID, locationID)
} else {
_, err = env.Tx().Exec(env.Ctx(),
`INSERT INTO stock_quant (product_id, location_id, quantity, reserved_quantity, company_id) VALUES ($1, $2, $3, 0, 1)`,
productID, locationID, delta)
}
return err
}
// getAvailableQty returns unreserved on-hand quantity for a product at a location.
// Mirrors: stock.quant._get_available_quantity()
func getAvailableQty(env *orm.Environment, productID, locationID int64) float64 {
var available 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(&available)
return available
}
// assignMove reserves available stock for a single move.
// Creates a stock.move.line (reservation) and updates quant reserved_quantity.
// Mirrors: stock.move._action_assign() per-move logic
func assignMove(env *orm.Environment, moveID int64) error {
// Read move details
var productID, locationID int64
var qty float64
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, product_uom_qty, location_id, state FROM stock_move WHERE id = $1`,
moveID).Scan(&productID, &qty, &locationID, &state)
if err != nil {
return fmt.Errorf("stock: read move %d for assign: %w", moveID, err)
}
if state == "done" || state == "cancel" || qty <= 0 {
return nil
}
// Check available quantity in source location
available := getAvailableQty(env, productID, locationID)
// Reserve what we can
reserved := qty
if available < reserved {
reserved = available
}
if reserved <= 0 {
return nil // Nothing to reserve
}
// Create move line (reservation)
_, err = env.Tx().Exec(env.Ctx(),
`INSERT INTO stock_move_line (move_id, product_id, product_uom_id, location_id, location_dest_id, quantity, company_id, date)
SELECT $1, product_id, product_uom, location_id, location_dest_id, $2, company_id, COALESCE(date, NOW())
FROM stock_move WHERE id = $1`,
moveID, reserved)
if err != nil {
return fmt.Errorf("stock: create move line for move %d: %w", moveID, err)
}
// Update quant reserved_quantity
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_quant SET reserved_quantity = reserved_quantity + $1
WHERE product_id = $2 AND location_id = $3`,
reserved, productID, locationID)
if err != nil {
return fmt.Errorf("stock: update reserved qty for move %d: %w", moveID, err)
}
// Update move state
if reserved >= qty-0.005 {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'assigned' WHERE id = $1`, moveID)
} else {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'partially_available' WHERE id = $1`, moveID)
}
if err != nil {
return fmt.Errorf("stock: update state for move %d: %w", moveID, err)
}
return nil
}
// initStockMove registers stock.move — individual product movements.
// Mirrors: odoo/addons/stock/models/stock_move.py
func initStockMove() {
m := orm.NewModel("stock.move", orm.ModelOpts{
Description: "Stock Move",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Char("reference", orm.FieldOpts{String: "Reference", Index: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "New"},
{Value: "confirmed", Label: "Waiting Availability"},
{Value: "partially_available", Label: "Partially Available"},
{Value: "assigned", Label: "Available"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Index: true}),
orm.Selection("priority", []orm.SelectionItem{
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Urgent"},
}, orm.FieldOpts{String: "Priority", Default: "0"}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Float("product_uom_qty", orm.FieldOpts{String: "Demand", Required: true, Default: 1.0}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{
String: "UoM", Required: true,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Source Location", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "Destination Location", Required: true, Index: true,
}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{
String: "Transfer", Index: true,
}),
orm.Datetime("date", orm.FieldOpts{String: "Date Scheduled", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
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,
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
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) {
env := rs.Env()
for _, moveID := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM stock_move WHERE id = $1`, moveID).Scan(&state)
if err != nil {
return nil, fmt.Errorf("stock: read move %d for confirm: %w", moveID, err)
}
if state != "draft" {
continue
}
// Set to confirmed
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'confirmed' WHERE id = $1`, moveID)
if err != nil {
return nil, fmt.Errorf("stock: confirm move %d: %w", moveID, err)
}
// Try to reserve (assign) immediately
if err := assignMove(env, moveID); err != nil {
return nil, fmt.Errorf("stock: assign move %d after confirm: %w", moveID, err)
}
}
return true, nil
})
// _action_assign: Reserve stock for confirmed moves.
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_assign()
m.RegisterMethod("_action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, moveID := range rs.IDs() {
if err := assignMove(env, moveID); err != nil {
return nil, fmt.Errorf("stock: assign move %d: %w", moveID, err)
}
}
return true, nil
})
// _action_done: Finalize stock moves (assigned → done), updating quants and clearing reservations.
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_done()
m.RegisterMethod("_action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var productID, srcLoc, dstLoc int64
var qty float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, product_uom_qty, location_id, location_dest_id
FROM stock_move WHERE id = $1`, id).Scan(&productID, &qty, &srcLoc, &dstLoc)
if err != nil {
return nil, fmt.Errorf("stock: read move %d for done: %w", id, err)
}
// Decrease source quant
if err := updateQuant(env, productID, srcLoc, -qty); err != nil {
return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
}
// Increase destination quant
if err := updateQuant(env, productID, dstLoc, qty); err != nil {
return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
}
// Clear reservation on source quant
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_quant SET reserved_quantity = GREATEST(reserved_quantity - $1, 0)
WHERE product_id = $2 AND location_id = $3`,
qty, productID, srcLoc)
if err != nil {
return nil, fmt.Errorf("stock: clear reservation for move %d: %w", id, err)
}
// Mark move as done
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'done', date = NOW() WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("stock: done move %d: %w", id, err)
}
}
return true, nil
})
}
// initStockMoveLine registers stock.move.line — detailed operations per lot/package.
// Mirrors: odoo/addons/stock/models/stock_move_line.py
func initStockMoveLine() {
m := orm.NewModel("stock.move.line", orm.ModelOpts{
Description: "Product Moves (Stock Move Line)",
Order: "result_package_id desc, id",
})
m.AddFields(
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
String: "Stock Move", Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Required: true, Default: 0.0}),
orm.Many2one("product_uom_id", "uom.uom", orm.FieldOpts{
String: "Unit of Measure", Required: true,
}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{
String: "Lot/Serial Number", Index: true,
}),
orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{
String: "Source Package",
}),
orm.Many2one("result_package_id", "stock.quant.package", orm.FieldOpts{
String: "Destination Package",
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "From", Required: true, Index: true,
}),
orm.Many2one("location_dest_id", "stock.location", orm.FieldOpts{
String: "To", Required: true, Index: true,
}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{
String: "Transfer", Index: true,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Datetime("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "New"},
{Value: "confirmed", Label: "Waiting"},
{Value: "assigned", Label: "Reserved"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
)
}
// initStockQuant registers stock.quant — on-hand inventory quantities.
// Mirrors: odoo/addons/stock/models/stock_quant.py
func initStockQuant() {
m := orm.NewModel("stock.quant", orm.ModelOpts{
Description: "Quants",
Order: "removal_date, in_date, id",
})
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Location", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{
String: "Lot/Serial Number", Index: true,
}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Required: true, Default: 0.0}),
orm.Float("reserved_quantity", orm.FieldOpts{String: "Reserved Quantity", Required: true, Default: 0.0}),
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",
}),
orm.Many2one("owner_id", "res.partner", orm.FieldOpts{
String: "Owner",
}),
orm.Datetime("removal_date", orm.FieldOpts{String: "Removal Date"}),
)
// _update_available_quantity: Adjust available quantity for a product at a location.
// Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._update_available_quantity()
m.RegisterMethod("_update_available_quantity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 3 {
return nil, fmt.Errorf("stock.quant._update_available_quantity requires product_id, location_id, quantity")
}
productID, _ := args[0].(int64)
locationID, _ := args[1].(int64)
quantity, _ := args[2].(float64)
if productID == 0 || locationID == 0 {
return nil, fmt.Errorf("stock.quant._update_available_quantity: invalid product_id or location_id")
}
env := rs.Env()
if err := updateQuant(env, productID, locationID, quantity); err != nil {
return nil, fmt.Errorf("stock.quant._update_available_quantity: %w", err)
}
return true, nil
})
// stock.quant.package — physical packages / containers
orm.NewModel("stock.quant.package", orm.ModelOpts{
Description: "Packages",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Package Reference", Required: true, Index: true}),
orm.Many2one("package_type_id", "stock.package.type", orm.FieldOpts{
String: "Package Type",
}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{
String: "Location", Index: true,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Index: true,
}),
orm.Many2one("owner_id", "res.partner", orm.FieldOpts{
String: "Owner",
}),
)
// stock.package.type — packaging types (box, pallet, etc.)
orm.NewModel("stock.package.type", orm.ModelOpts{
Description: "Package Type",
Order: "sequence, id",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Package Type", Required: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Float("height", orm.FieldOpts{String: "Height"}),
orm.Float("width", orm.FieldOpts{String: "Width"}),
orm.Float("packaging_length", orm.FieldOpts{String: "Length"}),
orm.Float("max_weight", orm.FieldOpts{String: "Max Weight"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
)
// product.removal — removal strategies (FIFO, LIFO, etc.)
orm.NewModel("product.removal", orm.ModelOpts{
Description: "Removal Strategy",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Char("method", orm.FieldOpts{String: "Method", Required: true}),
)
}
// initStockLot registers stock.lot — lot/serial number tracking.
// Mirrors: odoo/addons/stock/models/stock_lot.py
func initStockLot() {
m := orm.NewModel("stock.lot", orm.ModelOpts{
Description: "Lot/Serial",
Order: "name, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Lot/Serial Number", Required: true, Index: true}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
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.
// 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
})
}
// 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) {
case int64:
return n
case float64:
return int64(n)
case int:
return int64(n)
case int32:
return int64(n)
}
return 0
}