package models import ( "fmt" "odoo-go/pkg/orm" ) // initStockLandedCost registers stock.landed.cost, stock.landed.cost.lines, // and stock.valuation.adjustment.lines — landed cost allocation on transfers. // Mirrors: odoo/addons/stock_landed_costs/models/stock_landed_cost.py func initStockLandedCost() { m := orm.NewModel("stock.landed.cost", orm.ModelOpts{ Description: "Landed Costs", Order: "date desc, id desc", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Default: "New"}), orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), orm.Many2many("picking_ids", "stock.picking", orm.FieldOpts{String: "Transfers"}), orm.One2many("cost_lines", "stock.landed.cost.lines", "cost_id", orm.FieldOpts{String: "Cost Lines"}), orm.One2many("valuation_adjustment_lines", "stock.valuation.adjustment.lines", "cost_id", orm.FieldOpts{String: "Valuation Adjustments"}), orm.Many2one("account_journal_id", "account.journal", orm.FieldOpts{String: "Journal"}), orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "Draft"}, {Value: "done", Label: "Posted"}, {Value: "cancel", Label: "Cancelled"}, }, orm.FieldOpts{String: "Status", Default: "draft"}), orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount_total", CurrencyField: "currency_id"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), ) // _compute_amount_total: Sum of all cost lines. // Mirrors: stock.landed.cost._compute_amount_total() m.RegisterCompute("amount_total", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() costID := rs.IDs()[0] var total float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(price_unit), 0) FROM stock_landed_cost_lines WHERE cost_id = $1`, costID, ).Scan(&total) return orm.Values{"amount_total": total}, nil }) // compute_landed_cost: Compute and create valuation adjustment lines based on split method. // Mirrors: stock.landed.cost.compute_landed_cost() m.RegisterMethod("compute_landed_cost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, costID := range rs.IDs() { // Delete existing adjustment lines _, err := env.Tx().Exec(env.Ctx(), `DELETE FROM stock_valuation_adjustment_lines WHERE cost_id = $1`, costID) if err != nil { return nil, fmt.Errorf("stock.landed.cost: clear adjustments for %d: %w", costID, err) } // Get all moves from associated pickings moveRows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit FROM stock_move sm JOIN stock_picking sp ON sp.id = sm.picking_id JOIN stock_landed_cost_stock_picking_rel rel ON rel.stock_picking_id = sp.id WHERE rel.stock_landed_cost_id = $1 AND sm.state = 'done'`, costID, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: query moves for %d: %w", costID, err) } type moveInfo struct { ID int64 ProductID int64 Qty float64 UnitCost float64 } var moves []moveInfo var totalQty, totalWeight, totalVolume, totalCost float64 for moveRows.Next() { var mi moveInfo if err := moveRows.Scan(&mi.ID, &mi.ProductID, &mi.Qty, &mi.UnitCost); err != nil { moveRows.Close() return nil, fmt.Errorf("stock.landed.cost: scan move: %w", err) } moves = append(moves, mi) totalQty += mi.Qty totalCost += mi.Qty * mi.UnitCost } moveRows.Close() if len(moves) == 0 { continue } // Get cost lines costLineRows, err := env.Tx().Query(env.Ctx(), `SELECT id, price_unit, split_method FROM stock_landed_cost_lines WHERE cost_id = $1`, costID, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: query cost lines for %d: %w", costID, err) } type costLineInfo struct { ID int64 PriceUnit float64 SplitMethod string } var costLines []costLineInfo for costLineRows.Next() { var cl costLineInfo if err := costLineRows.Scan(&cl.ID, &cl.PriceUnit, &cl.SplitMethod); err != nil { costLineRows.Close() return nil, fmt.Errorf("stock.landed.cost: scan cost line: %w", err) } costLines = append(costLines, cl) } costLineRows.Close() // For each cost line, distribute costs across moves for _, cl := range costLines { for _, mv := range moves { var share float64 switch cl.SplitMethod { case "equal": share = cl.PriceUnit / float64(len(moves)) case "by_quantity": if totalQty > 0 { share = cl.PriceUnit * mv.Qty / totalQty } case "by_current_cost_price": moveCost := mv.Qty * mv.UnitCost if totalCost > 0 { share = cl.PriceUnit * moveCost / totalCost } case "by_weight": // Simplified: use quantity as proxy for weight if totalWeight > 0 { share = cl.PriceUnit * mv.Qty / totalWeight } else if totalQty > 0 { share = cl.PriceUnit * mv.Qty / totalQty } case "by_volume": // Simplified: use quantity as proxy for volume if totalVolume > 0 { share = cl.PriceUnit * mv.Qty / totalVolume } else if totalQty > 0 { share = cl.PriceUnit * mv.Qty / totalQty } default: share = cl.PriceUnit / float64(len(moves)) } formerCost := mv.Qty * mv.UnitCost finalCost := formerCost + share _, err := env.Tx().Exec(env.Ctx(), `INSERT INTO stock_valuation_adjustment_lines (cost_id, cost_line_id, move_id, product_id, quantity, former_cost, additional_landed_cost, final_cost) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, costID, cl.ID, mv.ID, mv.ProductID, mv.Qty, formerCost, share, finalCost, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: create adjustment line: %w", err) } } } } return true, nil }) // button_validate: Post the landed cost, apply valuation adjustments, and set state to done. // Mirrors: stock.landed.cost.button_validate() m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, costID := range rs.IDs() { // First compute the cost distribution lcModel := orm.Registry.Get("stock.landed.cost") if lcModel != nil { if computeMethod, ok := lcModel.Methods["compute_landed_cost"]; ok { lcRS := env.Model("stock.landed.cost").Browse(costID) if _, err := computeMethod(lcRS); err != nil { return nil, fmt.Errorf("stock.landed.cost: compute for validate %d: %w", costID, err) } } } // Apply adjustments to valuation layers adjRows, err := env.Tx().Query(env.Ctx(), `SELECT move_id, product_id, additional_landed_cost FROM stock_valuation_adjustment_lines WHERE cost_id = $1 AND additional_landed_cost != 0`, costID, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: query adjustments for %d: %w", costID, err) } type adjInfo struct { MoveID int64 ProductID int64 AdditionalCost float64 } var adjustments []adjInfo for adjRows.Next() { var adj adjInfo if err := adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost); err != nil { adjRows.Close() return nil, fmt.Errorf("stock.landed.cost: scan adjustment: %w", err) } adjustments = append(adjustments, adj) } adjRows.Close() for _, adj := range adjustments { // Update the corresponding valuation layer remaining_value _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_valuation_layer SET remaining_value = remaining_value + $1, value = value + $1 WHERE id = ( SELECT id FROM stock_valuation_layer WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0 ORDER BY id LIMIT 1 )`, adj.AdditionalCost, adj.MoveID, adj.ProductID, ) if err != nil { // Non-fatal: layer might not exist yet fmt.Printf("stock.landed.cost: update valuation layer for move %d: %v\n", adj.MoveID, err) } } _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_landed_cost SET state = 'done' WHERE id = $1`, costID) if err != nil { return nil, fmt.Errorf("stock.landed.cost: validate %d: %w", costID, err) } } return true, nil }) // action_cancel: Reverse the landed cost and set state to cancelled. // Mirrors: stock.landed.cost.action_cancel() m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, costID := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM stock_landed_cost WHERE id = $1`, costID, ).Scan(&state) if err != nil { return nil, fmt.Errorf("stock.landed.cost: read state for %d: %w", costID, err) } if state == "done" { // Reverse valuation adjustments adjRows, err := env.Tx().Query(env.Ctx(), `SELECT move_id, product_id, additional_landed_cost FROM stock_valuation_adjustment_lines WHERE cost_id = $1 AND additional_landed_cost != 0`, costID, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: query adjustments for cancel %d: %w", costID, err) } type adjInfo struct { MoveID int64 ProductID int64 AdditionalCost float64 } var adjustments []adjInfo for adjRows.Next() { var adj adjInfo adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost) adjustments = append(adjustments, adj) } adjRows.Close() for _, adj := range adjustments { env.Tx().Exec(env.Ctx(), `UPDATE stock_valuation_layer SET remaining_value = remaining_value - $1, value = value - $1 WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0`, adj.AdditionalCost, adj.MoveID, adj.ProductID, ) } } _, err = env.Tx().Exec(env.Ctx(), `UPDATE stock_landed_cost SET state = 'cancel' WHERE id = $1`, costID) if err != nil { return nil, fmt.Errorf("stock.landed.cost: cancel %d: %w", costID, err) } } return true, nil }) // get_cost_summary: Return a summary of landed cost distribution. // Mirrors: stock.landed.cost views / reporting m.RegisterMethod("get_cost_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() costID := rs.IDs()[0] // Get totals by product rows, err := env.Tx().Query(env.Ctx(), `SELECT val.product_id, COALESCE(SUM(val.former_cost), 0) as total_former, COALESCE(SUM(val.additional_landed_cost), 0) as total_additional, COALESCE(SUM(val.final_cost), 0) as total_final FROM stock_valuation_adjustment_lines val WHERE val.cost_id = $1 GROUP BY val.product_id ORDER BY val.product_id`, costID, ) if err != nil { return nil, fmt.Errorf("stock.landed.cost: query summary for %d: %w", costID, err) } defer rows.Close() var lines []map[string]interface{} for rows.Next() { var productID int64 var former, additional, final float64 if err := rows.Scan(&productID, &former, &additional, &final); err != nil { return nil, fmt.Errorf("stock.landed.cost: scan summary row: %w", err) } lines = append(lines, map[string]interface{}{ "product_id": productID, "former_cost": former, "additional_landed_cost": additional, "final_cost": final, }) } return map[string]interface{}{"summary": lines}, nil }) // --- Sub-models --- // stock.landed.cost.lines — individual cost items on a landed cost orm.NewModel("stock.landed.cost.lines", orm.ModelOpts{ Description: "Landed Cost Lines", }).AddFields( orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}), orm.Char("name", orm.FieldOpts{String: "Description", Required: true}), orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), orm.Float("price_unit", orm.FieldOpts{String: "Cost"}), orm.Selection("split_method", []orm.SelectionItem{ {Value: "equal", Label: "Equal"}, {Value: "by_quantity", Label: "By Quantity"}, {Value: "by_current_cost_price", Label: "By Current Cost"}, {Value: "by_weight", Label: "By Weight"}, {Value: "by_volume", Label: "By Volume"}, }, orm.FieldOpts{String: "Split Method", Default: "equal", Required: true}), orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}), ) // stock.valuation.adjustment.lines — per-move cost adjustments orm.NewModel("stock.valuation.adjustment.lines", orm.ModelOpts{ Description: "Valuation Adjustment Lines", }).AddFields( orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}), orm.Many2one("cost_line_id", "stock.landed.cost.lines", orm.FieldOpts{String: "Cost Line"}), orm.Many2one("move_id", "stock.move", orm.FieldOpts{String: "Stock Move"}), orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), orm.Float("quantity", orm.FieldOpts{String: "Quantity"}), orm.Float("weight", orm.FieldOpts{String: "Weight"}), orm.Float("volume", orm.FieldOpts{String: "Volume"}), orm.Monetary("former_cost", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}), orm.Monetary("additional_landed_cost", orm.FieldOpts{String: "Additional Cost", CurrencyField: "currency_id"}), orm.Monetary("final_cost", orm.FieldOpts{String: "Final Cost", CurrencyField: "currency_id"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), ) }