package models import ( "fmt" "odoo-go/pkg/orm" ) // initStockReport registers stock.report — transient model for stock quantity reporting. // Mirrors: odoo/addons/stock/report/stock_report_views.py func initStockReport() { m := orm.NewModel("stock.report", orm.ModelOpts{ Description: "Stock Report", Type: orm.ModelTransient, }) m.AddFields( orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location"}), orm.Date("date_from", orm.FieldOpts{String: "From"}), orm.Date("date_to", orm.FieldOpts{String: "To"}), ) // get_stock_data: Aggregate on-hand / reserved / available per product+location. // Mirrors: stock.report logic from Odoo stock views. m.RegisterMethod("get_stock_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() query := ` SELECT p.id, pt.name as product_name, l.name as location_name, COALESCE(SUM(q.quantity), 0) as on_hand, COALESCE(SUM(q.reserved_quantity), 0) as reserved, COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available FROM stock_quant q JOIN product_product p ON p.id = q.product_id JOIN product_template pt ON pt.id = p.product_tmpl_id JOIN stock_location l ON l.id = q.location_id WHERE l.usage = 'internal' GROUP BY p.id, pt.name, l.name ORDER BY pt.name, l.name` rows, err := env.Tx().Query(env.Ctx(), query) if err != nil { return nil, fmt.Errorf("stock.report: query stock data: %w", err) } defer rows.Close() var lines []map[string]interface{} for rows.Next() { var prodID int64 var prodName, locName string var onHand, reserved, available float64 if err := rows.Scan(&prodID, &prodName, &locName, &onHand, &reserved, &available); err != nil { return nil, fmt.Errorf("stock.report: scan row: %w", err) } lines = append(lines, map[string]interface{}{ "product_id": prodID, "product": prodName, "location": locName, "on_hand": onHand, "reserved": reserved, "available": available, }) } return map[string]interface{}{"lines": lines}, nil }) // get_stock_data_by_product: Aggregate stock for a specific product across all internal locations. // Mirrors: stock.report filtered by product m.RegisterMethod("get_stock_data_by_product", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("stock.report.get_stock_data_by_product requires product_id") } productID, _ := args[0].(int64) if productID == 0 { return nil, fmt.Errorf("stock.report: invalid product_id") } env := rs.Env() rows, err := env.Tx().Query(env.Ctx(), `SELECT l.id, l.complete_name, COALESCE(SUM(q.quantity), 0) as on_hand, COALESCE(SUM(q.reserved_quantity), 0) as reserved, COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available FROM stock_quant q JOIN stock_location l ON l.id = q.location_id WHERE q.product_id = $1 AND l.usage = 'internal' GROUP BY l.id, l.complete_name ORDER BY l.complete_name`, productID, ) if err != nil { return nil, fmt.Errorf("stock.report: query by product: %w", err) } defer rows.Close() var lines []map[string]interface{} var totalOnHand, totalReserved, totalAvailable float64 for rows.Next() { var locID int64 var locName string var onHand, reserved, available float64 if err := rows.Scan(&locID, &locName, &onHand, &reserved, &available); err != nil { return nil, fmt.Errorf("stock.report: scan by product row: %w", err) } lines = append(lines, map[string]interface{}{ "location_id": locID, "location": locName, "on_hand": onHand, "reserved": reserved, "available": available, }) totalOnHand += onHand totalReserved += reserved totalAvailable += available } return map[string]interface{}{ "product_id": productID, "lines": lines, "total_on_hand": totalOnHand, "total_reserved": totalReserved, "total_available": totalAvailable, }, nil }) // get_stock_data_by_location: Aggregate stock for a specific location across all products. // Mirrors: stock.report filtered by location m.RegisterMethod("get_stock_data_by_location", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("stock.report.get_stock_data_by_location requires location_id") } locationID, _ := args[0].(int64) if locationID == 0 { return nil, fmt.Errorf("stock.report: invalid location_id") } env := rs.Env() rows, err := env.Tx().Query(env.Ctx(), `SELECT p.id, pt.name as product_name, COALESCE(SUM(q.quantity), 0) as on_hand, COALESCE(SUM(q.reserved_quantity), 0) as reserved, COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available FROM stock_quant q JOIN product_product p ON p.id = q.product_id JOIN product_template pt ON pt.id = p.product_tmpl_id WHERE q.location_id = $1 GROUP BY p.id, pt.name ORDER BY pt.name`, locationID, ) if err != nil { return nil, fmt.Errorf("stock.report: query by location: %w", err) } defer rows.Close() var lines []map[string]interface{} var totalOnHand, totalReserved, totalAvailable float64 for rows.Next() { var prodID int64 var prodName string var onHand, reserved, available float64 if err := rows.Scan(&prodID, &prodName, &onHand, &reserved, &available); err != nil { return nil, fmt.Errorf("stock.report: scan by location row: %w", err) } lines = append(lines, map[string]interface{}{ "product_id": prodID, "product": prodName, "on_hand": onHand, "reserved": reserved, "available": available, }) totalOnHand += onHand totalReserved += reserved totalAvailable += available } return map[string]interface{}{ "location_id": locationID, "lines": lines, "total_on_hand": totalOnHand, "total_reserved": totalReserved, "total_available": totalAvailable, }, nil }) // get_move_history: Return stock move history with filters. // Mirrors: stock.move.line reporting / traceability m.RegisterMethod("get_move_history", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() query := ` SELECT sm.id, sm.name, sm.product_id, pt.name as product_name, sm.product_uom_qty, sm.state, sl_src.name as source_location, sl_dst.name as dest_location, sm.date, sm.origin 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 JOIN stock_location sl_src ON sl_src.id = sm.location_id JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id WHERE sm.state = 'done' ORDER BY sm.date DESC LIMIT 100` rows, err := env.Tx().Query(env.Ctx(), query) if err != nil { return nil, fmt.Errorf("stock.report: query move history: %w", err) } defer rows.Close() var moves []map[string]interface{} for rows.Next() { var moveID, productID int64 var name, productName, state, srcLoc, dstLoc string var qty float64 var date, origin *string if err := rows.Scan(&moveID, &name, &productID, &productName, &qty, &state, &srcLoc, &dstLoc, &date, &origin); err != nil { return nil, fmt.Errorf("stock.report: scan move history row: %w", err) } dateStr := "" if date != nil { dateStr = *date } originStr := "" if origin != nil { originStr = *origin } moves = append(moves, map[string]interface{}{ "id": moveID, "name": name, "product_id": productID, "product": productName, "quantity": qty, "state": state, "source_location": srcLoc, "dest_location": dstLoc, "date": dateStr, "origin": originStr, }) } return map[string]interface{}{"moves": moves}, nil }) // get_inventory_valuation: Return total inventory valuation by product. // Mirrors: stock report valuation views m.RegisterMethod("get_inventory_valuation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() rows, err := env.Tx().Query(env.Ctx(), `SELECT p.id, pt.name as product_name, COALESCE(SUM(q.quantity), 0) as total_qty, COALESCE(SUM(q.value), 0) as total_value FROM stock_quant q JOIN product_product p ON p.id = q.product_id JOIN product_template pt ON pt.id = p.product_tmpl_id JOIN stock_location l ON l.id = q.location_id WHERE l.usage = 'internal' GROUP BY p.id, pt.name HAVING SUM(q.quantity) > 0 ORDER BY pt.name`, ) if err != nil { return nil, fmt.Errorf("stock.report: query valuation: %w", err) } defer rows.Close() var lines []map[string]interface{} var grandTotalQty, grandTotalValue float64 for rows.Next() { var prodID int64 var prodName string var totalQty, totalValue float64 if err := rows.Scan(&prodID, &prodName, &totalQty, &totalValue); err != nil { return nil, fmt.Errorf("stock.report: scan valuation row: %w", err) } avgCost := float64(0) if totalQty > 0 { avgCost = totalValue / totalQty } lines = append(lines, map[string]interface{}{ "product_id": prodID, "product": prodName, "quantity": totalQty, "value": totalValue, "average_cost": avgCost, }) grandTotalQty += totalQty grandTotalValue += totalValue } return map[string]interface{}{ "lines": lines, "total_qty": grandTotalQty, "total_value": grandTotalValue, }, nil }) } // initStockForecast registers stock.forecasted.product — transient model for forecast computation. // Mirrors: odoo/addons/stock/models/stock_forecasted.py func initStockForecast() { m := orm.NewModel("stock.forecasted.product", orm.ModelOpts{ Description: "Forecasted Stock", Type: orm.ModelTransient, }) m.AddFields( orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), ) // get_forecast: Compute on-hand, incoming, outgoing and forecast for a product. // Mirrors: stock.forecasted.product_product._get_report_data() m.RegisterMethod("get_forecast", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() productID := int64(0) if len(args) > 0 { if p, ok := args[0].(float64); ok { productID = int64(p) } } // On hand 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 IN (SELECT id FROM stock_location WHERE usage = 'internal')`, productID).Scan(&onHand) // Incoming (confirmed moves TO internal locations) var incoming float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting') AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, productID).Scan(&incoming) // Outgoing (confirmed moves FROM internal locations) var outgoing float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting') AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, productID).Scan(&outgoing) return map[string]interface{}{ "on_hand": onHand, "incoming": incoming, "outgoing": outgoing, "forecast": onHand + incoming - outgoing, }, nil }) // get_forecast_details: Detailed forecast with move-level breakdown. // Mirrors: stock.forecasted.product_product._get_report_lines() m.RegisterMethod("get_forecast_details", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() productID := int64(0) if len(args) > 0 { if p, ok := args[0].(float64); ok { productID = int64(p) } } if productID == 0 { return nil, fmt.Errorf("stock.forecasted.product: product_id required") } // On hand 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 IN (SELECT id FROM stock_location WHERE usage = 'internal')`, productID).Scan(&onHand) // Incoming moves inRows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state, sl.name as source_location, sld.name as dest_location, sp.name as picking_name FROM stock_move sm JOIN stock_location sl ON sl.id = sm.location_id JOIN stock_location sld ON sld.id = sm.location_dest_id LEFT JOIN stock_picking sp ON sp.id = sm.picking_id WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting') AND sm.location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal') ORDER BY sm.date`, productID, ) if err != nil { return nil, fmt.Errorf("stock.forecasted: query incoming moves: %w", err) } defer inRows.Close() var incomingMoves []map[string]interface{} var totalIncoming float64 for inRows.Next() { var moveID int64 var name, state, srcLoc, dstLoc string var qty float64 var date, pickingName *string if err := inRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil { return nil, fmt.Errorf("stock.forecasted: scan incoming move: %w", err) } dateStr := "" if date != nil { dateStr = *date } pickStr := "" if pickingName != nil { pickStr = *pickingName } incomingMoves = append(incomingMoves, map[string]interface{}{ "id": moveID, "name": name, "quantity": qty, "date": dateStr, "state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr, }) totalIncoming += qty } // Outgoing moves outRows, err := env.Tx().Query(env.Ctx(), `SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state, sl.name as source_location, sld.name as dest_location, sp.name as picking_name FROM stock_move sm JOIN stock_location sl ON sl.id = sm.location_id JOIN stock_location sld ON sld.id = sm.location_dest_id LEFT JOIN stock_picking sp ON sp.id = sm.picking_id WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting') AND sm.location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') ORDER BY sm.date`, productID, ) if err != nil { return nil, fmt.Errorf("stock.forecasted: query outgoing moves: %w", err) } defer outRows.Close() var outgoingMoves []map[string]interface{} var totalOutgoing float64 for outRows.Next() { var moveID int64 var name, state, srcLoc, dstLoc string var qty float64 var date, pickingName *string if err := outRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil { return nil, fmt.Errorf("stock.forecasted: scan outgoing move: %w", err) } dateStr := "" if date != nil { dateStr = *date } pickStr := "" if pickingName != nil { pickStr = *pickingName } outgoingMoves = append(outgoingMoves, map[string]interface{}{ "id": moveID, "name": name, "quantity": qty, "date": dateStr, "state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr, }) totalOutgoing += qty } forecast := onHand + totalIncoming - totalOutgoing return map[string]interface{}{ "product_id": productID, "on_hand": onHand, "incoming": totalIncoming, "outgoing": totalOutgoing, "forecast": forecast, "incoming_moves": incomingMoves, "outgoing_moves": outgoingMoves, }, nil }) // get_forecast_all: Compute forecast for all products with stock or pending moves. // Mirrors: stock.forecasted overview m.RegisterMethod("get_forecast_all", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() rows, err := env.Tx().Query(env.Ctx(), `SELECT p.id, pt.name as product_name, COALESCE(oh.on_hand, 0) as on_hand, COALESCE(inc.incoming, 0) as incoming, COALESCE(outg.outgoing, 0) as outgoing FROM product_product p JOIN product_template pt ON pt.id = p.product_tmpl_id LEFT JOIN ( SELECT product_id, SUM(quantity - reserved_quantity) as on_hand FROM stock_quant WHERE location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') GROUP BY product_id ) oh ON oh.product_id = p.id LEFT JOIN ( SELECT product_id, SUM(product_uom_qty) as incoming FROM stock_move WHERE state IN ('confirmed','assigned','waiting') AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal') GROUP BY product_id ) inc ON inc.product_id = p.id LEFT JOIN ( SELECT product_id, SUM(product_uom_qty) as outgoing FROM stock_move WHERE state IN ('confirmed','assigned','waiting') AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') GROUP BY product_id ) outg ON outg.product_id = p.id WHERE COALESCE(oh.on_hand, 0) != 0 OR COALESCE(inc.incoming, 0) != 0 OR COALESCE(outg.outgoing, 0) != 0 ORDER BY pt.name`, ) if err != nil { return nil, fmt.Errorf("stock.forecasted: query all forecasts: %w", err) } defer rows.Close() var products []map[string]interface{} for rows.Next() { var prodID int64 var prodName string var onHand, incoming, outgoing float64 if err := rows.Scan(&prodID, &prodName, &onHand, &incoming, &outgoing); err != nil { return nil, fmt.Errorf("stock.forecasted: scan forecast row: %w", err) } products = append(products, map[string]interface{}{ "product_id": prodID, "product": prodName, "on_hand": onHand, "incoming": incoming, "outgoing": outgoing, "forecast": onHand + incoming - outgoing, }) } return map[string]interface{}{"products": products}, nil }) }