package models import ( "fmt" "log" "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}), orm.Boolean("is_locked", orm.FieldOpts{String: "Is Locked", Default: true}), orm.Boolean("show_check_availability", orm.FieldOpts{ String: "Show Check Availability", Compute: "_compute_show_check_availability", }), orm.Boolean("show_validate", orm.FieldOpts{ String: "Show Validate", Compute: "_compute_show_validate", }), orm.Many2one("backorder_id", "stock.picking", orm.FieldOpts{ String: "Back Order of", Index: true, }), orm.Boolean("has_tracking", orm.FieldOpts{ String: "Has Tracking", Compute: "_compute_has_tracking", }), ) // --- 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 == "/" { seq, err := orm.NextByCode(env, "stock.picking") if err != nil || seq == "" { // Fallback: use DB sequence for guaranteed uniqueness var nextVal int64 env.Tx().QueryRow(env.Ctx(), `SELECT nextval(pg_get_serial_sequence('stock_picking', 'id'))`).Scan(&nextVal) vals["name"] = fmt.Sprintf("WH/PICK/%05d", nextVal) } else { vals["name"] = seq } } return nil } // --- BeforeWrite hook: prevent modifications on done & locked transfers --- m.BeforeWrite = orm.StateGuard("stock_picking", "state = 'done' AND is_locked = true", []string{"write_uid", "write_date", "is_locked", "message_partner_ids_count"}, "cannot modify done & locked transfers — unlock first") // --- Business methods: stock move workflow --- // action_confirm transitions a picking from draft → confirmed. // Confirms all associated stock moves via _action_confirm (which also reserves), // then recomputes picking state based on resulting move states. // 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) } // 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) } } } } // Recompute picking state from move states if err := updatePickingStateFromMoves(env, id); err != nil { return nil, fmt.Errorf("stock: update picking %d state after confirm: %w", id, err) } } return true, nil }) // action_assign reserves stock for all confirmed/waiting/partially_available moves on the picking. // Delegates to stock.move._action_assign() then recomputes picking state. // 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 (including waiting state) rows, err := env.Tx().Query(env.Ctx(), `SELECT id FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', 'waiting', '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) } } } } // Recompute picking state from move states if err := updatePickingStateFromMoves(env, pickingID); err != nil { return nil, fmt.Errorf("stock: update picking %d state after assign: %w", pickingID, err) } } return true, nil }) // _update_state_from_move_lines: Recompute picking state when move line quantities change. // If all moves done → done, all cancelled → cancel, mix → assigned/confirmed. // Mirrors: stock.picking._compute_state() triggered by move line changes m.RegisterMethod("_update_state_from_move_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, pickingID := range rs.IDs() { if err := updatePickingStateFromMoves(env, pickingID); err != nil { return nil, fmt.Errorf("stock.picking: _update_state_from_move_lines for %d: %w", pickingID, err) } } return true, nil }) // _action_split_picking: Split a picking into two. // Moves with qty_done > 0 stay in the current picking, moves without qty_done // are moved to a new picking. Returns the new picking ID. // Mirrors: stock.picking._action_split() / split wizard m.RegisterMethod("_action_split_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() pickingID := rs.IDs()[0] // Find moves WITH qty_done (stay) and WITHOUT qty_done (go to new picking) rows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0) as qty_done FROM stock_move sm WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: query moves for split %d: %w", pickingID, err) } var movesWithDone, movesWithoutDone []int64 for rows.Next() { var moveID int64 var qtyDone float64 if err := rows.Scan(&moveID, &qtyDone); err != nil { rows.Close() return nil, fmt.Errorf("stock.picking: scan move for split: %w", err) } if qtyDone > 0.005 { movesWithDone = append(movesWithDone, moveID) } else { movesWithoutDone = append(movesWithoutDone, moveID) } } rows.Close() if len(movesWithoutDone) == 0 { return map[string]interface{}{"split": false, "message": "All moves have qty_done, nothing to split"}, nil } if len(movesWithDone) == 0 { return map[string]interface{}{"split": false, "message": "No moves have qty_done, nothing to split"}, nil } // Read original picking data var name, origin string var pickTypeID, locID, locDestID, companyID int64 var partnerID *int64 err = env.Tx().QueryRow(env.Ctx(), `SELECT name, COALESCE(origin,''), picking_type_id, location_id, location_dest_id, company_id, partner_id FROM stock_picking WHERE id = $1`, pickingID, ).Scan(&name, &origin, &pickTypeID, &locID, &locDestID, &companyID, &partnerID) if err != nil { return nil, fmt.Errorf("stock.picking: read picking %d for split: %w", pickingID, err) } // Create new picking for moves without qty_done newVals := orm.Values{ "name": fmt.Sprintf("%s-SPLIT", name), "picking_type_id": pickTypeID, "location_id": locID, "location_dest_id": locDestID, "company_id": companyID, "origin": origin, "backorder_id": pickingID, "state": "draft", "scheduled_date": time.Now().Format("2006-01-02"), } if partnerID != nil { newVals["partner_id"] = *partnerID } newPicking, err := env.Model("stock.picking").Create(newVals) if err != nil { return nil, fmt.Errorf("stock.picking: create split picking for %d: %w", pickingID, err) } // Move the no-qty-done moves to the new picking for _, moveID := range movesWithoutDone { _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET picking_id = $1 WHERE id = $2`, newPicking.ID(), moveID) if err != nil { return nil, fmt.Errorf("stock.picking: move %d to split picking: %w", moveID, err) } // Also update move lines _, _ = env.Tx().Exec(env.Ctx(), `UPDATE stock_move_line SET picking_id = $1 WHERE move_id = $2`, newPicking.ID(), moveID) } return map[string]interface{}{ "split": true, "new_picking_id": newPicking.ID(), "kept_moves": len(movesWithDone), "split_moves": len(movesWithoutDone), }, 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() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID); err != nil { return nil, fmt.Errorf("stock.picking: cancel moves for %d: %w", pickingID, err) } if _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID); err != nil { return nil, fmt.Errorf("stock.picking: cancel picking %d: %w", pickingID, err) } } return true, nil }) // button_validate transitions a picking → done via _action_done on its moves. // Checks if all quantities are done; if not, creates a backorder for remaining. // 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() { // Step 1: Check if there are any non-cancelled moves 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 } // Step 1b: Enforce serial/lot tracking — reject if required lot is missing lotErr := enforceSerialLotTracking(env, pickingID) if lotErr != nil { return nil, lotErr } // Step 2: Check if any move has remaining qty (demand > qty_done) var hasRemaining bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM stock_move sm WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel') AND sm.product_uom_qty > COALESCE( (SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0 ) + 0.005 )`, pickingID, ).Scan(&hasRemaining) // Step 3: If partial, create backorder for remaining quantities if hasRemaining { pickModel := orm.Registry.Get("stock.picking") if pickModel != nil { if boMethod, ok := pickModel.Methods["_create_backorder"]; ok { pickRS := env.Model("stock.picking").Browse(pickingID) if _, err := boMethod(pickRS); err != nil { return nil, fmt.Errorf("stock: create backorder for picking %d: %w", pickingID, err) } } } } // Step 4: Re-read move IDs (demand may have been adjusted by backorder) rows2, err := env.Tx().Query(env.Ctx(), `SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done', 'cancel')`, pickingID) if err != nil { return nil, fmt.Errorf("stock: re-read moves for picking %d: %w", pickingID, err) } var activeMoveIDs []int64 for rows2.Next() { var id int64 if err := rows2.Scan(&id); err != nil { rows2.Close() return nil, fmt.Errorf("stock: scan active move for picking %d: %w", pickingID, err) } activeMoveIDs = append(activeMoveIDs, id) } rows2.Close() // Step 5: Call _action_done on all active moves if len(activeMoveIDs) > 0 { moveRS := env.Model("stock.move").Browse(activeMoveIDs...) 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) } } } } // Step 6: Update picking state to done _, 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 }) // do_unreserve: Un-reserve all moves on a picking, reset state to confirmed. // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.do_unreserve() m.RegisterMethod("do_unreserve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, pickingID := range rs.IDs() { // Clear reserved quantities on move lines env.Tx().Exec(env.Ctx(), `DELETE FROM stock_move_line WHERE move_id IN (SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done','cancel'))`, pickingID) // Reset moves to confirmed env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'confirmed', reserved_availability = 0 WHERE picking_id = $1 AND state NOT IN ('done','cancel')`, pickingID) // Reset picking state to confirmed env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'confirmed' WHERE id = $1 AND state NOT IN ('done','cancel')`, pickingID) } return true, nil }) // _compute_state: Compute picking state from move states. // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking._compute_state() m.RegisterCompute("state", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() pickingID := rs.IDs()[0] // Count moves by state var total, draftCount, cancelCount, doneCount, assignedCount int64 env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*), COUNT(*) FILTER (WHERE state = 'draft'), COUNT(*) FILTER (WHERE state = 'cancel'), COUNT(*) FILTER (WHERE state = 'done'), COUNT(*) FILTER (WHERE state = 'assigned') FROM stock_move WHERE picking_id = $1`, pickingID, ).Scan(&total, &draftCount, &cancelCount, &doneCount, &assignedCount) if total == 0 || draftCount > 0 { return orm.Values{"state": "draft"}, nil } if cancelCount == total { return orm.Values{"state": "cancel"}, nil } if doneCount+cancelCount == total { return orm.Values{"state": "done"}, nil } if assignedCount+doneCount+cancelCount == total { return orm.Values{"state": "assigned"}, nil } return orm.Values{"state": "confirmed"}, nil }) // _compute_show_check_availability: Show button when moves need reservation. // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking._compute_show_check_availability() m.RegisterCompute("show_check_availability", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() pickingID := rs.IDs()[0] var state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft') FROM stock_picking WHERE id = $1`, pickingID, ).Scan(&state) if state == "done" || state == "cancel" || state == "draft" { return orm.Values{"show_check_availability": false}, nil } // Show if any move is not fully reserved var needsReservation bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available', 'waiting') )`, pickingID, ).Scan(&needsReservation) return orm.Values{"show_check_availability": needsReservation}, nil }) // _compute_show_validate: Show validate button when picking can be validated. // Mirrors: odoo/addons/stock/models/stock_picking.py m.RegisterCompute("show_validate", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() pickingID := rs.IDs()[0] var state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft') FROM stock_picking WHERE id = $1`, pickingID, ).Scan(&state) show := state != "done" && state != "cancel" && state != "draft" return orm.Values{"show_validate": show}, nil }) // _compute_has_tracking: Check if any move has lot/serial tracking. // Mirrors: stock.picking._compute_has_tracking() m.RegisterCompute("has_tracking", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() pickingID := rs.IDs()[0] var hasTracking bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM stock_move sm JOIN product_product pp ON pp.id = sm.product_id JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE sm.picking_id = $1 AND pt.tracking != 'none' )`, pickingID, ).Scan(&hasTracking) return orm.Values{"has_tracking": hasTracking}, nil }) // action_set_quantities_to_reservation: Set done qty = reserved qty on all moves. // Mirrors: stock.picking.action_set_quantities_to_reservation() m.RegisterMethod("action_set_quantities_to_reservation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, pickingID := range rs.IDs() { // For each non-done/cancel move, set move line quantities to match reservations _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_move_line SET quantity = reserved_quantity WHERE move_id IN ( SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done', 'cancel') ) AND reserved_quantity > 0`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: action_set_quantities_to_reservation for %d: %w", pickingID, err) } // For moves without move lines, create a move line with demand as quantity rows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, sm.product_id, sm.product_uom, sm.product_uom_qty, sm.location_id, sm.location_dest_id, sm.company_id FROM stock_move sm WHERE sm.picking_id = $1 AND sm.state IN ('assigned', 'partially_available') AND NOT EXISTS (SELECT 1 FROM stock_move_line WHERE move_id = sm.id)`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: query moves without lines for %d: %w", pickingID, err) } type moveInfo struct { ID, ProductID, UomID, LocationID, LocationDestID, CompanyID int64 Qty float64 } var moves []moveInfo for rows.Next() { var mi moveInfo if err := rows.Scan(&mi.ID, &mi.ProductID, &mi.UomID, &mi.Qty, &mi.LocationID, &mi.LocationDestID, &mi.CompanyID); err != nil { rows.Close() return nil, fmt.Errorf("stock.picking: scan move for set qty: %w", err) } moves = append(moves, mi) } rows.Close() for _, mi := range moves { _, 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) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, mi.ID, mi.ProductID, mi.UomID, mi.LocationID, mi.LocationDestID, mi.Qty, mi.CompanyID) if err != nil { return nil, fmt.Errorf("stock.picking: create move line for set qty move %d: %w", mi.ID, err) } } } return true, nil }) // _check_entire_pack: Validate package completeness. // Mirrors: stock.picking._check_entire_pack() // Stub returning true — full package validation would require complete package quant tracking. m.RegisterMethod("_check_entire_pack", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { return true, nil }) // _compute_entire_package_ids: Compute related package IDs for the picking. // Mirrors: stock.picking._compute_entire_package_ids() m.RegisterMethod("_compute_entire_package_ids", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() pickingID := rs.IDs()[0] rows, err := env.Tx().Query(env.Ctx(), `SELECT DISTINCT sml.package_id FROM stock_move_line sml JOIN stock_move sm ON sm.id = sml.move_id WHERE sm.picking_id = $1 AND sml.package_id IS NOT NULL`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: compute entire_package_ids for %d: %w", pickingID, err) } defer rows.Close() var packageIDs []int64 for rows.Next() { var pkgID int64 if err := rows.Scan(&pkgID); err != nil { return nil, fmt.Errorf("stock.picking: scan package_id: %w", err) } packageIDs = append(packageIDs, pkgID) } return packageIDs, nil }) // _create_backorder: Create a backorder picking for remaining unprocessed quantities. // Copies undone move lines to a new picking linked via backorder_id. // Mirrors: stock.picking._create_backorder() m.RegisterMethod("_create_backorder", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() pickingID := rs.IDs()[0] // Read original picking data var name, state, origin string var pickTypeID, locID, locDestID, companyID int64 var partnerID *int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT name, COALESCE(state,'draft'), COALESCE(origin,''), picking_type_id, location_id, location_dest_id, company_id, partner_id FROM stock_picking WHERE id = $1`, pickingID, ).Scan(&name, &state, &origin, &pickTypeID, &locID, &locDestID, &companyID, &partnerID) if err != nil { return nil, fmt.Errorf("stock.picking: read picking %d for backorder: %w", pickingID, err) } // Find moves where quantity_done < demand (partially done or not done) rows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.product_uom, sm.location_id, sm.location_dest_id, sm.company_id, sm.name, COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0) as qty_done FROM stock_move sm WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: query moves for backorder %d: %w", pickingID, err) } type backorderMove struct { ID, ProductID, UomID, LocID, LocDestID, CompanyID int64 Demand, QtyDone float64 Name string } var movesToBackorder []backorderMove for rows.Next() { var bm backorderMove if err := rows.Scan(&bm.ID, &bm.ProductID, &bm.Demand, &bm.UomID, &bm.LocID, &bm.LocDestID, &bm.CompanyID, &bm.Name, &bm.QtyDone); err != nil { rows.Close() return nil, fmt.Errorf("stock.picking: scan backorder move: %w", err) } remaining := bm.Demand - bm.QtyDone if remaining > 0.005 { // Float tolerance movesToBackorder = append(movesToBackorder, bm) } } rows.Close() if len(movesToBackorder) == 0 { return nil, nil // Nothing to backorder } // Create backorder picking boVals := orm.Values{ "name": fmt.Sprintf("%s-BO", name), "picking_type_id": pickTypeID, "location_id": locID, "location_dest_id": locDestID, "company_id": companyID, "origin": origin, "backorder_id": pickingID, "state": "draft", "scheduled_date": time.Now().Format("2006-01-02"), } if partnerID != nil { boVals["partner_id"] = *partnerID } boPicking, err := env.Model("stock.picking").Create(boVals) if err != nil { return nil, fmt.Errorf("stock.picking: create backorder for %d: %w", pickingID, err) } // Create moves in the backorder for the remaining quantities moveRS := env.Model("stock.move") for _, bm := range movesToBackorder { remaining := bm.Demand - bm.QtyDone _, err := moveRS.Create(orm.Values{ "name": bm.Name, "product_id": bm.ProductID, "product_uom_qty": remaining, "product_uom": bm.UomID, "location_id": bm.LocID, "location_dest_id": bm.LocDestID, "picking_id": boPicking.ID(), "company_id": bm.CompanyID, "state": "draft", "date": time.Now(), }) if err != nil { return nil, fmt.Errorf("stock.picking: create backorder move for %d: %w", bm.ID, err) } // Reduce demand on original move to match qty_done if bm.QtyDone > 0 { _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET product_uom_qty = $1 WHERE id = $2`, bm.QtyDone, bm.ID) if err != nil { return nil, fmt.Errorf("stock.picking: reduce demand on move %d: %w", bm.ID, err) } } } return map[string]interface{}{ "backorder_id": boPicking.ID(), }, nil }) // action_generate_backorder_wizard: When not all qty is done, decide on backorder. // In Python Odoo this opens a wizard; here we auto-create the backorder. // Mirrors: stock.picking.action_generate_backorder_wizard() m.RegisterMethod("action_generate_backorder_wizard", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() pickingID := rs.IDs()[0] // Check if all quantities are done var hasRemaining bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS( SELECT 1 FROM stock_move sm WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel') AND sm.product_uom_qty > COALESCE( (SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0 ) + 0.005 )`, pickingID, ).Scan(&hasRemaining) if !hasRemaining { return map[string]interface{}{"backorder_needed": false}, nil } // Create the backorder pickModel := orm.Registry.Get("stock.picking") if pickModel != nil { if boMethod, ok := pickModel.Methods["_create_backorder"]; ok { result, err := boMethod(rs) if err != nil { return nil, err } return map[string]interface{}{ "backorder_needed": true, "backorder": result, }, nil } } return map[string]interface{}{"backorder_needed": true}, nil }) // action_back_to_draft: Reset a cancelled picking back to draft. // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_back_to_draft() m.RegisterMethod("action_back_to_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, pickingID := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM stock_picking WHERE id = $1`, pickingID, ).Scan(&state) if err != nil { return nil, fmt.Errorf("stock.picking: read state for %d: %w", pickingID, err) } if state != "cancel" { return nil, fmt.Errorf("stock.picking: can only reset cancelled pickings to draft (picking %d is %q)", pickingID, state) } // Reset moves to draft _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'draft' WHERE picking_id = $1 AND state = 'cancel'`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: reset moves to draft for %d: %w", pickingID, err) } // Reset picking to draft _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'draft' WHERE id = $1`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: reset to draft for %d: %w", pickingID, err) } } return true, nil }) // action_toggle_is_locked: Toggle the is_locked boolean for editing done pickings. // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_toggle_is_locked() m.RegisterMethod("action_toggle_is_locked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, pickingID := range rs.IDs() { _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET is_locked = NOT COALESCE(is_locked, true) WHERE id = $1`, pickingID) if err != nil { return nil, fmt.Errorf("stock.picking: toggle is_locked for %d: %w", pickingID, err) } } return true, nil }) // send_receipt: Stub that returns true (for receipt email button). // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.send_receipt() m.RegisterMethod("send_receipt", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { 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 { // Atomic upsert — eliminates TOCTOU race condition between concurrent transactions. // Uses INSERT ON CONFLICT to avoid separate SELECT+UPDATE/INSERT. _, 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) ON CONFLICT (product_id, location_id) DO UPDATE SET quantity = stock_quant.quantity + $3`, 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 } // Skip if move already has reservation lines (prevent duplicates) var existingLines int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM stock_move_line WHERE move_id = $1`, moveID).Scan(&existingLines) if existingLines > 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.Float("quantity_done", orm.FieldOpts{String: "Quantity Done", Compute: "_compute_quantity_done"}), orm.Float("reserved_availability", orm.FieldOpts{String: "Forecast Availability"}), 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"}), orm.Float("forecast_availability", orm.FieldOpts{ String: "Forecast Availability", Compute: "_compute_forecast_availability", }), orm.One2many("move_line_ids", "stock.move.line", "move_id", orm.FieldOpts{ String: "Move Lines", }), ) // _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 demandQty 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, &demandQty, &srcLoc, &dstLoc) if err != nil { return nil, fmt.Errorf("stock: read move %d for done: %w", id, err) } // Use actual done qty from move lines, falling back to demand var doneQty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, id, ).Scan(&doneQty) if doneQty == 0 { doneQty = demandQty } // Only update quants for internal locations (skip supplier/customer/virtual) var srcUsage, dstUsage string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(usage, '') FROM stock_location WHERE id = $1`, srcLoc).Scan(&srcUsage) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(usage, '') FROM stock_location WHERE id = $1`, dstLoc).Scan(&dstUsage) if srcUsage == "internal" { if err := updateQuant(env, productID, srcLoc, -doneQty); err != nil { return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err) } } if dstUsage == "internal" { if err := updateQuant(env, productID, dstLoc, doneQty); err != nil { return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err) } } // Clear reservation on source quant (only if internal) if srcUsage == "internal" { env.Tx().Exec(env.Ctx(), `UPDATE stock_quant SET reserved_quantity = GREATEST(reserved_quantity - $1, 0) WHERE product_id = $2 AND location_id = $3`, doneQty, productID, srcLoc) } // 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) } // Multi-location transfer propagation: auto-create chained move if a push rule exists if err := propagateChainedMove(env, id, productID, dstLoc, doneQty); err != nil { log.Printf("stock: chain propagation for move %d: %v", id, err) } } return true, nil }) // _action_cancel: Cancel stock moves, unreserving any reserved quantities. // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_cancel() m.RegisterMethod("_action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() moveIDs := rs.IDs() if len(moveIDs) == 0 { return true, nil } // Check for done moves (cannot cancel) var doneCount int if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM stock_move WHERE id = ANY($1) AND state = 'done'`, moveIDs, ).Scan(&doneCount); err != nil { return nil, fmt.Errorf("stock.move: cancel check: %w", err) } if doneCount > 0 { return nil, fmt.Errorf("stock.move: cannot cancel done moves — create a return instead") } // Batch release reservations: aggregate reserved qty per product+location rows, err := env.Tx().Query(env.Ctx(), `SELECT sm.product_id, sm.location_id, COALESCE(SUM(sml.quantity), 0) FROM stock_move sm LEFT JOIN stock_move_line sml ON sml.move_id = sm.id WHERE sm.id = ANY($1) AND sm.state NOT IN ('done', 'cancel') GROUP BY sm.product_id, sm.location_id HAVING SUM(sml.quantity) > 0`, moveIDs) if err != nil { return nil, fmt.Errorf("stock.move: cancel reservation query: %w", err) } for rows.Next() { var productID, locationID int64 var reserved float64 if err := rows.Scan(&productID, &locationID, &reserved); err != nil { rows.Close() return nil, 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`, reserved, productID, locationID) } rows.Close() // Batch delete all move lines if _, err := env.Tx().Exec(env.Ctx(), `DELETE FROM stock_move_line WHERE move_id = ANY($1)`, moveIDs); err != nil { return nil, fmt.Errorf("stock.move: delete move lines: %w", err) } // Batch update state to cancel (skip already cancelled) if _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE id = ANY($1) AND state != 'cancel'`, moveIDs); err != nil { return nil, fmt.Errorf("stock.move: cancel: %w", err) } return true, nil }) // _compute_quantity_done: Sum of move line quantities. // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._compute_quantity_done() m.RegisterCompute("quantity_done", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var qtyDone float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, moveID, ).Scan(&qtyDone) return orm.Values{"quantity_done": qtyDone}, nil }) // _compute_reserved_availability: SUM reserved_quantity from move_lines / product_uom_qty as percentage. // Mirrors: stock.move._compute_reserved_availability() m.RegisterCompute("reserved_availability", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var demandQty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(product_uom_qty, 0) FROM stock_move WHERE id = $1`, moveID, ).Scan(&demandQty) if demandQty <= 0 { return orm.Values{"reserved_availability": float64(0)}, nil } var reservedQty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, moveID, ).Scan(&reservedQty) // Return as absolute reserved qty (Odoo convention, not percentage) return orm.Values{"reserved_availability": reservedQty}, nil }) // _compute_forecast_availability: Check available qty from quants for the move's product+location. // Mirrors: stock.move._compute_forecast_information() m.RegisterCompute("forecast_availability", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var productID, locationID int64 env.Tx().QueryRow(env.Ctx(), `SELECT product_id, location_id FROM stock_move WHERE id = $1`, moveID, ).Scan(&productID, &locationID) available := getAvailableQty(env, productID, locationID) return orm.Values{"forecast_availability": available}, nil }) // _generate_serial_move_line_commands: For serial-tracked products, create one move line per serial. // Expects args: []string of serial numbers. // Mirrors: stock.move._generate_serial_move_line_commands() m.RegisterMethod("_generate_serial_move_line_commands", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("stock.move._generate_serial_move_line_commands requires serial numbers") } serials, ok := args[0].([]string) if !ok || len(serials) == 0 { return nil, fmt.Errorf("stock.move._generate_serial_move_line_commands: invalid serial numbers argument") } env := rs.Env() moveID := rs.IDs()[0] // Read move details var productID, uomID, locationID, locationDestID, companyID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT product_id, product_uom, location_id, location_dest_id, company_id FROM stock_move WHERE id = $1`, moveID, ).Scan(&productID, &uomID, &locationID, &locationDestID, &companyID) if err != nil { return nil, fmt.Errorf("stock.move: read move %d for serial generation: %w", moveID, err) } var createdLineIDs []int64 for _, serial := range serials { // Find or create the lot var lotID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_lot WHERE name = $1 AND product_id = $2 LIMIT 1`, serial, productID, ).Scan(&lotID) if err != nil || lotID == 0 { // Create the lot err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO stock_lot (name, product_id, company_id) VALUES ($1, $2, $3) RETURNING id`, serial, productID, companyID, ).Scan(&lotID) if err != nil { return nil, fmt.Errorf("stock.move: create lot for serial %q: %w", serial, err) } } // Create move line with qty 1 (one per serial) var lineID int64 err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO stock_move_line (move_id, product_id, product_uom_id, lot_id, location_id, location_dest_id, quantity, company_id, date) VALUES ($1, $2, $3, $4, $5, $6, 1, $7, NOW()) RETURNING id`, moveID, productID, uomID, lotID, locationID, locationDestID, companyID, ).Scan(&lineID) if err != nil { return nil, fmt.Errorf("stock.move: create serial move line for %q: %w", serial, err) } createdLineIDs = append(createdLineIDs, lineID) } return map[string]interface{}{ "move_line_ids": createdLineIDs, "count": len(createdLineIDs), }, 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 }) // _get_available_quantity: Query available (unreserved) qty for a product at a location. // Optionally filter by lot_id (args[2]). // Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._get_available_quantity() m.RegisterMethod("_get_available_quantity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 2 { return nil, fmt.Errorf("stock.quant._get_available_quantity requires product_id, location_id") } productID, _ := args[0].(int64) locationID, _ := args[1].(int64) if productID == 0 || locationID == 0 { return nil, fmt.Errorf("stock.quant._get_available_quantity: invalid product_id or location_id") } env := rs.Env() var lotID int64 if len(args) >= 3 { lotID, _ = args[2].(int64) } var available float64 if lotID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2 AND lot_id = $3`, productID, locationID, lotID).Scan(&available) } else { available = getAvailableQty(env, productID, locationID) } return available, nil }) // _gather: Find quants matching product + location + optional lot criteria. // Returns quant IDs as []int64. // Args: product_id (int64), location_id (int64), optional lot_id (int64). // Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._gather() m.RegisterMethod("_gather", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 2 { return nil, fmt.Errorf("stock.quant._gather requires product_id, location_id") } productID, _ := args[0].(int64) locationID, _ := args[1].(int64) if productID == 0 || locationID == 0 { return nil, fmt.Errorf("stock.quant._gather: invalid product_id or location_id") } env := rs.Env() var lotID int64 if len(args) >= 3 { lotID, _ = args[2].(int64) } var query string var queryArgs []interface{} if lotID > 0 { query = `SELECT id FROM stock_quant WHERE product_id = $1 AND location_id = $2 AND lot_id = $3 ORDER BY in_date, id` queryArgs = []interface{}{productID, locationID, lotID} } else { query = `SELECT id FROM stock_quant WHERE product_id = $1 AND location_id = $2 ORDER BY in_date, id` queryArgs = []interface{}{productID, locationID} } rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...) if err != nil { return nil, fmt.Errorf("stock.quant._gather: %w", err) } defer rows.Close() var quantIDs []int64 for rows.Next() { var qid int64 if err := rows.Scan(&qid); err != nil { return nil, fmt.Errorf("stock.quant._gather scan: %w", err) } quantIDs = append(quantIDs, qid) } return quantIDs, nil }) // _compute_qty_at_date: Historical stock query — SUM moves done before a given date. // Args: product_id (int64), location_id (int64), date (string YYYY-MM-DD) // Mirrors: stock.quant._compute_qty_at_date() / stock history m.RegisterMethod("_compute_qty_at_date", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 3 { return nil, fmt.Errorf("stock.quant._compute_qty_at_date requires product_id, location_id, date") } productID, _ := args[0].(int64) locationID, _ := args[1].(int64) dateStr, _ := args[2].(string) if productID == 0 || locationID == 0 || dateStr == "" { return nil, fmt.Errorf("stock.quant._compute_qty_at_date: invalid args") } env := rs.Env() // Sum incoming moves (destination = this location) done before the date var incoming float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM( COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), sm.product_uom_qty) ), 0) FROM stock_move sm WHERE sm.product_id = $1 AND sm.location_dest_id = $2 AND sm.state = 'done' AND sm.date <= $3`, productID, locationID, dateStr, ).Scan(&incoming) // Sum outgoing moves (source = this location) done before the date var outgoing float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM( COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), sm.product_uom_qty) ), 0) FROM stock_move sm WHERE sm.product_id = $1 AND sm.location_id = $2 AND sm.state = 'done' AND sm.date <= $3`, productID, locationID, dateStr, ).Scan(&outgoing) qtyAtDate := incoming - outgoing return map[string]interface{}{ "product_id": productID, "location_id": locationID, "date": dateStr, "qty_at_date": qtyAtDate, "incoming": incoming, "outgoing": outgoing, }, nil }) // _compute_forecast_qty: on_hand - outgoing_reserved + incoming_confirmed. // Args: product_id (int64), location_id (int64) // Mirrors: stock.quant forecast computation m.RegisterMethod("_compute_forecast_qty", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 2 { return nil, fmt.Errorf("stock.quant._compute_forecast_qty requires product_id, location_id") } productID, _ := args[0].(int64) locationID, _ := args[1].(int64) if productID == 0 || locationID == 0 { return nil, fmt.Errorf("stock.quant._compute_forecast_qty: invalid args") } env := rs.Env() // On-hand quantity at location var onHand 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(&onHand) // Outgoing reserved: confirmed/assigned moves FROM this location var outgoingReserved float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move WHERE product_id = $1 AND location_id = $2 AND state IN ('confirmed', 'assigned', 'waiting', 'partially_available')`, productID, locationID, ).Scan(&outgoingReserved) // Incoming confirmed: confirmed/assigned moves TO this location var incomingConfirmed float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move WHERE product_id = $1 AND location_dest_id = $2 AND state IN ('confirmed', 'assigned', 'waiting', 'partially_available')`, productID, locationID, ).Scan(&incomingConfirmed) forecastQty := onHand - outgoingReserved + incomingConfirmed return map[string]interface{}{ "product_id": productID, "location_id": locationID, "on_hand": onHand, "outgoing_reserved": outgoingReserved, "incoming_confirmed": incomingConfirmed, "forecast_qty": forecastQty, }, 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 }) // _generate_serial_numbers: Auto-create sequential lot/serial records for a product. // Args: product_id (int64), prefix (string), count (int64), company_id (int64) // Creates lots named prefix0001, prefix0002, ... prefixNNNN. // Mirrors: stock.lot.generate_lot_names() / serial number generation wizard m.RegisterMethod("_generate_serial_numbers", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 3 { return nil, fmt.Errorf("stock.lot._generate_serial_numbers requires product_id, prefix, count") } productID, _ := args[0].(int64) prefix, _ := args[1].(string) count, _ := args[2].(int64) if productID == 0 || count <= 0 { return nil, fmt.Errorf("stock.lot._generate_serial_numbers: invalid product_id or count") } companyID := int64(1) if len(args) >= 4 { if cid, ok := args[3].(int64); ok && cid > 0 { companyID = cid } } env := rs.Env() var createdIDs []int64 var createdNames []string for i := int64(1); i <= count; i++ { lotName := fmt.Sprintf("%s%04d", prefix, i) // Check if lot already exists var existingID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_lot WHERE name = $1 AND product_id = $2 LIMIT 1`, lotName, productID, ).Scan(&existingID) if existingID > 0 { continue // Skip duplicates } var lotID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO stock_lot (name, product_id, company_id) VALUES ($1, $2, $3) RETURNING id`, lotName, productID, companyID, ).Scan(&lotID) if err != nil { return nil, fmt.Errorf("stock.lot._generate_serial_numbers: create lot %q: %w", lotName, err) } createdIDs = append(createdIDs, lotID) createdNames = append(createdNames, lotName) } return map[string]interface{}{ "lot_ids": createdIDs, "names": createdNames, "count": len(createdIDs), }, 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(¤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 }) } // 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 } // updatePickingStateFromMoves recomputes and writes the picking state based on // the aggregate states of its stock moves. // If all moves done → done, all cancelled → cancel, all assigned+done+cancel → assigned, else confirmed. // Mirrors: stock.picking._compute_state() func updatePickingStateFromMoves(env *orm.Environment, pickingID int64) error { var total, draftCount, cancelCount, doneCount, assignedCount int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*), COUNT(*) FILTER (WHERE state = 'draft'), COUNT(*) FILTER (WHERE state = 'cancel'), COUNT(*) FILTER (WHERE state = 'done'), COUNT(*) FILTER (WHERE state = 'assigned') FROM stock_move WHERE picking_id = $1`, pickingID, ).Scan(&total, &draftCount, &cancelCount, &doneCount, &assignedCount) if err != nil { return fmt.Errorf("updatePickingStateFromMoves: query move states for picking %d: %w", pickingID, err) } var newState string switch { case total == 0 || draftCount > 0: newState = "draft" case cancelCount == total: newState = "cancel" case doneCount+cancelCount == total: newState = "done" case assignedCount+doneCount+cancelCount == total: newState = "assigned" default: newState = "confirmed" } _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = $1 WHERE id = $2`, newState, pickingID) if err != nil { return fmt.Errorf("updatePickingStateFromMoves: update picking %d to %s: %w", pickingID, newState, err) } // If done, also set date_done if newState == "done" { env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET date_done = NOW() WHERE id = $1 AND date_done IS NULL`, pickingID) } return nil } // propagateChainedMove checks for push rules on the destination location and // auto-creates a chained move if a stock.rule exists for the route. // This implements multi-location transfer propagation between warehouses. // Mirrors: stock.move._push_apply() / _action_done chain func propagateChainedMove(env *orm.Environment, moveID, productID, destLocationID int64, qty float64) error { // Look for a push rule where location_src_id = destLocationID var ruleID, nextDestID, pickingTypeID int64 var delay int err := env.Tx().QueryRow(env.Ctx(), `SELECT sr.id, sr.location_dest_id, sr.picking_type_id, COALESCE(sr.delay, 0) FROM stock_rule sr WHERE sr.location_src_id = $1 AND sr.action IN ('push', 'pull_push') AND sr.active = true ORDER BY sr.sequence LIMIT 1`, destLocationID, ).Scan(&ruleID, &nextDestID, &pickingTypeID, &delay) if err != nil { return nil // No push rule found — this is normal, not an error } if ruleID == 0 || nextDestID == 0 { return nil } // Find or create a picking for the chained move var chainedPickingID int64 err = env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_picking WHERE picking_type_id = $1 AND location_id = $2 AND location_dest_id = $3 AND state = 'draft' ORDER BY id DESC LIMIT 1`, pickingTypeID, destLocationID, nextDestID, ).Scan(&chainedPickingID) if chainedPickingID == 0 { // Create a new picking var companyID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(company_id, 1) FROM stock_picking_type WHERE id = $1`, pickingTypeID, ).Scan(&companyID) if companyID == 0 { companyID = 1 } scheduled := time.Now().AddDate(0, 0, delay).Format("2006-01-02") pickVals := orm.Values{ "picking_type_id": pickingTypeID, "location_id": destLocationID, "location_dest_id": nextDestID, "company_id": companyID, "state": "draft", "scheduled_date": scheduled, "origin": fmt.Sprintf("Chain from move %d", moveID), } pickRS, err := env.Model("stock.picking").Create(pickVals) if err != nil { return fmt.Errorf("propagateChainedMove: create picking: %w", err) } chainedPickingID = pickRS.ID() } // Create the chained move scheduled := time.Now().AddDate(0, 0, delay) _, err = env.Model("stock.move").Create(orm.Values{ "name": fmt.Sprintf("Chained: product %d from rule %d", productID, ruleID), "product_id": productID, "product_uom_qty": qty, "product_uom": int64(1), // default UoM "location_id": destLocationID, "location_dest_id": nextDestID, "picking_id": chainedPickingID, "company_id": int64(1), "state": "draft", "date": scheduled, "origin": fmt.Sprintf("Chain from move %d", moveID), }) if err != nil { return fmt.Errorf("propagateChainedMove: create chained move: %w", err) } log.Printf("stock: created chained move for product %d from location %d to %d (rule %d)", productID, destLocationID, nextDestID, ruleID) return nil } // enforceSerialLotTracking validates that move lines have required lot/serial numbers. // Products with tracking = 'lot' or 'serial' must have lot_id set on their move lines. // Mirrors: odoo/addons/stock/models/stock_picking.py _check_move_lines_map_quant() func enforceSerialLotTracking(env *orm.Environment, pickingID int64) error { rows, err := env.Tx().Query(env.Ctx(), `SELECT sml.id, COALESCE(pt.name, ''), COALESCE(pt.tracking, 'none'), sml.lot_id FROM stock_move_line sml JOIN stock_move sm ON sm.id = sml.move_id LEFT JOIN product_product pp ON pp.id = sml.product_id LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel') AND sml.quantity > 0`, pickingID) if err != nil { log.Printf("stock: serial/lot tracking query failed for picking %d: %v", pickingID, err) return fmt.Errorf("stock: cannot verify lot/serial tracking: %w", err) } defer rows.Close() for rows.Next() { var lineID int64 var productName, tracking string var lotID *int64 if err := rows.Scan(&lineID, &productName, &tracking, &lotID); err != nil { continue } if (tracking == "lot" || tracking == "serial") && (lotID == nil || *lotID == 0) { return fmt.Errorf("stock: product '%s' requires a lot/serial number (tracking=%s) on move line %d", productName, tracking, lineID) } } return nil }