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() initStockOrderpoint() initStockScrap() initStockInventory() } // 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 }) } // 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"}), ) } // 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(¤tQty) 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 }