Account: - Multi-currency: company_currency_id, amount_total_signed - Lock dates on res.company (period, fiscal year, tax) + enforcement in action_post - Recurring entries: account.move.recurring with action_generate (copy+advance) - Tax groups: amount_type='group' computes child taxes with include_base_amount - ComputeTaxes batch function, findTaxAccount helper Stock: - Lot/Serial tracking: enhanced stock.lot with expiration dates + qty compute - Routes: stock.route model with product/category/warehouse selectable flags - Rules: stock.rule model with pull/push/buy/manufacture actions + procure methods - Returns: action_return on picking (swap locations, copy moves) - Product tracking extension (none/lot/serial) + route_ids M2M Sale: - Pricelist: get_product_price with fixed/percentage/formula computation - Margin: purchase_price, margin, margin_percent on line + order totals - Down payments: action_create_down_payment (deposit invoice at X%) Purchase: - 3-way matching: action_create_bill now updates qty_invoiced on PO lines - Purchase agreements: purchase.requisition + line with state machine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
Go
275 lines
12 KiB
Go
package models
|
|
|
|
import "odoo-go/pkg/orm"
|
|
|
|
// initProductCategory registers product.category — product classification tree.
|
|
// Mirrors: odoo/addons/product/models/product_category.py
|
|
func initProductCategory() {
|
|
m := orm.NewModel("product.category", orm.ModelOpts{
|
|
Description: "Product Category",
|
|
Order: "complete_name",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
|
orm.Char("complete_name", orm.FieldOpts{
|
|
String: "Complete Name", Compute: "_compute_complete_name", Store: true,
|
|
}),
|
|
orm.Many2one("parent_id", "product.category", orm.FieldOpts{
|
|
String: "Parent Category", Index: true, OnDelete: orm.OnDeleteCascade,
|
|
}),
|
|
orm.One2many("child_id", "product.category", "parent_id", orm.FieldOpts{
|
|
String: "Child Categories",
|
|
}),
|
|
orm.Many2one("property_account_income_categ_id", "account.account", orm.FieldOpts{
|
|
String: "Income Account",
|
|
Help: "This account will be used when validating a customer invoice.",
|
|
}),
|
|
orm.Many2one("property_account_expense_categ_id", "account.account", orm.FieldOpts{
|
|
String: "Expense Account",
|
|
Help: "This account will be used when validating a vendor bill.",
|
|
}),
|
|
)
|
|
}
|
|
|
|
// initUoM registers uom.category and uom.uom — units of measure.
|
|
// Mirrors: odoo/addons/product/models/product_uom.py
|
|
func initUoM() {
|
|
// uom.category — groups compatible units (e.g., Weight, Volume)
|
|
orm.NewModel("uom.category", orm.ModelOpts{
|
|
Description: "Product UoM Categories",
|
|
Order: "name",
|
|
}).AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
|
)
|
|
|
|
// uom.uom — individual units of measure
|
|
m := orm.NewModel("uom.uom", orm.ModelOpts{
|
|
Description: "Product Unit of Measure",
|
|
Order: "name",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Unit of Measure", Required: true, Translate: true}),
|
|
orm.Many2one("category_id", "uom.category", orm.FieldOpts{
|
|
String: "Category", Required: true, OnDelete: orm.OnDeleteCascade,
|
|
Help: "Conversion between Units of Measure can only occur if they belong to the same category.",
|
|
}),
|
|
orm.Float("factor", orm.FieldOpts{
|
|
String: "Ratio", Required: true, Default: 1.0,
|
|
Help: "How much bigger or smaller this unit is compared to the reference unit of measure for this category.",
|
|
}),
|
|
orm.Selection("uom_type", []orm.SelectionItem{
|
|
{Value: "bigger", Label: "Bigger than the reference Unit of Measure"},
|
|
{Value: "reference", Label: "Reference Unit of Measure for this category"},
|
|
{Value: "smaller", Label: "Smaller than the reference Unit of Measure"},
|
|
}, orm.FieldOpts{String: "Type", Required: true, Default: "reference"}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
orm.Float("rounding", orm.FieldOpts{
|
|
String: "Rounding Precision", Required: true, Default: 0.01,
|
|
Help: "The computed quantity will be a multiple of this value. Use 1.0 for a Unit of Measure that cannot be further split.",
|
|
}),
|
|
)
|
|
}
|
|
|
|
// initProductTemplate registers product.template — the base product definition.
|
|
// Mirrors: odoo/addons/product/models/product_template.py
|
|
func initProductTemplate() {
|
|
m := orm.NewModel("product.template", orm.ModelOpts{
|
|
Description: "Product Template",
|
|
Order: "name",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Index: true, Translate: true}),
|
|
orm.Selection("type", []orm.SelectionItem{
|
|
{Value: "consu", Label: "Consumable"},
|
|
{Value: "service", Label: "Service"},
|
|
{Value: "storable", Label: "Storable Product"},
|
|
}, orm.FieldOpts{String: "Product Type", Required: true, Default: "consu"}),
|
|
orm.Float("list_price", orm.FieldOpts{String: "Sales Price", Default: 1.0}),
|
|
orm.Float("standard_price", orm.FieldOpts{String: "Cost"}),
|
|
orm.Char("default_code", orm.FieldOpts{String: "Internal Reference", Index: true}),
|
|
orm.Char("barcode", orm.FieldOpts{String: "Barcode", Index: true}),
|
|
orm.Many2one("categ_id", "product.category", orm.FieldOpts{
|
|
String: "Product Category", Required: true,
|
|
}),
|
|
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{
|
|
String: "Unit of Measure", Required: true,
|
|
}),
|
|
orm.Boolean("sale_ok", orm.FieldOpts{String: "Can be Sold", Default: true}),
|
|
orm.Boolean("purchase_ok", orm.FieldOpts{String: "Can be Purchased", Default: true}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
orm.Text("description", orm.FieldOpts{String: "Description", Translate: true}),
|
|
orm.Text("description_sale", orm.FieldOpts{
|
|
String: "Sales Description", Translate: true,
|
|
Help: "Description used in sales orders, quotations, and invoices.",
|
|
}),
|
|
orm.Text("description_purchase", orm.FieldOpts{
|
|
String: "Purchase Description", Translate: true,
|
|
Help: "Description used in purchase orders.",
|
|
}),
|
|
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
|
|
orm.Many2many("taxes_id", "account.tax", orm.FieldOpts{
|
|
String: "Customer Taxes",
|
|
Help: "Default taxes used when selling the product.",
|
|
}),
|
|
orm.Many2many("supplier_taxes_id", "account.tax", orm.FieldOpts{
|
|
String: "Vendor Taxes",
|
|
Help: "Default taxes used when buying the product.",
|
|
}),
|
|
)
|
|
}
|
|
|
|
// initProductProduct registers product.product — a concrete product variant.
|
|
// Mirrors: odoo/addons/product/models/product_product.py
|
|
//
|
|
// In Odoo, product.product delegates to product.template via _inherits.
|
|
// Here we define the variant-specific fields plus Related fields that proxy
|
|
// key template fields for convenience (the web client reads these directly).
|
|
func initProductProduct() {
|
|
m := orm.NewModel("product.product", orm.ModelOpts{
|
|
Description: "Product",
|
|
Order: "default_code, name, id",
|
|
Inherits: map[string]string{"product.template": "product_tmpl_id"},
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{
|
|
String: "Product Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
|
}),
|
|
|
|
// Variant-specific fields
|
|
orm.Char("default_code", orm.FieldOpts{String: "Internal Reference", Index: true}),
|
|
orm.Char("barcode", orm.FieldOpts{String: "Barcode", Index: true}),
|
|
orm.Float("volume", orm.FieldOpts{String: "Volume", Help: "The volume in m3."}),
|
|
orm.Float("weight", orm.FieldOpts{String: "Weight", Help: "The weight in kg."}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
|
|
// Related fields from product.template — proxied for direct access.
|
|
// Mirrors: product_product.py fields with related='product_tmpl_id.xxx'
|
|
orm.Char("name", orm.FieldOpts{
|
|
String: "Name", Related: "product_tmpl_id.name",
|
|
}),
|
|
orm.Float("lst_price", orm.FieldOpts{
|
|
String: "Public Price", Related: "product_tmpl_id.list_price",
|
|
}),
|
|
orm.Float("standard_price", orm.FieldOpts{
|
|
String: "Cost", Related: "product_tmpl_id.standard_price",
|
|
}),
|
|
orm.Many2one("categ_id", "product.category", orm.FieldOpts{
|
|
String: "Product Category", Related: "product_tmpl_id.categ_id",
|
|
}),
|
|
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{
|
|
String: "Unit of Measure", Related: "product_tmpl_id.uom_id",
|
|
}),
|
|
orm.Selection("type", []orm.SelectionItem{
|
|
{Value: "consu", Label: "Consumable"},
|
|
{Value: "service", Label: "Service"},
|
|
{Value: "storable", Label: "Storable Product"},
|
|
}, orm.FieldOpts{String: "Product Type", Related: "product_tmpl_id.type"}),
|
|
orm.Boolean("sale_ok", orm.FieldOpts{
|
|
String: "Can be Sold", Related: "product_tmpl_id.sale_ok",
|
|
}),
|
|
orm.Boolean("purchase_ok", orm.FieldOpts{
|
|
String: "Can be Purchased", Related: "product_tmpl_id.purchase_ok",
|
|
}),
|
|
orm.Text("description_sale", orm.FieldOpts{
|
|
String: "Sales Description", Related: "product_tmpl_id.description_sale",
|
|
}),
|
|
orm.Binary("image_1920", orm.FieldOpts{
|
|
String: "Image", Related: "product_tmpl_id.image_1920",
|
|
}),
|
|
)
|
|
}
|
|
|
|
// initProductPricelist registers product.pricelist — price lists for customers.
|
|
// Mirrors: odoo/addons/product/models/product_pricelist.py
|
|
func initProductPricelist() {
|
|
m := orm.NewModel("product.pricelist", orm.ModelOpts{
|
|
Description: "Pricelist",
|
|
Order: "sequence, id",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Pricelist Name", Required: true, Translate: true}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 16}),
|
|
orm.One2many("item_ids", "product.pricelist.item", "pricelist_id", orm.FieldOpts{String: "Pricelist Rules"}),
|
|
orm.Many2one("country_group_id", "res.country.group", orm.FieldOpts{String: "Country Group"}),
|
|
)
|
|
|
|
// get_product_price: returns the price for a product in this pricelist.
|
|
// Mirrors: odoo/addons/product/models/product_pricelist.py Pricelist._get_product_price()
|
|
m.RegisterMethod("get_product_price", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return float64(0), nil
|
|
}
|
|
env := rs.Env()
|
|
pricelistID := rs.IDs()[0]
|
|
productID, _ := args[0].(float64)
|
|
qty, _ := args[1].(float64)
|
|
if qty <= 0 {
|
|
qty = 1
|
|
}
|
|
|
|
// Find matching pricelist item
|
|
var price float64
|
|
err := env.Tx().QueryRow(env.Ctx(), `
|
|
SELECT CASE
|
|
WHEN pi.compute_price = 'fixed' THEN pi.fixed_price
|
|
WHEN pi.compute_price = 'percentage' THEN pt.list_price * (1 - pi.percent_price / 100)
|
|
ELSE pt.list_price
|
|
END
|
|
FROM product_pricelist_item pi
|
|
JOIN product_product pp ON pp.id = $2
|
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
|
WHERE pi.pricelist_id = $1
|
|
AND (pi.product_id = $2 OR pi.product_id IS NULL)
|
|
AND (pi.min_quantity <= $3 OR pi.min_quantity IS NULL)
|
|
ORDER BY pi.sequence, pi.id LIMIT 1`,
|
|
pricelistID, int64(productID), qty,
|
|
).Scan(&price)
|
|
|
|
if err != nil {
|
|
// Fallback to list price
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT pt.list_price FROM product_product pp JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE pp.id = $1`,
|
|
int64(productID)).Scan(&price)
|
|
}
|
|
return price, nil
|
|
})
|
|
|
|
// product.pricelist.item — Price rules
|
|
orm.NewModel("product.pricelist.item", orm.ModelOpts{
|
|
Description: "Pricelist Rule",
|
|
Order: "sequence, id",
|
|
}).AddFields(
|
|
orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{
|
|
String: "Pricelist", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
|
}),
|
|
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product Variant"}),
|
|
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}),
|
|
orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}),
|
|
orm.Float("min_quantity", orm.FieldOpts{String: "Min. Quantity"}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
|
|
orm.Selection("applied_on", []orm.SelectionItem{
|
|
{Value: "3_global", Label: "All Products"},
|
|
{Value: "2_product_category", Label: "Product Category"},
|
|
{Value: "1_product", Label: "Product"},
|
|
{Value: "0_product_variant", Label: "Product Variant"},
|
|
}, orm.FieldOpts{String: "Apply On", Default: "3_global", Required: true}),
|
|
orm.Selection("compute_price", []orm.SelectionItem{
|
|
{Value: "fixed", Label: "Fixed Price"},
|
|
{Value: "percentage", Label: "Discount"},
|
|
{Value: "formula", Label: "Formula"},
|
|
}, orm.FieldOpts{String: "Computation", Default: "fixed", Required: true}),
|
|
orm.Float("fixed_price", orm.FieldOpts{String: "Fixed Price"}),
|
|
orm.Float("percent_price", orm.FieldOpts{String: "Percentage Price"}),
|
|
orm.Date("date_start", orm.FieldOpts{String: "Start Date"}),
|
|
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
|
)
|
|
}
|