- 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>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"odoo-go/pkg/orm"
|
|
)
|
|
|
|
// initStockBarcode registers stock.barcode.picking — transient model for barcode scanning interface.
|
|
// Mirrors: odoo/addons/stock_barcode/models/stock_picking.py barcode processing
|
|
func initStockBarcode() {
|
|
m := orm.NewModel("stock.barcode.picking", orm.ModelOpts{
|
|
Description: "Barcode Picking Interface",
|
|
Type: orm.ModelTransient,
|
|
})
|
|
m.AddFields(
|
|
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{String: "Transfer"}),
|
|
orm.Char("barcode", orm.FieldOpts{String: "Barcode"}),
|
|
)
|
|
|
|
// process_barcode: Look up a product or lot/serial by barcode.
|
|
// Mirrors: stock_barcode barcode scanning logic
|
|
m.RegisterMethod("process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
if len(args) < 1 {
|
|
return nil, fmt.Errorf("barcode required")
|
|
}
|
|
barcode, _ := args[0].(string)
|
|
|
|
// Try to find product by barcode
|
|
var productID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pp.id FROM product_product pp
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pt.barcode = $1 OR pp.barcode = $1 LIMIT 1`, barcode,
|
|
).Scan(&productID)
|
|
|
|
if productID > 0 {
|
|
return map[string]interface{}{"product_id": productID, "found": true}, nil
|
|
}
|
|
|
|
// Try lot/serial
|
|
var lotID, lotProductID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id, product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
|
|
).Scan(&lotID, &lotProductID)
|
|
|
|
if lotID > 0 {
|
|
return map[string]interface{}{"lot_id": lotID, "product_id": lotProductID, "found": true}, nil
|
|
}
|
|
|
|
// Try package
|
|
var packageID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM stock_quant_package WHERE name = $1 LIMIT 1`, barcode,
|
|
).Scan(&packageID)
|
|
|
|
if packageID > 0 {
|
|
return map[string]interface{}{"package_id": packageID, "found": true}, nil
|
|
}
|
|
|
|
// Try location barcode
|
|
var locationID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM stock_location WHERE barcode = $1 LIMIT 1`, barcode,
|
|
).Scan(&locationID)
|
|
|
|
if locationID > 0 {
|
|
return map[string]interface{}{"location_id": locationID, "found": true}, nil
|
|
}
|
|
|
|
return map[string]interface{}{"found": false, "barcode": barcode}, nil
|
|
})
|
|
|
|
// action_process_barcode: Enhanced barcode scan loop — handles UPC/EAN by searching
|
|
// product.product.barcode field directly. Supports UPC-A (12 digits), EAN-13 (13 digits),
|
|
// EAN-8 (8 digits), and arbitrary barcodes. In the context of a picking, increments
|
|
// qty_done on the matching move line.
|
|
// Mirrors: stock_barcode.picking barcode scan loop with UPC/EAN support
|
|
m.RegisterMethod("action_process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("stock.barcode.picking.action_process_barcode requires picking_id, barcode")
|
|
}
|
|
pickingID, _ := args[0].(int64)
|
|
barcode, _ := args[1].(string)
|
|
if pickingID == 0 || barcode == "" {
|
|
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id or barcode")
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
// Step 1: Try to find product by barcode on product.product.barcode (UPC/EAN stored here)
|
|
var productID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, barcode,
|
|
).Scan(&productID)
|
|
|
|
// Step 2: If not found on product_product, try product_template.barcode
|
|
if productID == 0 {
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pp.id FROM product_product pp
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pt.barcode = $1 LIMIT 1`, barcode,
|
|
).Scan(&productID)
|
|
}
|
|
|
|
// Step 3: For UPC-A (12 digits), try converting to EAN-13 by prepending '0'
|
|
if productID == 0 && len(barcode) == 12 && isNumeric(barcode) {
|
|
ean13 := "0" + barcode
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, ean13,
|
|
).Scan(&productID)
|
|
// Also try the reverse: if stored as UPC but scanned as EAN
|
|
if productID == 0 {
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pp.id FROM product_product pp
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pt.barcode = $1 LIMIT 1`, ean13,
|
|
).Scan(&productID)
|
|
}
|
|
}
|
|
|
|
// Step 4: For EAN-13 (13 digits starting with 0), try stripping leading 0 to get UPC-A
|
|
if productID == 0 && len(barcode) == 13 && barcode[0] == '0' && isNumeric(barcode) {
|
|
upc := barcode[1:]
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, upc,
|
|
).Scan(&productID)
|
|
if productID == 0 {
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pp.id FROM product_product pp
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pt.barcode = $1 LIMIT 1`, upc,
|
|
).Scan(&productID)
|
|
}
|
|
}
|
|
|
|
// Step 5: Try lot/serial number
|
|
if productID == 0 {
|
|
var lotProductID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
|
|
).Scan(&lotProductID)
|
|
productID = lotProductID
|
|
}
|
|
|
|
if productID == 0 {
|
|
return map[string]interface{}{
|
|
"found": false,
|
|
"barcode": barcode,
|
|
"message": fmt.Sprintf("No product found for barcode %q (tried UPC/EAN lookup)", barcode),
|
|
}, nil
|
|
}
|
|
|
|
// Step 6: Find matching move line in the picking
|
|
var moveLineID int64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT sml.id FROM stock_move_line sml
|
|
JOIN stock_move sm ON sm.id = sml.move_id
|
|
WHERE sm.picking_id = $1 AND sml.product_id = $2 AND sm.state NOT IN ('done', 'cancel')
|
|
ORDER BY sml.id LIMIT 1`,
|
|
pickingID, productID,
|
|
).Scan(&moveLineID)
|
|
|
|
if err != nil || moveLineID == 0 {
|
|
// Check if product expected in any move
|
|
var moveID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state NOT IN ('done', 'cancel') LIMIT 1`,
|
|
pickingID, productID,
|
|
).Scan(&moveID)
|
|
|
|
if moveID == 0 {
|
|
return map[string]interface{}{
|
|
"found": false,
|
|
"product_id": productID,
|
|
"message": fmt.Sprintf("Product %d not expected in this transfer", productID),
|
|
}, nil
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"found": true,
|
|
"product_id": productID,
|
|
"move_id": moveID,
|
|
"action": "new_line",
|
|
"message": "Product found in move, new line needed",
|
|
}, nil
|
|
}
|
|
|
|
// Increment quantity on the move line
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_move_line SET quantity = quantity + 1 WHERE id = $1`, moveLineID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.barcode.picking: increment qty on move line %d: %w", moveLineID, err)
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"found": true,
|
|
"product_id": productID,
|
|
"move_line_id": moveLineID,
|
|
"action": "incremented",
|
|
"message": "Quantity incremented",
|
|
}, nil
|
|
})
|
|
|
|
// process_barcode_picking: Process a barcode in the context of a picking.
|
|
// Finds the product and increments qty_done on the matching move line.
|
|
// Mirrors: stock_barcode.picking barcode processing
|
|
m.RegisterMethod("process_barcode_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("stock.barcode.picking.process_barcode_picking requires picking_id, barcode")
|
|
}
|
|
pickingID, _ := args[0].(int64)
|
|
barcode, _ := args[1].(string)
|
|
|
|
if pickingID == 0 || barcode == "" {
|
|
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id or barcode")
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
// Find product by barcode
|
|
var productID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pp.id FROM product_product pp
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pt.barcode = $1 OR pp.barcode = $1 LIMIT 1`, barcode,
|
|
).Scan(&productID)
|
|
|
|
if productID == 0 {
|
|
// Try lot/serial
|
|
var lotProductID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
|
|
).Scan(&lotProductID)
|
|
productID = lotProductID
|
|
}
|
|
|
|
if productID == 0 {
|
|
return map[string]interface{}{
|
|
"found": false, "barcode": barcode,
|
|
"message": fmt.Sprintf("No product found for barcode %q", barcode),
|
|
}, nil
|
|
}
|
|
|
|
// Find matching move line
|
|
var moveLineID int64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT sml.id FROM stock_move_line sml
|
|
JOIN stock_move sm ON sm.id = sml.move_id
|
|
WHERE sm.picking_id = $1 AND sml.product_id = $2 AND sm.state != 'done'
|
|
ORDER BY sml.id LIMIT 1`,
|
|
pickingID, productID,
|
|
).Scan(&moveLineID)
|
|
|
|
if err != nil || moveLineID == 0 {
|
|
// No existing move line — check if there is a move for this product
|
|
var moveID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state != 'done' LIMIT 1`,
|
|
pickingID, productID,
|
|
).Scan(&moveID)
|
|
|
|
if moveID == 0 {
|
|
return map[string]interface{}{
|
|
"found": false, "product_id": productID,
|
|
"message": fmt.Sprintf("Product %d not expected in this transfer", productID),
|
|
}, nil
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"found": true, "product_id": productID, "move_id": moveID,
|
|
"action": "new_line",
|
|
"message": "Product found in move, new line needed",
|
|
}, nil
|
|
}
|
|
|
|
// Increment quantity on the move line
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE stock_move_line SET quantity = quantity + 1 WHERE id = $1`, moveLineID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.barcode.picking: increment qty on move line %d: %w", moveLineID, err)
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"found": true, "product_id": productID, "move_line_id": moveLineID,
|
|
"action": "incremented", "message": "Quantity incremented",
|
|
}, nil
|
|
})
|
|
|
|
// get_picking_data: Return the full picking data for the barcode interface.
|
|
// Mirrors: stock_barcode.picking get_barcode_data()
|
|
m.RegisterMethod("get_picking_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 1 {
|
|
return nil, fmt.Errorf("stock.barcode.picking.get_picking_data requires picking_id")
|
|
}
|
|
pickingID, _ := args[0].(int64)
|
|
if pickingID == 0 {
|
|
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id")
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
// Get picking header
|
|
var pickingName, state string
|
|
var srcLocID, dstLocID int64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT name, state, location_id, location_dest_id
|
|
FROM stock_picking WHERE id = $1`, pickingID,
|
|
).Scan(&pickingName, &state, &srcLocID, &dstLocID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.barcode.picking: read picking %d: %w", pickingID, err)
|
|
}
|
|
|
|
// Get source/dest location names
|
|
var srcLocName, dstLocName string
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, srcLocID,
|
|
).Scan(&srcLocName)
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, dstLocID,
|
|
).Scan(&dstLocName)
|
|
|
|
// Get move lines
|
|
rows, err := env.Tx().Query(env.Ctx(),
|
|
`SELECT sm.id, sm.product_id, pt.name as product_name,
|
|
sm.product_uom_qty as demand,
|
|
COALESCE(SUM(sml.quantity), 0) as done_qty
|
|
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
|
|
LEFT JOIN stock_move_line sml ON sml.move_id = sm.id
|
|
WHERE sm.picking_id = $1 AND sm.state != 'cancel'
|
|
GROUP BY sm.id, sm.product_id, pt.name, sm.product_uom_qty
|
|
ORDER BY pt.name`,
|
|
pickingID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock.barcode.picking: query moves for %d: %w", pickingID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var moveLines []map[string]interface{}
|
|
for rows.Next() {
|
|
var moveID, prodID int64
|
|
var prodName string
|
|
var demand, doneQty float64
|
|
if err := rows.Scan(&moveID, &prodID, &prodName, &demand, &doneQty); err != nil {
|
|
return nil, fmt.Errorf("stock.barcode.picking: scan move: %w", err)
|
|
}
|
|
moveLines = append(moveLines, map[string]interface{}{
|
|
"move_id": moveID, "product_id": prodID, "product": prodName,
|
|
"demand": demand, "done": doneQty,
|
|
"remaining": demand - doneQty,
|
|
})
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"picking_id": pickingID,
|
|
"name": pickingName,
|
|
"state": state,
|
|
"source_location": srcLocName,
|
|
"dest_location": dstLocName,
|
|
"lines": moveLines,
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// isNumeric checks if a string contains only digit characters.
|
|
func isNumeric(s string) bool {
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return len(s) > 0
|
|
}
|