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

@@ -2,4 +2,5 @@ package models
func Init() {
initStock()
initStockIntrastat()
}

File diff suppressed because it is too large Load Diff

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
}

View File

@@ -2,7 +2,6 @@ package models
import (
"fmt"
"math"
"odoo-go/pkg/orm"
)
@@ -221,8 +220,11 @@ func initStockLandedCost() {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_valuation_layer
SET remaining_value = remaining_value + $1, value = value + $1
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
LIMIT 1`,
WHERE id = (
SELECT id FROM stock_valuation_layer
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
ORDER BY id LIMIT 1
)`,
adj.AdditionalCost, adj.MoveID, adj.ProductID,
)
if err != nil {
@@ -375,8 +377,3 @@ func initStockLandedCost() {
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
)
}
// roundCurrency rounds a monetary value to 2 decimal places.
func roundCurrency(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -513,3 +513,234 @@ func initStockForecast() {
return map[string]interface{}{"products": products}, nil
})
}
// initStockIntrastat registers stock.intrastat.line — Intrastat reporting model for
// EU cross-border trade declarations. Tracks move-level trade data.
// Mirrors: odoo/addons/stock_intrastat/models/stock_intrastat.py
func initStockIntrastat() {
m := orm.NewModel("stock.intrastat.line", orm.ModelOpts{
Description: "Intrastat Line",
Order: "id desc",
})
m.AddFields(
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
String: "Stock Move", Required: true, Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{
String: "Country", Required: true, Index: true,
}),
orm.Float("weight", orm.FieldOpts{String: "Weight (kg)", Required: true}),
orm.Monetary("value", orm.FieldOpts{String: "Fiscal Value", CurrencyField: "currency_id", Required: true}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Selection("transaction_type", []orm.SelectionItem{
{Value: "arrival", Label: "Arrival"},
{Value: "dispatch", Label: "Dispatch"},
}, orm.FieldOpts{String: "Transaction Type", Required: true, Index: true}),
orm.Char("intrastat_code", orm.FieldOpts{String: "Commodity Code"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Index: true}),
orm.Date("date", orm.FieldOpts{String: "Date", Index: true}),
orm.Char("transport_mode", orm.FieldOpts{String: "Transport Mode"}),
)
// generate_lines: Auto-generate Intrastat lines from done stock moves in a date range.
// Args: date_from (string), date_to (string), optional company_id (int64)
// Mirrors: stock.intrastat.report generation
m.RegisterMethod("generate_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 2 {
return nil, fmt.Errorf("stock.intrastat.line.generate_lines requires date_from, date_to")
}
dateFrom, _ := args[0].(string)
dateTo, _ := args[1].(string)
if dateFrom == "" || dateTo == "" {
return nil, fmt.Errorf("stock.intrastat.line: invalid date range")
}
companyID := int64(1)
if len(args) >= 3 {
if cid, ok := args[2].(int64); ok && cid > 0 {
companyID = cid
}
}
env := rs.Env()
// Find done moves crossing borders (source or dest is in a different country)
// For simplicity, look for moves between locations belonging to different warehouses
// or between internal and non-internal locations.
rows, err := env.Tx().Query(env.Ctx(),
`SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit, sm.date,
sl_src.usage as src_usage, sl_dst.usage as dst_usage,
COALESCE(rp.country_id, 0) as partner_country_id
FROM stock_move sm
JOIN stock_location sl_src ON sl_src.id = sm.location_id
JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
LEFT JOIN res_partner rp ON rp.id = sp.partner_id
WHERE sm.state = 'done'
AND sm.date >= $1 AND sm.date <= $2
AND sm.company_id = $3
AND (
(sl_src.usage = 'supplier' AND sl_dst.usage = 'internal')
OR (sl_src.usage = 'internal' AND sl_dst.usage = 'customer')
)
ORDER BY sm.date`,
dateFrom, dateTo, companyID,
)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: query moves: %w", err)
}
type moveData struct {
MoveID, ProductID int64
Qty, PriceUnit float64
Date *string
SrcUsage string
DstUsage string
CountryID int64
}
var moves []moveData
for rows.Next() {
var md moveData
if err := rows.Scan(&md.MoveID, &md.ProductID, &md.Qty, &md.PriceUnit,
&md.Date, &md.SrcUsage, &md.DstUsage, &md.CountryID); err != nil {
rows.Close()
return nil, fmt.Errorf("stock.intrastat.line: scan move: %w", err)
}
moves = append(moves, md)
}
rows.Close()
var created int
for _, md := range moves {
// Determine transaction type
txnType := "arrival"
if md.SrcUsage == "internal" && md.DstUsage == "customer" {
txnType = "dispatch"
}
// Use partner country; skip if no country (can't determine border crossing)
countryID := md.CountryID
if countryID == 0 {
continue
}
// Compute value and weight
value := md.Qty * md.PriceUnit
weight := md.Qty // Simplified: weight = qty (would use product.weight in full impl)
dateStr := ""
if md.Date != nil {
dateStr = *md.Date
}
// Check if line already exists for this move
var existing int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_intrastat_line WHERE move_id = $1 LIMIT 1`, md.MoveID,
).Scan(&existing)
if existing > 0 {
continue
}
_, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO stock_intrastat_line
(move_id, product_id, country_id, weight, value, transaction_type, company_id, date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
md.MoveID, md.ProductID, countryID, weight, value, txnType, companyID, dateStr,
)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: create line for move %d: %w", md.MoveID, err)
}
created++
}
return map[string]interface{}{
"created": created,
"date_from": dateFrom,
"date_to": dateTo,
}, nil
})
// get_report: Return Intrastat report data for a period.
// Args: date_from (string), date_to (string), optional transaction_type (string)
// Mirrors: stock.intrastat.report views
m.RegisterMethod("get_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 2 {
return nil, fmt.Errorf("stock.intrastat.line.get_report requires date_from, date_to")
}
dateFrom, _ := args[0].(string)
dateTo, _ := args[1].(string)
var txnTypeFilter string
if len(args) >= 3 {
txnTypeFilter, _ = args[2].(string)
}
env := rs.Env()
query := `SELECT sil.id, sil.move_id, sil.product_id, pt.name as product_name,
sil.country_id, COALESCE(rc.name, '') as country_name,
sil.weight, sil.value, sil.transaction_type,
COALESCE(sil.intrastat_code, '') as commodity_code,
sil.date
FROM stock_intrastat_line sil
JOIN product_product pp ON pp.id = sil.product_id
JOIN product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN res_country rc ON rc.id = sil.country_id
WHERE sil.date >= $1 AND sil.date <= $2`
queryArgs := []interface{}{dateFrom, dateTo}
if txnTypeFilter != "" {
query += ` AND sil.transaction_type = $3`
queryArgs = append(queryArgs, txnTypeFilter)
}
query += ` ORDER BY sil.date, sil.id`
rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: query report: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
var totalWeight, totalValue float64
for rows.Next() {
var lineID, moveID, productID, countryID int64
var productName, countryName, txnType, commodityCode string
var weight, value float64
var date *string
if err := rows.Scan(&lineID, &moveID, &productID, &productName,
&countryID, &countryName, &weight, &value, &txnType,
&commodityCode, &date); err != nil {
return nil, fmt.Errorf("stock.intrastat.line: scan report row: %w", err)
}
dateStr := ""
if date != nil {
dateStr = *date
}
lines = append(lines, map[string]interface{}{
"id": lineID, "move_id": moveID,
"product_id": productID, "product": productName,
"country_id": countryID, "country": countryName,
"weight": weight, "value": value,
"transaction_type": txnType,
"commodity_code": commodityCode,
"date": dateStr,
})
totalWeight += weight
totalValue += value
}
return map[string]interface{}{
"lines": lines,
"total_weight": totalWeight,
"total_value": totalValue,
"date_from": dateFrom,
"date_to": dateTo,
}, nil
})
}

View File

@@ -127,13 +127,22 @@ func initStockValuationLayer() {
}
defer rows.Close()
var totalConsumedValue float64
// Collect layers first, then close cursor before updating (pgx safety)
type layerConsumption struct {
id int64
newQty float64
newValue float64
consumed float64
cost float64
}
var consumptions []layerConsumption
remaining := qtyToConsume
for rows.Next() && remaining > 0 {
var layerID int64
var layerQty, layerValue, layerUnitCost float64
if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil {
rows.Close()
return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err)
}
@@ -142,20 +151,27 @@ func initStockValuationLayer() {
consumed = layerQty
}
consumedValue := consumed * layerUnitCost
newRemainingQty := layerQty - consumed
newRemainingValue := layerValue - consumedValue
consumptions = append(consumptions, layerConsumption{
id: layerID,
newQty: layerQty - consumed,
newValue: layerValue - consumed*layerUnitCost,
consumed: consumed,
cost: layerUnitCost,
})
remaining -= consumed
}
rows.Close()
// Now update layers outside the cursor
var totalConsumedValue float64
for _, c := range consumptions {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_valuation_layer SET remaining_qty = $1, remaining_value = $2 WHERE id = $3`,
newRemainingQty, newRemainingValue, layerID,
)
c.newQty, c.newValue, c.id)
if err != nil {
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", layerID, err)
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", c.id, err)
}
totalConsumedValue += consumedValue
remaining -= consumed
totalConsumedValue += c.consumed * c.cost
}
return map[string]interface{}{