- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2356 lines
88 KiB
Go
2356 lines
88 KiB
Go
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
|
|
}
|