Bring all areas to 60%: modules, reporting, i18n, views, data

Business modules deepened:
- sale: tag_ids, invoice/delivery counts with computes
- stock: _action_confirm/_action_done on stock.move, quant update stub
- purchase: done state added
- hr: parent_id, address_home_id, no_of_recruitment
- project: user_id, date_start, kanban_state on tasks

Reporting framework (0% → 60%):
- ir.actions.report model registered
- /report/html/<name>/<ids> endpoint serves styled HTML reports
- Report-to-model mapping for invoice, sale, stock, purchase

i18n framework (0% → 60%):
- ir.translation model with src/value/lang/type fields
- handleTranslations loads from DB, returns per-module structure
- 140 German translations seeded (UI terms across all modules)
- res_lang seeded with en_US + de_DE

Views/UI improved:
- Stored form views: sale.order (with editable O2M lines), account.move
  (with Post/Cancel buttons), res.partner (with title)
- Stored list views: purchase.order, hr.employee, project.project

Demo data expanded:
- 5 products (templates + variants with codes)
- 3 HR departments, 3 employees
- 2 projects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-02 20:11:45 +02:00
parent eb92a2e239
commit 03fd58a852
13 changed files with 944 additions and 31 deletions

View File

@@ -20,4 +20,6 @@ func Init() {
initIrCron() // ir.cron (Scheduled Actions)
initResLang() // res.lang (Languages)
initResConfigSettings() // res.config.settings (TransientModel)
initIrActionsReport() // ir.actions.report (Report Actions)
initIrTranslation() // ir.translation (Translations)
}

View File

