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:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user