Files
goodie/addons/stock/models/stock_report.go
Marc bdb97f98ad Massive module expansion: Stock, CRM, HR — +2895 LOC
Stock (1193→2867 LOC):
- Valuation layers (FIFO consumption, product valuation history)
- Landed costs (split by equal/qty/cost/weight/volume, validation)
- Stock reports (by product, by location, move history, valuation)
- Forecasting (on_hand + incoming - outgoing per product)
- Batch transfers (confirm/assign/done with picking delegation)
- Barcode interface (scan product/lot/package/location, qty increment)

CRM (233→1113 LOC):
- Sales teams with dashboard KPIs (opportunity count/amount/unassigned)
- Team members with lead capacity + round-robin auto-assignment
- Lead extended: activities, UTM tracking, scoring, address fields
- Lead methods: merge, duplicate, schedule activity, set priority/stage
- Pipeline analysis (stages, win rate, conversion, team/salesperson perf)
- Partner onchange (auto-populate contact from partner)

HR (223→520 LOC):
- Leave management: hr.leave.type, hr.leave, hr.leave.allocation
  with full approval workflow (draft→confirm→validate/refuse)
- Attendance: check in/out with computed worked_hours
- Expenses: hr.expense + hr.expense.sheet with state machine
- Skills/Resume: skill types, employee skills, resume lines
- Employee extensions: skills, attendance, leave count links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:21:52 +02:00

516 lines
18 KiB
Go

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
})
}