Account: - action_post: partner validation, line count check, sequence number assignment (JOURNAL/YYYY/NNNN format) - action_register_payment: opens payment wizard from invoice - remove_move_reconcile: undo reconciliation, reset residuals - Register Payment button in invoice form (visible when posted+unpaid) Sale: - action_cancel: cancels linked draft invoices + SO state - action_draft: reset cancelled SO to draft - action_view_invoice: navigate to linked invoices - Cancel/Reset buttons in form view header Purchase: - button_draft: reset cancelled PO to draft - action_create_bill already existed Stock: - action_cancel on picking: cancels moves + picking state CRM: - action_set_won_rainbowman: sets Won stage + rainbow effect - convert_opportunity: lead→opportunity type switch HR: - hr.contract model (name, employee, wage, dates, state) Project: - action_blocked on task (kanban_state) - Task stage seed data (New, In Progress, Done, Cancelled) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
791 lines
29 KiB
Go
791 lines
29 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"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()
|
|
}
|
|
|
|
// 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}),
|
|
)
|
|
|
|
// --- 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 == "/" {
|
|
vals["name"] = fmt.Sprintf("WH/IN/%05d", time.Now().UnixNano()%100000)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Business methods: stock move workflow ---
|
|
|
|
// action_confirm transitions a picking from draft → confirmed.
|
|
// Confirms all associated stock moves via _action_confirm (which also reserves).
|
|
// 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)
|
|
}
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_picking SET state = 'confirmed' WHERE id = $1`, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: confirm picking %d: %w", id, err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update picking state based on move states after reservation
|
|
var allAssigned bool
|
|
err = env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT NOT EXISTS(
|
|
SELECT 1 FROM stock_move
|
|
WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
|
|
)`, id).Scan(&allAssigned)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: check move states for picking %d: %w", id, err)
|
|
}
|
|
if allAssigned {
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: update picking %d to assigned: %w", id, err)
|
|
}
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// action_assign reserves stock for all confirmed/partially_available moves on the picking.
|
|
// 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
|
|
rows, err := env.Tx().Query(env.Ctx(),
|
|
`SELECT id FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', '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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update picking state based on move states
|
|
var allAssigned bool
|
|
err = env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT NOT EXISTS(
|
|
SELECT 1 FROM stock_move
|
|
WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
|
|
)`, pickingID).Scan(&allAssigned)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: check move states for picking %d: %w", pickingID, err)
|
|
}
|
|
if allAssigned {
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, pickingID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: update picking %d state: %w", pickingID, err)
|
|
}
|
|
}
|
|
return true, 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() {
|
|
env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID)
|
|
env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID)
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// button_validate transitions a picking → done via _action_done on its moves.
|
|
// 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() {
|
|
// Get all non-cancelled moves for this picking
|
|
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
|
|
}
|
|
|
|
// Call _action_done on all moves
|
|
moveRS := env.Model("stock.move").Browse(moveIDs...)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update picking state
|
|
_, 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
|
|
})
|
|
}
|
|
|
|
// 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 {
|
|
var exists bool
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT EXISTS(SELECT 1 FROM stock_quant WHERE product_id = $1 AND location_id = $2)`,
|
|
productID, locationID).Scan(&exists)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_quant SET quantity = quantity + $1 WHERE product_id = $2 AND location_id = $3`,
|
|
delta, productID, locationID)
|
|
} else {
|
|
_, 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)`,
|
|
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
|
|
}
|
|
|
|
// 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.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.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
|
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"}),
|
|
)
|
|
|
|
// _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 qty 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, &qty, &srcLoc, &dstLoc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: read move %d for done: %w", id, err)
|
|
}
|
|
|
|
// Decrease source quant
|
|
if err := updateQuant(env, productID, srcLoc, -qty); err != nil {
|
|
return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
|
|
}
|
|
// Increase destination quant
|
|
if err := updateQuant(env, productID, dstLoc, qty); err != nil {
|
|
return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
|
|
}
|
|
|
|
// Clear reservation on source quant
|
|
_, 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`,
|
|
qty, productID, srcLoc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: clear reservation for move %d: %w", id, err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
return true, 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.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
|
|
})
|
|
|
|
// 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"}),
|
|
)
|
|
}
|