@@ -0,0 +1,32 @@
package models
import "odoo-go/pkg/orm"
// initIrActionsReport registers ir.actions.report — Report Action definitions.
// Mirrors: odoo/addons/base/models/ir_actions_report.py class IrActionsReport
//
// Report actions define how to generate reports for records.
// The default report_type is "qweb-html" which renders HTML in the browser.
func initIrActionsReport() {
m := orm.NewModel("ir.actions.report", orm.ModelOpts{
Description: "Report Action",
Table: "ir_act_report",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("type", orm.FieldOpts{String: "Action Type", Default: "ir.actions.report"}),
orm.Char("report_name", orm.FieldOpts{String: "Report Name", Required: true}),
orm.Char("report_type", orm.FieldOpts{String: "Report Type", Default: "qweb-html"}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model"}),
orm.Char("model", orm.FieldOpts{String: "Model Name"}),
orm.Boolean("multi", orm.FieldOpts{String: "On Multiple Docs"}),
orm.Many2one("paperformat_id", "report.paperformat", orm.FieldOpts{String: "Paper Format"}),
orm.Char("print_report_name", orm.FieldOpts{String: "Printed Report Name"}),
orm.Boolean("attachment_use", orm.FieldOpts{String: "Reload from Attachment"}),
orm.Char("attachment", orm.FieldOpts{String: "Save as Attachment Prefix"}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
orm.Char("binding_type", orm.FieldOpts{String: "Binding Type", Default: "report"}),
)
}

View File

@@ -0,0 +1,34 @@
package models
import "odoo-go/pkg/orm"
// initIrTranslation registers ir.translation — Translation storage.
// Mirrors: odoo/addons/base/models/ir_translation.py class IrTranslation
//
// Stores translated strings for model fields, code terms, and structured terms.
// The web client loads translations via /web/webclient/translations and uses them
// to render UI elements in the user's language.
func initIrTranslation() {
m := orm.NewModel("ir.translation", orm.ModelOpts{
Description: "Translation",
Order: "lang, name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Translated field", Required: true, Index: true}),
orm.Char("res_id", orm.FieldOpts{String: "Record ID"}),
orm.Char("lang", orm.FieldOpts{String: "Language", Required: true, Index: true}),
orm.Selection("type", []orm.SelectionItem{
{Value: "model", Label: "Model Field"},
{Value: "model_terms", Label: "Structured Model Field"},
{Value: "code", Label: "Code"},
}, orm.FieldOpts{String: "Type", Index: true}),
orm.Text("src", orm.FieldOpts{String: "Source"}),
orm.Text("value", orm.FieldOpts{String: "Translation Value"}),
orm.Char("module", orm.FieldOpts{String: "Module"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "to_translate", Label: "To Translate"},
{Value: "translated", Label: "Translated"},
}, orm.FieldOpts{String: "Status", Default: "to_translate"}),
)
}

View File

@@ -57,7 +57,12 @@ func initHREmployee() {
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("parent_id", "hr.employee", orm.FieldOpts{String: "Manager", Index: true}),
orm.Many2one("address_id", "res.partner", orm.FieldOpts{String: "Work Address"}),
orm.Many2one("address_home_id", "res.partner", orm.FieldOpts{
String: "Private Address", Groups: "hr.group_hr_user",
}),
orm.Char("identification_id", orm.FieldOpts{String: "Identification No", Groups: "hr.group_hr_user"}),
orm.Char("work_email", orm.FieldOpts{String: "Work Email"}),
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone"}),
orm.Char("mobile_phone", orm.FieldOpts{String: "Work Mobile"}),
@@ -145,6 +150,7 @@ func initHRJob() {
String: "Company", Required: true, Index: true,
}),
orm.Integer("expected_employees", orm.FieldOpts{String: "Expected New Employees", Default: 1}),
orm.Integer("no_of_recruitment", orm.FieldOpts{String: "Expected in Recruitment"}),
orm.Integer("no_of_hired_employee", orm.FieldOpts{String: "Hired Employees"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "recruit", Label: "Recruitment in Progress"},

View File

@@ -19,9 +19,12 @@ func initProjectProject() {
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Project Manager"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Date("date_start", orm.FieldOpts{String: "Start Date"}),
orm.Date("date", orm.FieldOpts{String: "Expiration Date"}),
orm.Many2one("stage_id", "project.task.type", orm.FieldOpts{String: "Stage"}),
orm.Many2many("favorite_user_ids", "res.users", orm.FieldOpts{String: "Favorite Users"}),
orm.Integer("task_count", orm.FieldOpts{
@@ -60,6 +63,11 @@ func initProjectTask() {
{Value: "0", Label: "Normal"},
{Value: "1", Label: "Important"},
}, orm.FieldOpts{String: "Priority", Default: "0"}),
orm.Selection("kanban_state", []orm.SelectionItem{
{Value: "normal", Label: "In Progress"},
{Value: "done", Label: "Ready"},
{Value: "blocked", Label: "Blocked"},
}, orm.FieldOpts{String: "Kanban State", Default: "normal"}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
orm.Many2many("user_ids", "res.users", orm.FieldOpts{String: "Assignees"}),
orm.Date("date_deadline", orm.FieldOpts{String: "Deadline", Index: true}),

View File

@@ -28,6 +28,7 @@ func initPurchaseOrder() {
{Value: "sent", Label: "RFQ Sent"},
{Value: "to approve", Label: "To Approve"},
{Value: "purchase", Label: "Purchase Order"},
{Value: "done", Label: "Locked"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Readonly: true, Index: true}),
orm.Selection("priority", []orm.SelectionItem{

View File

@@ -105,6 +105,21 @@ func initSaleOrder() {
}),
)
// -- Tags --
m.AddFields(
orm.Many2many("tag_ids", "crm.tag", orm.FieldOpts{String: "Tags"}),
)
// -- Counts (Computed placeholders) --
m.AddFields(
orm.Integer("invoice_count", orm.FieldOpts{
String: "Invoice Count", Compute: "_compute_invoice_count", Store: false,
}),
orm.Integer("delivery_count", orm.FieldOpts{
String: "Delivery Count", Compute: "_compute_delivery_count", Store: false,
}),
)
// -- Misc --
m.AddFields(
orm.Text("note", orm.FieldOpts{String: "Terms and Conditions"}),
@@ -156,6 +171,45 @@ func initSaleOrder() {
m.RegisterCompute("amount_tax", computeSaleAmounts)
m.RegisterCompute("amount_total", computeSaleAmounts)
// -- Computed: _compute_invoice_count --
// Counts the number of invoices linked to this sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_count()
computeInvoiceCount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var count int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE invoice_origin = $1 AND move_type = 'out_invoice'`,
fmt.Sprintf("SO%d", soID)).Scan(&count)
if err != nil {
count = 0
}
return orm.Values{"invoice_count": count}, nil
}
m.RegisterCompute("invoice_count", computeInvoiceCount)
// -- Computed: _compute_delivery_count --
// Counts the number of delivery pickings linked to this sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_delivery_count()
computeDeliveryCount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM sale_order WHERE id = $1`, soID).Scan(&soName)
if err != nil {
return orm.Values{"delivery_count": 0}, nil
}
var count int
err = env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM stock_picking WHERE origin = $1`, soName).Scan(&count)
if err != nil {
count = 0
}
return orm.Values{"delivery_count": count}, nil
}
m.RegisterCompute("delivery_count", computeDeliveryCount)
// -- DefaultGet: Provide dynamic defaults for new records --
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.default_get()
// Supplies company_id, currency_id, date_order when creating a new quotation.

View File

@@ -45,6 +45,12 @@ func initStockWarehouse() {
orm.Many2one("lot_stock_id", "stock.location", orm.FieldOpts{
String: "Location Stock", Required: true, OnDelete: orm.OnDeleteRestrict,
}),
orm.Many2one("wh_input_stock_loc_id", "stock.location", orm.FieldOpts{
String: "Input Location",
}),
orm.Many2one("wh_output_stock_loc_id", "stock.location", orm.FieldOpts{
String: "Output Location",
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
}
@@ -368,6 +374,49 @@ func initStockMove() {
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
)
// _action_confirm: Confirm stock moves (draft → confirmed).
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_confirm()
m.RegisterMethod("_action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'confirmed' WHERE id = $1 AND state = 'draft'`, id)
if err != nil {
return nil, fmt.Errorf("stock: confirm move %d: %w", id, err)
}
}
return true, nil
})
// _action_done: Finalize stock moves (assigned → done), updating quants.
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_done()
m.RegisterMethod("_action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var productID, srcLoc, dstLoc int64
var qty float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, product_uom_qty, location_id, location_dest_id
FROM stock_move WHERE id = $1`, id).Scan(&productID, &qty, &srcLoc, &dstLoc)
if err != nil {
return nil, fmt.Errorf("stock: read move %d for done: %w", id, err)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move SET state = 'done', date = NOW() WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("stock: done move %d: %w", id, err)
}
// Adjust quants
if err := updateQuant(env, productID, srcLoc, -qty); err != nil {
return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
}
if err := updateQuant(env, productID, dstLoc, qty); err != nil {
return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
}
}
return true, nil
})
}
// initStockMoveLine registers stock.move.line — detailed operations per lot/package.
@@ -454,6 +503,25 @@ func initStockQuant() {
orm.Datetime("removal_date", orm.FieldOpts{String: "Removal Date"}),
)
// _update_available_quantity: Adjust available quantity for a product at a location.
// Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._update_available_quantity()
m.RegisterMethod("_update_available_quantity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 3 {
return nil, fmt.Errorf("stock.quant._update_available_quantity requires product_id, location_id, quantity")
}
productID, _ := args[0].(int64)
locationID, _ := args[1].(int64)
quantity, _ := args[2].(float64)
if productID == 0 || locationID == 0 {
return nil, fmt.Errorf("stock.quant._update_available_quantity: invalid product_id or location_id")
}
env := rs.Env()
if err := updateQuant(env, productID, locationID, quantity); err != nil {
return nil, fmt.Errorf("stock.quant._update_available_quantity: %w", err)
}
return true, nil
})
// stock.quant.package — physical packages / containers
orm.NewModel("stock.quant.package", orm.ModelOpts{
Description: "Packages",