feat: Portal, Email Inbound, Discuss + module improvements

- 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>
This commit is contained in:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -72,6 +72,137 @@ func initStockBarcode() {
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
@@ -234,3 +365,13 @@ func initStockBarcode() {
}, 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
}