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:
Marc
2026-04-02 23:25:32 +02:00
parent 9ad633fc3c
commit 5d48737c9d
3 changed files with 921 additions and 171 deletions

View File

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