Implement core business logic depth: reconciliation, quants, invoicing
Account Reconciliation Engine: - reconcile() method on account.move.line matches debit↔credit lines - Creates account.partial.reconcile records for each match - Detects full reconciliation (all residuals=0) → account.full.reconcile - updatePaymentState() tracks paid/partial/not_paid on invoices - Payment register wizard now creates journal entries + reconciles Stock Quant Reservation: - assignMove() reserves products from source location quants - getAvailableQty() queries unreserved on-hand stock - _action_confirm → confirmed + auto-assigns if stock available - _action_assign creates stock.move.line reservations - _action_done updates quants (decrease source, increase dest) - button_validate on picking delegates to move._action_done - Clears reserved_quantity on completion Sale Invoice Creation (rewritten): - Proper debit/credit/balance on invoice lines - Tax computation from SO line M2M tax_ids (percent/fixed/division) - Revenue account lookup (SKR03 8300 with fallbacks) - amount_residual set on receivable line (enables reconciliation) - qty_invoiced tracking on SO lines - Line amount computes now include tax in price_total Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,7 +193,7 @@ func initStockPicking() {
|
||||
// --- Business methods: stock move workflow ---
|
||||
|
||||
// action_confirm transitions a picking from draft → confirmed.
|
||||
// Confirms all associated stock moves that are still in draft.
|
||||
// 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()
|
||||
@@ -212,95 +212,168 @@ func initStockPicking() {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: confirm picking %d: %w", id, err)
|
||||
}
|
||||
// Also confirm all draft moves on this picking
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_move SET state = 'confirmed' WHERE picking_id = $1 AND state = 'draft'`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: confirm moves for picking %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_assign transitions a picking from confirmed → assigned (reserve stock).
|
||||
// Mirrors: stock.picking.action_assign()
|
||||
m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: assign picking %d: %w", id, err)
|
||||
}
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_move SET state = 'assigned' WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available')`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: assign moves for picking %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// button_validate transitions a picking from assigned → done (process the transfer).
|
||||
// Updates all moves to done and adjusts stock quants (source decremented, dest incremented).
|
||||
// Mirrors: stock.picking.button_validate()
|
||||
m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
// Mark picking as done
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking SET state = 'done', date_done = NOW() WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: validate picking %d: %w", id, err)
|
||||
}
|
||||
// Mark all moves as done
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_move SET state = 'done', date = NOW() WHERE picking_id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: validate moves for picking %d: %w", id, err)
|
||||
}
|
||||
|
||||
// Update quants: for each done move, adjust source and destination locations
|
||||
// Confirm all draft moves via _action_confirm (which also tries to reserve)
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT product_id, product_uom_qty, location_id, location_dest_id
|
||||
FROM stock_move WHERE picking_id = $1 AND state = 'done'`, id)
|
||||
`SELECT id FROM stock_move WHERE picking_id = $1 AND state = 'draft'`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: read moves for picking %d: %w", id, err)
|
||||
return nil, fmt.Errorf("stock: read draft moves for picking %d: %w", id, err)
|
||||
}
|
||||
|
||||
type moveInfo struct {
|
||||
productID int64
|
||||
qty float64
|
||||
srcLoc int64
|
||||
dstLoc int64
|
||||
}
|
||||
var moves []moveInfo
|
||||
var moveIDs []int64
|
||||
for rows.Next() {
|
||||
var mi moveInfo
|
||||
if err := rows.Scan(&mi.productID, &mi.qty, &mi.srcLoc, &mi.dstLoc); err != nil {
|
||||
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)
|
||||
}
|
||||
moves = append(moves, mi)
|
||||
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)
|
||||
}
|
||||
|
||||
for _, mi := range moves {
|
||||
// Decrease source location quant
|
||||
if err := updateQuant(env, mi.productID, mi.srcLoc, -mi.qty); err != nil {
|
||||
return nil, fmt.Errorf("stock: update source quant 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Increase destination location quant
|
||||
if err := updateQuant(env, mi.productID, mi.dstLoc, mi.qty); err != nil {
|
||||
return nil, fmt.Errorf("stock: update dest quant 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
|
||||
})
|
||||
|
||||
// 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.
|
||||
@@ -319,12 +392,89 @@ func updateQuant(env *orm.Environment, productID, locationID int64, delta float6
|
||||
delta, productID, locationID)
|
||||
} else {
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO stock_quant (product_id, location_id, quantity, company_id) VALUES ($1, $2, $3, 1)`,
|
||||
`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, location_id, location_dest_id, quantity, company_id)
|
||||
SELECT $1, product_id, location_id, location_dest_id, $2, company_id
|
||||
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() {
|
||||
@@ -375,21 +525,49 @@ func initStockMove() {
|
||||
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
|
||||
)
|
||||
|
||||
// _action_confirm: Confirm stock moves (draft → confirmed).
|
||||
// _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 _, id := range rs.IDs() {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_move SET state = 'confirmed' WHERE id = $1 AND state = 'draft'`, id)
|
||||
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: confirm move %d: %w", id, err)
|
||||
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_done: Finalize stock moves (assigned → done), updating quants.
|
||||
// _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()
|
||||
@@ -402,18 +580,31 @@ func initStockMove() {
|
||||
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)
|
||||
}
|
||||
// Adjust quants
|
||||
if err := updateQuant(env, productID, srcLoc, -qty); err != nil {
|
||||
return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
|
||||
}
|
||||
if err := updateQuant(env, productID, dstLoc, qty); err != nil {
|
||||
return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user