- 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>
747 lines
26 KiB
Go
747 lines
26 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
|
|
})
|
|
}
|
|
|
|
// initStockIntrastat registers stock.intrastat.line — Intrastat reporting model for
|
|
// EU cross-border trade declarations. Tracks move-level trade data.
|
|
// Mirrors: odoo/addons/stock_intrastat/models/stock_intrastat.py
|
|
func initStockIntrastat() {
|
|
m := orm.NewModel("stock.intrastat.line", orm.ModelOpts{
|
|
Description: "Intrastat Line",
|
|
Order: "id desc",
|
|
})
|
|
m.AddFields(
|
|
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
|
|
String: "Stock Move", Required: true, Index: true, OnDelete: orm.OnDeleteCascade,
|
|
}),
|
|
orm.Many2one("product_id", "product.product", orm.FieldOpts{
|
|
String: "Product", Required: true, Index: true,
|
|
}),
|
|
orm.Many2one("country_id", "res.country", orm.FieldOpts{
|
|
String: "Country", Required: true, Index: true,
|
|
}),
|
|
orm.Float("weight", orm.FieldOpts{String: "Weight (kg)", Required: true}),
|
|
orm.Monetary("value", orm.FieldOpts{String: "Fiscal Value", CurrencyField: "currency_id", Required: true}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
|
orm.Selection("transaction_type", []orm.SelectionItem{
|
|
{Value: "arrival", Label: "Arrival"},
|
|
{Value: "dispatch", Label: "Dispatch"},
|
|
}, orm.FieldOpts{String: "Transaction Type", Required: true, Index: true}),
|
|
orm.Char("intrastat_code", orm.FieldOpts{String: "Commodity Code"}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Index: true}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Index: true}),
|
|
orm.Char("transport_mode", orm.FieldOpts{String: "Transport Mode"}),
|
|
)
|
|
|
|
// generate_lines: Auto-generate Intrastat lines from done stock moves in a date range.
|
|
// Args: date_from (string), date_to (string), optional company_id (int64)
|
|
// Mirrors: stock.intrastat.report generation
|
|
m.RegisterMethod("generate_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("stock.intrastat.line.generate_lines requires date_from, date_to")
|
|
}
|
|
dateFrom, _ := args[0].(string)
|
|
dateTo, _ := args[1].(string)
|
|
if dateFrom == "" || dateTo == "" {
|
|
return nil, fmt.Errorf("stock.intrastat.line: invalid date range")
|
|
}
|
|
|
|
companyID := int64(1)
|
|
if len(args) >= 3 {
|
|
if cid, ok := args[2].(int64); ok && cid > 0 {
|
|
companyID = cid
|
|
}
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
// Find done moves crossing borders (source or dest is in a different country)
|
|
// For simplicity, look for moves between locations belonging to different warehouses
|
|
// or between internal and non-internal locations.
|
|
rows, err := env.Tx().Query(env.Ctx(),
|
|
`SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit, sm.date,
|
|
sl_src.usage as src_usage, sl_dst.usage as dst_usage,
|
|
COALESCE(rp.country_id, 0) as partner_country_id
|
|
FROM stock_move sm
|
|
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
|
|
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
|
|
LEFT JOIN res_partner rp ON rp.id = sp.partner_id
|
|
WHERE sm.state = 'done'
|
|
AND sm.date >= $1 AND sm.date <= $2
|
|
AND sm.company_id = $3
|
|
AND (
|
|
(sl_src.usage = 'supplier' AND sl_dst.usage = 'internal')
|
|
OR (sl_src.usage = 'internal' AND sl_dst.usage = 'customer')
|
|
)
|
|
ORDER BY sm.date`,
|
|
dateFrom, dateTo, companyID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.intrastat.line: query moves: %w", err)
|
|
}
|
|
|
|
type moveData struct {
|
|
MoveID, ProductID int64
|
|
Qty, PriceUnit float64
|
|
Date *string
|
|
SrcUsage string
|
|
DstUsage string
|
|
CountryID int64
|
|
}
|
|
var moves []moveData
|
|
for rows.Next() {
|
|
var md moveData
|
|
if err := rows.Scan(&md.MoveID, &md.ProductID, &md.Qty, &md.PriceUnit,
|
|
&md.Date, &md.SrcUsage, &md.DstUsage, &md.CountryID); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("stock.intrastat.line: scan move: %w", err)
|
|
}
|
|
moves = append(moves, md)
|
|
}
|
|
rows.Close()
|
|
|
|
var created int
|
|
for _, md := range moves {
|
|
// Determine transaction type
|
|
txnType := "arrival"
|
|
if md.SrcUsage == "internal" && md.DstUsage == "customer" {
|
|
txnType = "dispatch"
|
|
}
|
|
|
|
// Use partner country; skip if no country (can't determine border crossing)
|
|
countryID := md.CountryID
|
|
if countryID == 0 {
|
|
continue
|
|
}
|
|
|
|
// Compute value and weight
|
|
value := md.Qty * md.PriceUnit
|
|
weight := md.Qty // Simplified: weight = qty (would use product.weight in full impl)
|
|
|
|
dateStr := ""
|
|
if md.Date != nil {
|
|
dateStr = *md.Date
|
|
}
|
|
|
|
// Check if line already exists for this move
|
|
var existing int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM stock_intrastat_line WHERE move_id = $1 LIMIT 1`, md.MoveID,
|
|
).Scan(&existing)
|
|
if existing > 0 {
|
|
continue
|
|
}
|
|
|
|
_, err := env.Tx().Exec(env.Ctx(),
|
|
`INSERT INTO stock_intrastat_line
|
|
(move_id, product_id, country_id, weight, value, transaction_type, company_id, date)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
md.MoveID, md.ProductID, countryID, weight, value, txnType, companyID, dateStr,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.intrastat.line: create line for move %d: %w", md.MoveID, err)
|
|
}
|
|
created++
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"created": created,
|
|
"date_from": dateFrom,
|
|
"date_to": dateTo,
|
|
}, nil
|
|
})
|
|
|
|
// get_report: Return Intrastat report data for a period.
|
|
// Args: date_from (string), date_to (string), optional transaction_type (string)
|
|
// Mirrors: stock.intrastat.report views
|
|
m.RegisterMethod("get_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("stock.intrastat.line.get_report requires date_from, date_to")
|
|
}
|
|
dateFrom, _ := args[0].(string)
|
|
dateTo, _ := args[1].(string)
|
|
|
|
var txnTypeFilter string
|
|
if len(args) >= 3 {
|
|
txnTypeFilter, _ = args[2].(string)
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
query := `SELECT sil.id, sil.move_id, sil.product_id, pt.name as product_name,
|
|
sil.country_id, COALESCE(rc.name, '') as country_name,
|
|
sil.weight, sil.value, sil.transaction_type,
|
|
COALESCE(sil.intrastat_code, '') as commodity_code,
|
|
sil.date
|
|
FROM stock_intrastat_line sil
|
|
JOIN product_product pp ON pp.id = sil.product_id
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
LEFT JOIN res_country rc ON rc.id = sil.country_id
|
|
WHERE sil.date >= $1 AND sil.date <= $2`
|
|
|
|
queryArgs := []interface{}{dateFrom, dateTo}
|
|
|
|
if txnTypeFilter != "" {
|
|
query += ` AND sil.transaction_type = $3`
|
|
queryArgs = append(queryArgs, txnTypeFilter)
|
|
}
|
|
query += ` ORDER BY sil.date, sil.id`
|
|
|
|
rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.intrastat.line: query report: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var lines []map[string]interface{}
|
|
var totalWeight, totalValue float64
|
|
for rows.Next() {
|
|
var lineID, moveID, productID, countryID int64
|
|
var productName, countryName, txnType, commodityCode string
|
|
var weight, value float64
|
|
var date *string
|
|
if err := rows.Scan(&lineID, &moveID, &productID, &productName,
|
|
&countryID, &countryName, &weight, &value, &txnType,
|
|
&commodityCode, &date); err != nil {
|
|
return nil, fmt.Errorf("stock.intrastat.line: scan report row: %w", err)
|
|
}
|
|
dateStr := ""
|
|
if date != nil {
|
|
dateStr = *date
|
|
}
|
|
lines = append(lines, map[string]interface{}{
|
|
"id": lineID, "move_id": moveID,
|
|
"product_id": productID, "product": productName,
|
|
"country_id": countryID, "country": countryName,
|
|
"weight": weight, "value": value,
|
|
"transaction_type": txnType,
|
|
"commodity_code": commodityCode,
|
|
"date": dateStr,
|
|
})
|
|
totalWeight += weight
|
|
totalValue += value
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"lines": lines,
|
|
"total_weight": totalWeight,
|
|
"total_value": totalValue,
|
|
"date_from": dateFrom,
|
|
"date_to": dateTo,
|
|
}, nil
|
|
})
|
|
}
|