Compare commits
11 Commits
2e5a550069
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4ffaa945 | ||
|
|
66383adf06 | ||
|
|
2c7c1e6c88 | ||
|
|
cc6184a18b | ||
|
|
5973a445c0 | ||
|
|
fad2a37d1c | ||
|
|
bdb97f98ad | ||
|
|
0a76a2b9aa | ||
|
|
b8fa4719ad | ||
|
|
d9171191af | ||
|
|
e0d8bc81d3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build output
|
||||
build/
|
||||
odoo-server
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
17
.gitlab-ci.yml
Normal file
17
.gitlab-ci.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
stages:
|
||||
- test
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: golang:1.24-bookworm
|
||||
variables:
|
||||
CGO_ENABLED: "0"
|
||||
script:
|
||||
- go test ./pkg/orm/... ./pkg/server/... -v -count=1
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .go-cache/
|
||||
before_script:
|
||||
- mkdir -p .go-cache
|
||||
- export GOPATH="$CI_PROJECT_DIR/.go-cache"
|
||||
@@ -8,6 +8,7 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wkhtmltopdf && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd -m -s /bin/bash odoo
|
||||
COPY --from=builder /odoo-server /usr/local/bin/odoo-server
|
||||
COPY --from=builder /build/frontend /app/frontend
|
||||
|
||||
278
TODO.md
278
TODO.md
@@ -1,250 +1,62 @@
|
||||
# odoo-go TODO — Weg zur 1:1 Paritaet mit Python Odoo
|
||||
# TODO
|
||||
|
||||
Stand: 2026-04-02 | Go: 19.925 LOC | Python Referenz: 92.733 LOC (core) + Addons
|
||||
> Stand: 2026-04-12
|
||||
> Offene Punkte → Details in `open.md`
|
||||
|
||||
---
|
||||
|
||||
## Legende
|
||||
- [ ] Offen
|
||||
- [x] Erledigt
|
||||
- ~70% = UI/Demo funktioniert, ~25% = Code-Tiefe vs Python
|
||||
## 🔵 Tech Debt
|
||||
|
||||
- [ ] Tax-Logik in Sale und Account zusammenführen → gemeinsames `tax`-Package evaluieren
|
||||
- [ ] Domain Parser: Edge Cases bei komplexen Python-Expressions dokumentieren
|
||||
- [ ] Floating-Point Präzision bei Tax/Reconciliation nochmal evaluieren (aktuell `::float8`)
|
||||
- [ ] Report Wizard: Aged Report + General Ledger auch mit Date-Filter versehen
|
||||
- [ ] CRM: `message_subscribe` N+1 → Batch INSERT ON CONFLICT
|
||||
|
||||
---
|
||||
|
||||
## 1. ORM-Kern (aktuell ~75%)
|
||||
## 📋 Offene Features → `open.md`
|
||||
|
||||
### Erledigt
|
||||
- [x] CRUD (create/read/write/unlink)
|
||||
- [x] Domain-Compiler (AND/OR/NOT, child_of, parent_of, any, dot-notation JOINs)
|
||||
- [x] read_group mit Aggregation (sum/avg/min/max/count, Date-Granularitaet)
|
||||
- [x] Computed Fields mit Dependency-Tracking
|
||||
- [x] _inherits (Delegation: res.users→res.partner, product.product→product.template)
|
||||
- [x] Related Fields (Read + Write-back)
|
||||
- [x] Record Rules (domain_force parsing, global AND + group OR)
|
||||
- [x] Copy/Duplicate mit IsCopyable
|
||||
- [x] Constraints in Create + Write
|
||||
- [x] Readonly-Skip in Write
|
||||
- [x] active_test Auto-Filter in Search
|
||||
- [x] Onchange mit Compute-Triggering
|
||||
- [x] sanitizeFieldValue (false→nil fuer non-boolean)
|
||||
- [x] name_search / name_create
|
||||
Alle offenen Feature-Punkte sind in `open.md` dokumentiert:
|
||||
|
||||
### Offen
|
||||
- [ ] _inherit (Model-Extension/Mixin) — aktuell nur neue Models, kein Erweitern bestehender
|
||||
- [ ] Inverse Fields (Compute write-back via benannte Methode)
|
||||
- [ ] Multi-hop Related Fields (aktuell nur single-hop "fk.field")
|
||||
- [ ] Batch-Create (Liste von Values statt einzeln)
|
||||
- [ ] Prefetch/Lazy Loading (aktuell alles auf einmal)
|
||||
- [x] SQL Constraints (UNIQUE, CHECK)
|
||||
- [ ] Field-Level Groups (Feld-Sichtbarkeit nach Gruppen)
|
||||
- [x] Translatable Fields (translate=True → ir.translation Lookup)
|
||||
- [ ] Properties Field (JSONB key-value)
|
||||
- [ ] parent_path (Hierarchie-Optimierung)
|
||||
- [ ] Flush/Recompute Ordering (Python hat _recompute_todo Queue)
|
||||
- [x] Environment.ref() vollstaendig
|
||||
- [ ] Recordset Arithmetic (+ - & | Operatoren vollstaendig)
|
||||
- **Odoo Community Core (fehlend):** Portal (XL), Discuss (L), Email Inbound (M)
|
||||
- **Frontend / UI Zukunft:** UI modernisieren, View-Format JSON-fähig
|
||||
|
||||
---
|
||||
|
||||
## 2. Web/RPC Server (aktuell ~80%)
|
||||
|
||||
### Erledigt
|
||||
- [x] JSON-RPC Dispatch (/web/dataset/call_kw, call_button)
|
||||
- [x] web_search_read / web_read / web_save
|
||||
- [x] web_read_group / read_group
|
||||
- [x] O2M Child Records in web_read Response
|
||||
- [x] get_views mit Comodel-Metadaten
|
||||
- [x] Onchange RPC
|
||||
- [x] Action Loading (/web/action/load)
|
||||
- [x] Menu Loading (/web/webclient/load_menus)
|
||||
- [x] Session Management (PostgreSQL-persistent)
|
||||
- [x] File Upload/Download (ir.attachment)
|
||||
- [x] CSV Export (/web/export/csv)
|
||||
- [x] HTML Reports (/report/html/)
|
||||
- [x] Translations Endpoint
|
||||
- [x] call_button mit Action-Return
|
||||
- [x] get_formview_action / name_create / ir.http stub
|
||||
|
||||
### Offen
|
||||
- [ ] PDF Reports (wkhtmltopdf oder Go-PDF-Library)
|
||||
- [ ] QWeb Template Engine (Report-Templates wie Python)
|
||||
- [ ] /web/dataset/export (XLSX Export)
|
||||
- [ ] /web/action/run (Server Actions ausfuehren)
|
||||
- [ ] /web/view/edit_custom (Custom View Saves)
|
||||
- [ ] /web/model/get_definitions
|
||||
- [ ] Kanban auto_unfold (Gruppen standardmaessig offen mit Records)
|
||||
- [ ] Binary Field Serving (Bilder in Form-Views)
|
||||
- [ ] Websocket/Longpoll (Bus fuer Live-Updates)
|
||||
- [ ] Mail-Integration (SMTP senden)
|
||||
|
||||
## IDEEN
|
||||
WEITER ENWICKELUNG DER PLATTFORM
|
||||
- ** Ki/ AI Unterstützung Datenbasierter informationen: ** Daten Analyse (Longterm)
|
||||
- ** SSO / SAML / LADP / WEBDAV**: technologien der verbesserung der plattform
|
||||
---
|
||||
|
||||
## 3. Views/UI (aktuell ~70%)
|
||||
## ✅ Erledigt (Referenz)
|
||||
|
||||
### Erledigt
|
||||
- [x] List View (auto-generiert + stored)
|
||||
- [x] Form View (auto-generiert + stored, mit Header-Buttons)
|
||||
- [x] Kanban View (auto-generiert + stored)
|
||||
- [x] Search View (auto-generiert mit Filters/Group-By)
|
||||
- [x] Stored Views aus DB (14 Views)
|
||||
- [x] Auto-generierte Views als Fallback
|
||||
- [x] O2M Inline-Listen in Form-Views (editable)
|
||||
- [x] Statusbar Widget mit clickable States
|
||||
### Infrastruktur
|
||||
- [x] ORM-Kern (read_group, record rules, domain operators, _inherits, compute, onchange, constraints)
|
||||
- [x] JSON-RPC Dispatch (call_kw, call_button, action/run, model/get_definitions)
|
||||
- [x] View Inheritance (XPath), get_views, alle View-Typen (list/form/search/kanban/pivot/graph/calendar/activity/dashboard)
|
||||
- [x] Session-Persistenz (PostgreSQL), Multi-Company Switcher
|
||||
- [x] CSV + XLSX Export, Generic CSV Import, Bank-Statement Import
|
||||
- [x] HTML + PDF Reports, Binary Field Serving, SMTP Email
|
||||
- [x] Automated Actions Engine (ir.actions.server)
|
||||
- [x] Post-Setup Wizard, Database Manager
|
||||
- [x] Mail/Chatter (Follower-Notify, Attachments, Thread)
|
||||
|
||||
### Offen
|
||||
- [ ] View Inheritance (XPath-basiert, inherit_id)
|
||||
- [ ] Pivot View
|
||||
- [ ] Graph View
|
||||
- [ ] Calendar View
|
||||
- [ ] Gantt View
|
||||
- [ ] Activity View
|
||||
- [ ] Map View
|
||||
- [ ] View Customization (ir.ui.view.custom)
|
||||
- [ ] Dynamic Visibility (invisible/readonly Conditions in XML)
|
||||
- [ ] Widget-System (many2one_avatar, priority, statusbar etc.)
|
||||
- [ ] Chatter/Mail-Thread in Form-Views
|
||||
### Security (alle Audits abgeschlossen)
|
||||
- [x] ACL Seeds für alle ~167 Models + fail-closed checkAccess
|
||||
- [x] CSRF Token (crypto/rand, persisted in DB)
|
||||
- [x] SQL Injection Fixes (sanitizeOrderBy, domain rebaseParams)
|
||||
- [x] Auth Bypass Fix, Rate Limiting, Session Throttling
|
||||
- [x] XSS Fixes (HTML-Escaping in Reports, Emails, Setup Wizard)
|
||||
- [x] State Guards (orm.StateGuard), Field-Level ACL
|
||||
- [x] Full Codebase Review: 55 Findings gefunden, 48+ gefixt
|
||||
|
||||
---
|
||||
|
||||
## 4. Business-Module (aktuell ~25% Code-Tiefe)
|
||||
|
||||
### Account (1.072 / 40.910 LOC = 2.6%)
|
||||
- [x] account.move (Invoice CRUD, action_post, button_cancel)
|
||||
- [x] account.move.line (Buchungszeilen)
|
||||
- [x] account.journal, account.account, account.tax
|
||||
- [x] account.payment + payment.register Wizard
|
||||
- [ ] Reconciliation (Payment↔Invoice Matching) — **KRITISCH**
|
||||
- [ ] Bank Statements (Import, Matching)
|
||||
- [ ] Tax Reports (Umsatzsteuervoranmeldung)
|
||||
- [ ] Asset Management
|
||||
- [ ] Aged Receivable/Payable Reports
|
||||
- [ ] Fiscal Year / Period Management
|
||||
- [ ] Payment Provider Integration
|
||||
- [ ] Chart of Accounts Import (volle l10n_de)
|
||||
|
||||
### Sale (641 / 8.009 LOC = 8%)
|
||||
- [x] sale.order + sale.order.line (CRUD, Confirm, Create Invoice)
|
||||
- [x] Computed Amounts (untaxed, tax, total, line subtotal)
|
||||
- [ ] Sale Order Line Tax-Berechnung (tax_id → tax lines)
|
||||
- [ ] Pricelist-Support (Rabatte, Staffelpreise)
|
||||
- [ ] Quotation Templates
|
||||
- [ ] Sale Order Cancellation + Refund
|
||||
- [ ] Delivery Integration (Lieferschein-Erstellung korrekt)
|
||||
- [ ] Margin-Berechnung
|
||||
- [ ] Sale Reports (Revenue, Pipeline)
|
||||
|
||||
### Stock (588 / 17.672 LOC = 3.3%)
|
||||
- [x] stock.picking, stock.move, stock.location, stock.warehouse
|
||||
- [x] stock.quant (Basic)
|
||||
- [ ] Quant-Management (Reservation, Unreserve) — **KRITISCH**
|
||||
- [ ] Stock Valuation (FIFO, Average Cost)
|
||||
- [ ] Reorder Rules (stock.warehouse.orderpoint)
|
||||
- [ ] Barcode/Lot Tracking
|
||||
- [ ] Inventory Adjustments
|
||||
- [ ] Stock Reports (Valuation, Moves)
|
||||
- [ ] Multi-Warehouse Routing
|
||||
- [ ] Package Management
|
||||
|
||||
### Purchase (350 / 3.977 LOC = 8.8%)
|
||||
- [x] purchase.order + purchase.order.line (CRUD, Confirm)
|
||||
- [ ] 3-Way Matching (PO → Receipt → Bill)
|
||||
- [ ] Purchase Agreements
|
||||
- [ ] Vendor Bills aus PO erstellen
|
||||
- [ ] Purchase Reports
|
||||
|
||||
### CRM (300 / 5.019 LOC = 6%)
|
||||
- [x] crm.lead + crm.stage + crm.lost.reason
|
||||
- [x] Pipeline Kanban mit Stages
|
||||
- [x] Won/Lost Actions
|
||||
- [ ] Lead Scoring / Probability
|
||||
- [ ] Email Integration (Lead aus Email)
|
||||
- [ ] Activities / Planned Actions
|
||||
- [ ] Lead Assignment Rules
|
||||
- [ ] CRM Reports (Pipeline, Revenue)
|
||||
|
||||
### HR (200 / 4.609 LOC = 4.3%)
|
||||
- [x] hr.employee, hr.department, hr.job
|
||||
- [ ] Contracts (hr.contract)
|
||||
- [ ] Leave Management (hr.leave)
|
||||
- [ ] Attendance Tracking
|
||||
- [ ] Payroll Integration
|
||||
- [ ] Employee Documents
|
||||
|
||||
### Project (200 / 6.730 LOC = 3%)
|
||||
- [x] project.project, project.task
|
||||
- [ ] Task Stages / Kanban
|
||||
- [ ] Timesheet Integration
|
||||
- [ ] Project Updates / Milestones
|
||||
- [ ] Burndown Charts
|
||||
|
||||
---
|
||||
|
||||
## 5. System/Admin (aktuell ~68%)
|
||||
|
||||
### Erledigt
|
||||
- [x] Settings-Seite (res.config.settings mit Firmen-Fields)
|
||||
- [x] Users/Companies/Groups Verwaltung
|
||||
- [x] Technical Menu (Views, Actions, Parameters, Security, Logging)
|
||||
- [x] Session-Persistenz (PostgreSQL)
|
||||
- [x] ir.config_parameter (get_param/set_param)
|
||||
- [x] ir.cron Model (registriert)
|
||||
- [x] ir.logging Model
|
||||
- [x] res.lang + ir.translation Models
|
||||
- [x] Password Hashing (bcrypt)
|
||||
- [x] change_password Methode
|
||||
|
||||
### Offen
|
||||
- [ ] ir.cron Runner (Goroutine Scheduler der Jobs ausfuehrt)
|
||||
- [ ] Mail Server (ir.mail_server, SMTP Config)
|
||||
- [ ] Authentik SSO / OAuth2 (geplant, siehe memory)
|
||||
- [ ] API Key Auth (Token-basierter Zugriff)
|
||||
- [ ] Module Install/Upgrade Lifecycle
|
||||
- [ ] XML Data Loading (aus Addon data/ Ordnern)
|
||||
- [ ] User Audit Log (wer hat was geaendert)
|
||||
- [ ] Database Backup/Restore
|
||||
- [ ] Multi-Database Support
|
||||
|
||||
---
|
||||
|
||||
## 6. Infrastruktur
|
||||
|
||||
### Erledigt
|
||||
- [x] Docker Compose (Go Server + PostgreSQL)
|
||||
- [x] OWL 19.0 Frontend eingebettet
|
||||
- [x] JS Transpiler (ES6→odoo.define)
|
||||
- [x] XML Template Compiler
|
||||
- [x] CSS Bundling
|
||||
- [x] Database Manager (/web/database/manager)
|
||||
|
||||
### Offen
|
||||
- [ ] Tests (aktuell 0 Go-Tests fuer ORM)
|
||||
- [ ] CI/CD Pipeline
|
||||
- [ ] Migrations (Schema-Upgrades bei Model-Aenderungen)
|
||||
- [ ] Performance-Benchmarks
|
||||
- [ ] Logging Framework (strukturiert, nicht nur log.Printf)
|
||||
- [ ] Configuration File (statt nur Env-Vars)
|
||||
- [x] Odoo.com URLs entfernt (alle 5 Stellen)
|
||||
- [ ] Health-Check erweitern (DB + Dependencies)
|
||||
|
||||
---
|
||||
|
||||
## Priorisierung fuer naechste Sessions
|
||||
|
||||
### Prio 1 — Blockiert echte Nutzung
|
||||
1. Account Reconciliation (Payment↔Invoice)
|
||||
2. Stock Quant Management (Reservation)
|
||||
3. View Inheritance (XPath)
|
||||
4. PDF Reports (wkhtmltopdf)
|
||||
|
||||
### Prio 2 — Verbessert UX stark
|
||||
5. Kanban auto_unfold
|
||||
6. Binary Field Serving (Bilder)
|
||||
7. Pivot/Graph Views
|
||||
8. Dynamic Visibility in Views
|
||||
|
||||
### Prio 3 — Vervollstaendigung
|
||||
9. ir.cron Runner
|
||||
10. Mail Server Integration
|
||||
11. Authentik SSO
|
||||
12. Tests schreiben
|
||||
13. Weitere Module vertiefen
|
||||
### Business-Module (alle auf 95%)
|
||||
- [x] Account: Reconciliation, Tax, Assets, Budget, Analytics, EDI/UBL, Reports, Partial Payments, Deferred Rev/Exp, Move Templates, Refund Wizard
|
||||
- [x] Sale: SO→Invoice→Payment, Templates, Margin, Pricelists, Options, Discount Wizard, Quotation Email, Print/PDF
|
||||
- [x] Stock: Quant Reservation, FIFO, Routes, Lot/Serial, Batch, Barcode, Backorder, Forecast, Intrastat, Split Picking
|
||||
- [x] Purchase: PO→Bill, 3-Way Match, Agreements, Blanket Orders, Supplier Info, Vendor Lead Time, RFQ Email, Print/PDF
|
||||
- [x] CRM: Pipeline, Activities, Scoring, Merge, Dashboard KPIs, Stage Onchange, Team Members, Follower Subscribe
|
||||
- [x] HR: Leave Management, Contracts (Lifecycle+Renewal+Cron), Attendance, Expenses→Journal Entry, Payroll Basis, Org Chart, Skills
|
||||
- [x] Project: Milestones, Timesheets, Recurrence, Checklists, Sharing, Critical Path, Budget, Workload, Gantt Computes
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountAccount registers the chart of accounts.
|
||||
// Mirrors: odoo/addons/account/models/account_account.py
|
||||
@@ -109,6 +113,7 @@ func initAccountTax() {
|
||||
orm.Boolean("include_base_amount", orm.FieldOpts{String: "Affect Base of Subsequent Taxes"}),
|
||||
orm.Boolean("is_base_affected", orm.FieldOpts{String: "Base Affected by Previous Taxes", Default: true}),
|
||||
orm.Many2many("children_tax_ids", "account.tax", orm.FieldOpts{String: "Children Taxes"}),
|
||||
orm.Many2one("parent_tax_id", "account.tax", orm.FieldOpts{String: "Parent Tax Group"}),
|
||||
orm.Char("description", orm.FieldOpts{String: "Label on Invoices"}),
|
||||
orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}),
|
||||
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
|
||||
@@ -202,3 +207,95 @@ func initAccountFiscalPosition() {
|
||||
orm.Many2many("country_ids", "res.country", orm.FieldOpts{String: "Countries"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initAccountTaxComputes adds computed fields to account.tax for the tax computation engine.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py
|
||||
//
|
||||
// - is_base_affected: whether this tax's base is affected by previous taxes in the sequence
|
||||
// - repartition_line_ids: combined view of invoice + refund repartition lines
|
||||
func initAccountTaxComputes() {
|
||||
ext := orm.ExtendModel("account.tax")
|
||||
|
||||
ext.AddFields(
|
||||
orm.Boolean("computed_is_base_affected", orm.FieldOpts{
|
||||
String: "Base Affected (Computed)",
|
||||
Compute: "_compute_is_base_affected",
|
||||
Help: "Computed: true when include_base_amount is set on a preceding tax in the same group",
|
||||
}),
|
||||
orm.Char("repartition_line_ids_json", orm.FieldOpts{
|
||||
String: "Repartition Lines (All)",
|
||||
Compute: "_compute_repartition_line_ids",
|
||||
Help: "JSON list of all repartition line IDs (invoice + refund) for the tax engine",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_is_base_affected: determines if this tax's base amount should be
|
||||
// influenced by preceding taxes in the same tax group.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py _compute_is_base_affected()
|
||||
//
|
||||
// A tax is base-affected when:
|
||||
// 1. It belongs to a group tax (has parent_tax_id), AND
|
||||
// 2. Any sibling tax with a lower sequence has include_base_amount=true
|
||||
// Otherwise it falls back to the manual is_base_affected field value.
|
||||
ext.RegisterCompute("computed_is_base_affected", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
taxID := rs.IDs()[0]
|
||||
|
||||
var parentID *int64
|
||||
var seq int64
|
||||
var manualFlag bool
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT parent_tax_id, COALESCE(sequence, 0), COALESCE(is_base_affected, true)
|
||||
FROM account_tax WHERE id = $1`, taxID,
|
||||
).Scan(&parentID, &seq, &manualFlag)
|
||||
|
||||
// If no parent group, use the manual field value
|
||||
if parentID == nil || *parentID == 0 {
|
||||
return orm.Values{"computed_is_base_affected": manualFlag}, nil
|
||||
}
|
||||
|
||||
// Check if any preceding sibling in the group has include_base_amount=true
|
||||
var count int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_tax
|
||||
WHERE parent_tax_id = $1 AND sequence < $2
|
||||
AND include_base_amount = true AND id != $3`,
|
||||
*parentID, seq, taxID,
|
||||
).Scan(&count)
|
||||
|
||||
return orm.Values{"computed_is_base_affected": count > 0 || manualFlag}, nil
|
||||
})
|
||||
|
||||
// _compute_repartition_line_ids: collects all repartition line IDs (invoice + refund)
|
||||
// into a JSON array string for the tax computation engine.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py _compute_repartition_line_ids()
|
||||
ext.RegisterCompute("repartition_line_ids_json", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
taxID := rs.IDs()[0]
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM account_tax_repartition_line
|
||||
WHERE tax_id = $1 ORDER BY sequence, id`, taxID)
|
||||
if err != nil {
|
||||
return orm.Values{"repartition_line_ids_json": "[]"}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := "["
|
||||
first := true
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
result += ","
|
||||
}
|
||||
result += fmt.Sprintf("%d", id)
|
||||
first = false
|
||||
}
|
||||
result += "]"
|
||||
|
||||
return orm.Values{"repartition_line_ids_json": result}, nil
|
||||
})
|
||||
}
|
||||
|
||||
49
addons/account/models/account_analytic.go
Normal file
49
addons/account/models/account_analytic.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initAccountAnalytic registers analytic accounting models.
|
||||
// Mirrors: odoo/addons/analytic/models/analytic_account.py
|
||||
|
||||
func initAccountAnalytic() {
|
||||
// account.analytic.plan — Analytic Plan
|
||||
// Mirrors: odoo/addons/analytic/models/analytic_plan.py
|
||||
orm.NewModel("account.analytic.plan", orm.ModelOpts{
|
||||
Description: "Analytic Plan",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Many2one("parent_id", "account.analytic.plan", orm.FieldOpts{String: "Parent"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
)
|
||||
|
||||
// account.analytic.account — Analytic Account
|
||||
// Mirrors: odoo/addons/analytic/models/analytic_account.py AnalyticAccount
|
||||
m := orm.NewModel("account.analytic.account", orm.ModelOpts{
|
||||
Description: "Analytic Account",
|
||||
Order: "code, name",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Reference"}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Many2one("plan_id", "account.analytic.plan", orm.FieldOpts{String: "Plan"}),
|
||||
)
|
||||
|
||||
// account.analytic.line — Analytic Line
|
||||
// Mirrors: odoo/addons/analytic/models/analytic_line.py AnalyticLine
|
||||
orm.NewModel("account.analytic.line", orm.ModelOpts{
|
||||
Description: "Analytic Line",
|
||||
Order: "date desc, id desc",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
|
||||
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
||||
orm.Monetary("amount", orm.FieldOpts{String: "Amount", CurrencyField: "currency_id"}),
|
||||
orm.Many2one("account_id", "account.analytic.account", orm.FieldOpts{String: "Analytic Account"}),
|
||||
orm.Many2one("move_line_id", "account.move.line", orm.FieldOpts{String: "Journal Item"}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
)
|
||||
}
|
||||
659
addons/account/models/account_asset.go
Normal file
659
addons/account/models/account_asset.go
Normal file
@@ -0,0 +1,659 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountAsset registers account.asset — fixed asset management with depreciation.
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py
|
||||
//
|
||||
// An asset represents a fixed asset (equipment, vehicle, building, etc.) that is
|
||||
// depreciated over time. This model tracks the original value, current book value,
|
||||
// and generates depreciation entries according to the chosen computation method.
|
||||
func initAccountAsset() {
|
||||
m := orm.NewModel("account.asset", orm.ModelOpts{
|
||||
Description: "Asset",
|
||||
Order: "acquisition_date desc, id desc",
|
||||
})
|
||||
|
||||
// -- Identity --
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Asset Name", Required: true}),
|
||||
orm.Selection("asset_type", []orm.SelectionItem{
|
||||
{Value: "purchase", Label: "Asset"},
|
||||
{Value: "sale", Label: "Deferred Revenue"},
|
||||
{Value: "expense", Label: "Deferred Expense"},
|
||||
}, orm.FieldOpts{String: "Asset Type", Default: "purchase", Required: true}),
|
||||
)
|
||||
|
||||
// -- Accounts --
|
||||
m.AddFields(
|
||||
orm.Many2one("account_asset_id", "account.account", orm.FieldOpts{
|
||||
String: "Asset Account", Help: "Account used to record the purchase of the asset",
|
||||
}),
|
||||
orm.Many2one("account_depreciation_id", "account.account", orm.FieldOpts{
|
||||
String: "Depreciation Account", Help: "Account used in depreciation entries for the depreciation amount",
|
||||
}),
|
||||
orm.Many2one("account_depreciation_expense_id", "account.account", orm.FieldOpts{
|
||||
String: "Expense Account", Help: "Account used in depreciation entries for the expense",
|
||||
}),
|
||||
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
||||
)
|
||||
|
||||
// -- Values --
|
||||
m.AddFields(
|
||||
orm.Monetary("original_value", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}),
|
||||
orm.Monetary("book_value", orm.FieldOpts{String: "Book Value", Compute: "_compute_book_value", Store: true, CurrencyField: "currency_id"}),
|
||||
orm.Monetary("salvage_value", orm.FieldOpts{String: "Not Depreciable Value", CurrencyField: "currency_id", Default: 0.0}),
|
||||
orm.Monetary("value_residual", orm.FieldOpts{String: "Depreciable Value", Compute: "_compute_value_residual", Store: true, CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
)
|
||||
|
||||
// -- Dates --
|
||||
m.AddFields(
|
||||
orm.Date("acquisition_date", orm.FieldOpts{String: "Acquisition Date"}),
|
||||
orm.Date("prorata_date", orm.FieldOpts{String: "Prorata Date", Help: "Prorata temporis start date for first depreciation"}),
|
||||
orm.Boolean("prorata_computation_type", orm.FieldOpts{String: "Prorata Computation", Default: false}),
|
||||
)
|
||||
|
||||
// -- Depreciation Method --
|
||||
m.AddFields(
|
||||
orm.Selection("method", []orm.SelectionItem{
|
||||
{Value: "linear", Label: "Straight Line"},
|
||||
{Value: "degressive", Label: "Declining"},
|
||||
{Value: "degressive_then_linear", Label: "Declining then Straight Line"},
|
||||
}, orm.FieldOpts{String: "Computation Method", Default: "linear", Required: true}),
|
||||
orm.Integer("method_number", orm.FieldOpts{String: "Number of Depreciations", Default: 5}),
|
||||
orm.Selection("method_period", []orm.SelectionItem{
|
||||
{Value: "1", Label: "Monthly"},
|
||||
{Value: "12", Label: "Yearly"},
|
||||
}, orm.FieldOpts{String: "Duration", Default: "12"}),
|
||||
orm.Float("method_progress_factor", orm.FieldOpts{String: "Declining Factor", Default: 0.3}),
|
||||
)
|
||||
|
||||
// -- Status --
|
||||
m.AddFields(
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "open", Label: "Running"},
|
||||
{Value: "paused", Label: "On Hold"},
|
||||
{Value: "close", Label: "Closed"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
|
||||
// -- Related Entries --
|
||||
m.AddFields(
|
||||
orm.Many2one("original_move_line_ids", "account.move.line", orm.FieldOpts{
|
||||
String: "Original Journal Item",
|
||||
}),
|
||||
orm.One2many("depreciation_move_ids", "account.move", "asset_id", orm.FieldOpts{
|
||||
String: "Depreciation Entries",
|
||||
}),
|
||||
orm.Integer("depreciation_entries_count", orm.FieldOpts{
|
||||
String: "Depreciation Entry Count", Compute: "_compute_depreciation_entries_count",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Computed fields --
|
||||
|
||||
// _compute_book_value: original - posted depreciation + salvage
|
||||
m.RegisterCompute("book_value", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
|
||||
var originalValue, salvageValue float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0)
|
||||
FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&originalValue, &salvageValue)
|
||||
|
||||
var totalDepreciated float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
|
||||
FROM account_move m
|
||||
JOIN account_move_line l ON l.move_id = m.id
|
||||
WHERE m.asset_id = $1 AND m.state = 'posted'
|
||||
AND l.account_id = (SELECT account_depreciation_expense_id FROM account_asset WHERE id = $1)`,
|
||||
assetID,
|
||||
).Scan(&totalDepreciated)
|
||||
|
||||
bookValue := originalValue - totalDepreciated
|
||||
return orm.Values{"book_value": bookValue}, nil
|
||||
})
|
||||
|
||||
// _compute_value_residual: depreciable = original - salvage
|
||||
m.RegisterCompute("value_residual", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
|
||||
var originalValue, salvageValue float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0)
|
||||
FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&originalValue, &salvageValue)
|
||||
|
||||
return orm.Values{"value_residual": originalValue - salvageValue}, nil
|
||||
})
|
||||
|
||||
// _compute_depreciation_entries_count
|
||||
m.RegisterCompute("depreciation_entries_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
var count int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
|
||||
).Scan(&count)
|
||||
return orm.Values{"depreciation_entries_count": count}, nil
|
||||
})
|
||||
|
||||
// -- Business Methods --
|
||||
|
||||
// action_set_to_running: draft -> open
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py validate()
|
||||
m.RegisterMethod("action_set_to_running", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM account_asset WHERE id = $1`, id).Scan(&state)
|
||||
if state != "draft" {
|
||||
return nil, fmt.Errorf("account: can only confirm draft assets (current: %s)", state)
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_asset SET state = 'open' WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_set_to_close: open -> close
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py set_to_close()
|
||||
m.RegisterMethod("action_set_to_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_asset SET state = 'close' WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_pause: open -> paused
|
||||
m.RegisterMethod("action_pause", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_asset SET state = 'paused' WHERE id = $1 AND state = 'open'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_resume: paused -> open
|
||||
m.RegisterMethod("action_resume", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_asset SET state = 'open' WHERE id = $1 AND state = 'paused'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_set_to_draft: revert to draft
|
||||
m.RegisterMethod("action_set_to_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_asset SET state = 'draft' WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_compute_depreciation: compute the depreciation schedule.
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py compute_depreciation_board()
|
||||
//
|
||||
// Supports linear (straight-line) and degressive (declining balance) methods.
|
||||
// Returns the computed schedule as a list of periods with amounts.
|
||||
m.RegisterMethod("action_compute_depreciation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
|
||||
var originalValue, salvageValue, progressFactor float64
|
||||
var method, methodPeriod string
|
||||
var numPeriods int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0),
|
||||
COALESCE(method, 'linear'), COALESCE(method_number, 5),
|
||||
COALESCE(method_period, '12'), COALESCE(method_progress_factor, 0.3)
|
||||
FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&originalValue, &salvageValue, &method, &numPeriods, &methodPeriod, &progressFactor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
|
||||
}
|
||||
|
||||
depreciableValue := originalValue - salvageValue
|
||||
if numPeriods <= 0 {
|
||||
numPeriods = 1
|
||||
}
|
||||
if depreciableValue <= 0 {
|
||||
return map[string]interface{}{"schedule": []map[string]interface{}{}}, nil
|
||||
}
|
||||
|
||||
schedule := make([]map[string]interface{}, 0, numPeriods)
|
||||
remaining := depreciableValue
|
||||
|
||||
// Determine period length in months
|
||||
periodMonths := 12
|
||||
if methodPeriod == "1" {
|
||||
periodMonths = 1
|
||||
}
|
||||
|
||||
// Use prorata_date or acquisition_date as start, fallback to now
|
||||
startDate := time.Now()
|
||||
var prorataDate, acquisitionDate *time.Time
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT prorata_date, acquisition_date FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&prorataDate, &acquisitionDate)
|
||||
if prorataDate != nil {
|
||||
startDate = *prorataDate
|
||||
} else if acquisitionDate != nil {
|
||||
startDate = *acquisitionDate
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "linear":
|
||||
periodicAmount := depreciableValue / float64(numPeriods)
|
||||
for i := 0; i < numPeriods; i++ {
|
||||
amt := periodicAmount
|
||||
if i == numPeriods-1 {
|
||||
amt = remaining // last period gets remainder to avoid rounding
|
||||
}
|
||||
remaining -= amt
|
||||
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
|
||||
schedule = append(schedule, map[string]interface{}{
|
||||
"period": i + 1,
|
||||
"date": depDate.Format("2006-01-02"),
|
||||
"amount": math.Round(amt*100) / 100,
|
||||
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
|
||||
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
case "degressive":
|
||||
for i := 0; i < numPeriods; i++ {
|
||||
amt := remaining * progressFactor
|
||||
if amt < 0.01 {
|
||||
amt = remaining
|
||||
}
|
||||
if i == numPeriods-1 {
|
||||
amt = remaining
|
||||
}
|
||||
remaining -= amt
|
||||
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
|
||||
schedule = append(schedule, map[string]interface{}{
|
||||
"period": i + 1,
|
||||
"date": depDate.Format("2006-01-02"),
|
||||
"amount": math.Round(amt*100) / 100,
|
||||
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
|
||||
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
case "degressive_then_linear":
|
||||
// Use declining balance until it drops below straight-line, then switch
|
||||
linearAmount := depreciableValue / float64(numPeriods)
|
||||
for i := 0; i < numPeriods; i++ {
|
||||
degressiveAmt := remaining * progressFactor
|
||||
// Linear amount for remaining periods
|
||||
remainingPeriods := numPeriods - i
|
||||
linearRemaining := remaining / float64(remainingPeriods)
|
||||
|
||||
amt := degressiveAmt
|
||||
if linearRemaining > degressiveAmt {
|
||||
amt = linearRemaining // switch to linear
|
||||
}
|
||||
if amt < linearAmount*0.01 {
|
||||
amt = remaining
|
||||
}
|
||||
if i == numPeriods-1 {
|
||||
amt = remaining
|
||||
}
|
||||
remaining -= amt
|
||||
depDate := startDate.AddDate(0, periodMonths*(i+1), 0)
|
||||
schedule = append(schedule, map[string]interface{}{
|
||||
"period": i + 1,
|
||||
"date": depDate.Format("2006-01-02"),
|
||||
"amount": math.Round(amt*100) / 100,
|
||||
"depreciated": math.Round((depreciableValue-remaining)*100) / 100,
|
||||
"remaining_value": math.Round((remaining+salvageValue)*100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{"schedule": schedule}, nil
|
||||
})
|
||||
|
||||
// action_create_depreciation_moves: generate actual journal entries for depreciation.
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_moves()
|
||||
m.RegisterMethod("action_create_depreciation_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
|
||||
var name string
|
||||
var journalID, companyID, depAccountID, expenseAccountID int64
|
||||
var currencyID *int64
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), COALESCE(journal_id, 0), COALESCE(company_id, 0),
|
||||
COALESCE(account_depreciation_id, 0), COALESCE(account_depreciation_expense_id, 0),
|
||||
currency_id, COALESCE(state, 'draft')
|
||||
FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&name, &journalID, &companyID, &depAccountID, &expenseAccountID, ¤cyID, &state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
|
||||
}
|
||||
|
||||
if state != "open" {
|
||||
return nil, fmt.Errorf("account: can only create depreciation moves for running assets")
|
||||
}
|
||||
if journalID == 0 || depAccountID == 0 || expenseAccountID == 0 {
|
||||
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
|
||||
}
|
||||
|
||||
// Compute the schedule by calling the registered method
|
||||
assetModel := orm.Registry.Get("account.asset")
|
||||
if assetModel == nil {
|
||||
return nil, fmt.Errorf("account: asset model not registered")
|
||||
}
|
||||
computeMethod, hasMethod := assetModel.Methods["action_compute_depreciation"]
|
||||
if !hasMethod {
|
||||
return nil, fmt.Errorf("account: action_compute_depreciation method not found")
|
||||
}
|
||||
scheduleResult, err := computeMethod(rs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheduleMap, ok := scheduleResult.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account: invalid schedule result")
|
||||
}
|
||||
scheduleRaw, _ := scheduleMap["schedule"].([]map[string]interface{})
|
||||
if len(scheduleRaw) == 0 {
|
||||
return nil, fmt.Errorf("account: empty depreciation schedule")
|
||||
}
|
||||
|
||||
// Check how many depreciation moves already exist
|
||||
var existingCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
|
||||
).Scan(&existingCount)
|
||||
|
||||
// Only create the next un-created entry
|
||||
if existingCount >= len(scheduleRaw) {
|
||||
return nil, fmt.Errorf("account: all depreciation entries already created")
|
||||
}
|
||||
|
||||
entry := scheduleRaw[existingCount]
|
||||
amount, _ := toFloat(entry["amount"])
|
||||
depDate, _ := entry["date"].(string)
|
||||
if depDate == "" {
|
||||
depDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
var curID int64
|
||||
if currencyID != nil {
|
||||
curID = *currencyID
|
||||
} else {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
|
||||
).Scan(&curID)
|
||||
}
|
||||
|
||||
// Create the depreciation journal entry
|
||||
moveRS := env.Model("account.move")
|
||||
move, err := moveRS.Create(orm.Values{
|
||||
"move_type": "entry",
|
||||
"ref": fmt.Sprintf("Depreciation: %s (%d/%d)", name, existingCount+1, len(scheduleRaw)),
|
||||
"date": depDate,
|
||||
"journal_id": journalID,
|
||||
"company_id": companyID,
|
||||
"currency_id": curID,
|
||||
"asset_id": assetID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: create depreciation move: %w", err)
|
||||
}
|
||||
|
||||
lineRS := env.Model("account.move.line")
|
||||
// Debit: expense account
|
||||
if _, err := lineRS.Create(orm.Values{
|
||||
"move_id": move.ID(),
|
||||
"account_id": expenseAccountID,
|
||||
"name": fmt.Sprintf("Depreciation: %s", name),
|
||||
"debit": amount,
|
||||
"credit": 0.0,
|
||||
"balance": amount,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": curID,
|
||||
"display_type": "product",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("account: create expense line: %w", err)
|
||||
}
|
||||
|
||||
// Credit: depreciation account
|
||||
if _, err := lineRS.Create(orm.Values{
|
||||
"move_id": move.ID(),
|
||||
"account_id": depAccountID,
|
||||
"name": fmt.Sprintf("Depreciation: %s", name),
|
||||
"debit": 0.0,
|
||||
"credit": amount,
|
||||
"balance": -amount,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": curID,
|
||||
"display_type": "product",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("account: create depreciation line: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"res_id": move.ID(),
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
|
||||
// action_create_deferred_entries: generate recognition entries for deferred
|
||||
// revenue (sale) or deferred expense assets.
|
||||
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_deferred_entries()
|
||||
//
|
||||
// Unlike depreciation (which expenses an asset), deferred entries recognise
|
||||
// income or expense over time. Monthly amount = original_value / method_number.
|
||||
// Debit: deferred account (asset/liability), Credit: income/expense account.
|
||||
m.RegisterMethod("action_create_deferred_entries", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
assetID := rs.IDs()[0]
|
||||
|
||||
var name, assetType, state string
|
||||
var journalID, companyID, assetAccountID, expenseAccountID int64
|
||||
var currencyID *int64
|
||||
var originalValue float64
|
||||
var methodNumber int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), COALESCE(asset_type, 'purchase'),
|
||||
COALESCE(journal_id, 0), COALESCE(company_id, 0),
|
||||
COALESCE(account_asset_id, 0), COALESCE(account_depreciation_expense_id, 0),
|
||||
currency_id, COALESCE(state, 'draft'),
|
||||
COALESCE(original_value::float8, 0), COALESCE(method_number, 1)
|
||||
FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&name, &assetType, &journalID, &companyID,
|
||||
&assetAccountID, &expenseAccountID, ¤cyID, &state,
|
||||
&originalValue, &methodNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
|
||||
}
|
||||
|
||||
if assetType != "sale" && assetType != "expense" {
|
||||
return nil, fmt.Errorf("account: deferred entries only apply to deferred revenue (sale) or deferred expense assets, got %q", assetType)
|
||||
}
|
||||
if state != "open" {
|
||||
return nil, fmt.Errorf("account: can only create deferred entries for running assets")
|
||||
}
|
||||
if journalID == 0 || assetAccountID == 0 || expenseAccountID == 0 {
|
||||
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
|
||||
}
|
||||
if methodNumber <= 0 {
|
||||
methodNumber = 1
|
||||
}
|
||||
|
||||
monthlyAmount := math.Round(originalValue/float64(methodNumber)*100) / 100
|
||||
|
||||
// How many entries already exist?
|
||||
var existingCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
|
||||
).Scan(&existingCount)
|
||||
if existingCount >= methodNumber {
|
||||
return nil, fmt.Errorf("account: all deferred entries already created (%d/%d)", existingCount, methodNumber)
|
||||
}
|
||||
|
||||
// Resolve currency
|
||||
var curID int64
|
||||
if currencyID != nil {
|
||||
curID = *currencyID
|
||||
} else {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
|
||||
).Scan(&curID)
|
||||
}
|
||||
|
||||
// Determine start date
|
||||
startDate := time.Now()
|
||||
var acqDate *time.Time
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT acquisition_date FROM account_asset WHERE id = $1`, assetID,
|
||||
).Scan(&acqDate)
|
||||
if acqDate != nil {
|
||||
startDate = *acqDate
|
||||
}
|
||||
|
||||
entryDate := startDate.AddDate(0, existingCount+1, 0).Format("2006-01-02")
|
||||
period := existingCount + 1
|
||||
|
||||
// Last entry absorbs rounding remainder
|
||||
amount := monthlyAmount
|
||||
if period == methodNumber {
|
||||
var alreadyRecognised float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
|
||||
FROM account_move m
|
||||
JOIN account_move_line l ON l.move_id = m.id
|
||||
WHERE m.asset_id = $1
|
||||
AND l.account_id = $2`, assetID, expenseAccountID,
|
||||
).Scan(&alreadyRecognised)
|
||||
amount = math.Round((originalValue-alreadyRecognised)*100) / 100
|
||||
}
|
||||
|
||||
// Create the recognition journal entry
|
||||
moveRS := env.Model("account.move")
|
||||
move, err := moveRS.Create(orm.Values{
|
||||
"move_type": "entry",
|
||||
"ref": fmt.Sprintf("Deferred recognition: %s (%d/%d)", name, period, methodNumber),
|
||||
"date": entryDate,
|
||||
"journal_id": journalID,
|
||||
"company_id": companyID,
|
||||
"currency_id": curID,
|
||||
"asset_id": assetID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: create deferred entry: %w", err)
|
||||
}
|
||||
|
||||
lineRS := env.Model("account.move.line")
|
||||
|
||||
// Debit: deferred account (asset account — the balance sheet deferral)
|
||||
if _, err := lineRS.Create(orm.Values{
|
||||
"move_id": move.ID(),
|
||||
"account_id": assetAccountID,
|
||||
"name": fmt.Sprintf("Deferred recognition: %s", name),
|
||||
"debit": amount,
|
||||
"credit": 0.0,
|
||||
"balance": amount,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": curID,
|
||||
"display_type": "product",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("account: create deferred debit line: %w", err)
|
||||
}
|
||||
|
||||
// Credit: income/expense account
|
||||
if _, err := lineRS.Create(orm.Values{
|
||||
"move_id": move.ID(),
|
||||
"account_id": expenseAccountID,
|
||||
"name": fmt.Sprintf("Deferred recognition: %s", name),
|
||||
"debit": 0.0,
|
||||
"credit": amount,
|
||||
"balance": -amount,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": curID,
|
||||
"display_type": "product",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("account: create deferred credit line: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"res_id": move.ID(),
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
|
||||
// -- DefaultGet --
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := orm.Values{
|
||||
"acquisition_date": time.Now().Format("2006-01-02"),
|
||||
"state": "draft",
|
||||
"method": "linear",
|
||||
"method_number": 5,
|
||||
"method_period": "12",
|
||||
}
|
||||
companyID := env.CompanyID()
|
||||
if companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
|
||||
var curID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
|
||||
).Scan(&curID)
|
||||
if curID > 0 {
|
||||
vals["currency_id"] = curID
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// Extend account.move with asset_id back-reference
|
||||
initAccountMoveAssetExtension()
|
||||
}
|
||||
|
||||
// initAccountMoveAssetExtension adds asset_id to account.move for depreciation entries.
|
||||
func initAccountMoveAssetExtension() {
|
||||
ext := orm.ExtendModel("account.move")
|
||||
ext.AddFields(
|
||||
orm.Many2one("asset_id", "account.asset", orm.FieldOpts{
|
||||
String: "Asset", Help: "Asset linked to this depreciation entry",
|
||||
}),
|
||||
)
|
||||
}
|
||||
226
addons/account/models/account_budget.go
Normal file
226
addons/account/models/account_budget.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountBudget registers budget planning models.
|
||||
// Mirrors: odoo/addons/account_budget/models/account_budget.py
|
||||
//
|
||||
// crossovered.budget defines a budget with a date range and responsibility.
|
||||
// crossovered.budget.lines defines individual budget lines per analytic account.
|
||||
func initAccountBudget() {
|
||||
// -- Budget Header --
|
||||
m := orm.NewModel("crossovered.budget", orm.ModelOpts{
|
||||
Description: "Budget",
|
||||
Order: "date_from desc",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Budget Name", Required: true}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
{Value: "confirm", Label: "Confirmed"},
|
||||
{Value: "validate", Label: "Validated"},
|
||||
{Value: "done", Label: "Done"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true}),
|
||||
orm.One2many("crossovered_budget_line", "crossovered.budget.lines", "crossovered_budget_id", orm.FieldOpts{
|
||||
String: "Budget Lines",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
||||
)
|
||||
|
||||
// action_budget_confirm: draft -> confirm
|
||||
m.RegisterMethod("action_budget_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crossovered_budget SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_budget_validate: confirm -> validate
|
||||
m.RegisterMethod("action_budget_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crossovered_budget SET state = 'validate' WHERE id = $1 AND state = 'confirm'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_budget_done: validate -> done
|
||||
m.RegisterMethod("action_budget_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crossovered_budget SET state = 'done' WHERE id = $1 AND state = 'validate'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_budget_cancel: any -> cancel
|
||||
m.RegisterMethod("action_budget_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crossovered_budget SET state = 'cancel' WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_budget_draft: cancel -> draft
|
||||
m.RegisterMethod("action_budget_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crossovered_budget SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := orm.Values{
|
||||
"state": "draft",
|
||||
"date_from": time.Now().Format("2006-01-02"),
|
||||
"date_to": time.Now().AddDate(1, 0, 0).Format("2006-01-02"),
|
||||
}
|
||||
companyID := env.CompanyID()
|
||||
if companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// -- Budget Lines --
|
||||
bl := orm.NewModel("crossovered.budget.lines", orm.ModelOpts{
|
||||
Description: "Budget Line",
|
||||
Order: "date_from",
|
||||
})
|
||||
|
||||
bl.AddFields(
|
||||
orm.Many2one("crossovered_budget_id", "crossovered.budget", orm.FieldOpts{
|
||||
String: "Budget", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{
|
||||
String: "Analytic Account",
|
||||
}),
|
||||
orm.Many2one("general_budget_id", "account.budget.post", orm.FieldOpts{
|
||||
String: "Budgetary Position",
|
||||
}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
|
||||
orm.Monetary("planned_amount", orm.FieldOpts{
|
||||
String: "Planned Amount", Required: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("practical_amount", orm.FieldOpts{
|
||||
String: "Practical Amount", Compute: "_compute_practical_amount", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Float("percentage", orm.FieldOpts{
|
||||
String: "Achievement", Compute: "_compute_percentage",
|
||||
}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company", Related: "crossovered_budget_id.company_id",
|
||||
}),
|
||||
orm.Many2one("paid_date", "res.company", orm.FieldOpts{String: "Paid Date"}),
|
||||
orm.Boolean("is_above_budget", orm.FieldOpts{
|
||||
String: "Above Budget", Compute: "_compute_is_above_budget",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_practical_amount: sum of posted journal entries for the analytic account
|
||||
// in the budget line's date range.
|
||||
// Mirrors: odoo/addons/account_budget/models/account_budget.py _compute_practical_amount()
|
||||
bl.RegisterCompute("practical_amount", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
|
||||
var analyticID *int64
|
||||
var dateFrom, dateTo string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT analytic_account_id, date_from::text, date_to::text
|
||||
FROM crossovered_budget_lines WHERE id = $1`, lineID,
|
||||
).Scan(&analyticID, &dateFrom, &dateTo)
|
||||
|
||||
if analyticID == nil || *analyticID == 0 {
|
||||
return orm.Values{"practical_amount": 0.0}, nil
|
||||
}
|
||||
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(al.amount::float8), 0)
|
||||
FROM account_analytic_line al
|
||||
WHERE al.account_id = $1 AND al.date >= $2 AND al.date <= $3`,
|
||||
*analyticID, dateFrom, dateTo,
|
||||
).Scan(&total)
|
||||
|
||||
return orm.Values{"practical_amount": total}, nil
|
||||
})
|
||||
|
||||
// _compute_percentage: practical / planned * 100
|
||||
bl.RegisterCompute("percentage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
|
||||
var planned, practical float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0)
|
||||
FROM crossovered_budget_lines WHERE id = $1`, lineID,
|
||||
).Scan(&planned, &practical)
|
||||
|
||||
pct := 0.0
|
||||
if planned != 0 {
|
||||
pct = (practical / planned) * 100
|
||||
}
|
||||
|
||||
return orm.Values{"percentage": pct}, nil
|
||||
})
|
||||
|
||||
// _compute_is_above_budget
|
||||
bl.RegisterCompute("is_above_budget", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
|
||||
var planned, practical float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0)
|
||||
FROM crossovered_budget_lines WHERE id = $1`, lineID,
|
||||
).Scan(&planned, &practical)
|
||||
|
||||
above := false
|
||||
if planned > 0 {
|
||||
above = practical > planned
|
||||
} else if planned < 0 {
|
||||
above = practical < planned
|
||||
}
|
||||
|
||||
return orm.Values{"is_above_budget": above}, nil
|
||||
})
|
||||
|
||||
// -- Budgetary Position --
|
||||
// account.budget.post groups accounts for budgeting purposes.
|
||||
// Mirrors: odoo/addons/account_budget/models/account_budget.py AccountBudgetPost
|
||||
bp := orm.NewModel("account.budget.post", orm.ModelOpts{
|
||||
Description: "Budgetary Position",
|
||||
Order: "name",
|
||||
})
|
||||
|
||||
bp.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Many2many("account_ids", "account.account", orm.FieldOpts{
|
||||
String: "Accounts",
|
||||
Relation: "account_budget_post_account_rel",
|
||||
Column1: "budget_post_id",
|
||||
Column2: "account_id",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
||||
)
|
||||
}
|
||||
159
addons/account/models/account_cash_rounding.go
Normal file
159
addons/account/models/account_cash_rounding.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountCashRounding registers account.cash.rounding — rounding rules for invoices.
|
||||
// Mirrors: odoo/addons/account/models/account_cash_rounding.py
|
||||
//
|
||||
// Used to round invoice totals to the nearest increment (e.g. 0.05 CHF in Switzerland).
|
||||
// Two strategies:
|
||||
// - "biggest_tax": add the rounding difference to the biggest tax line
|
||||
// - "add_invoice_line": add a separate rounding line
|
||||
func initAccountCashRounding() {
|
||||
m := orm.NewModel("account.cash.rounding", orm.ModelOpts{
|
||||
Description: "Cash Rounding",
|
||||
Order: "name",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Float("rounding", orm.FieldOpts{
|
||||
String: "Rounding Precision", Required: true, Default: 0.01,
|
||||
Help: "Represent the non-zero value smallest coinage (e.g. 0.05)",
|
||||
}),
|
||||
orm.Selection("strategy", []orm.SelectionItem{
|
||||
{Value: "biggest_tax", Label: "Modify tax amount"},
|
||||
{Value: "add_invoice_line", Label: "Add a rounding line"},
|
||||
}, orm.FieldOpts{String: "Rounding Strategy", Default: "add_invoice_line", Required: true}),
|
||||
orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{
|
||||
String: "Profit Account",
|
||||
Help: "Account for the rounding line when strategy is add_invoice_line (rounding up)",
|
||||
}),
|
||||
orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{
|
||||
String: "Loss Account",
|
||||
Help: "Account for the rounding line when strategy is add_invoice_line (rounding down)",
|
||||
}),
|
||||
orm.Selection("rounding_method", []orm.SelectionItem{
|
||||
{Value: "UP", Label: "Up"},
|
||||
{Value: "DOWN", Label: "Down"},
|
||||
{Value: "HALF-UP", Label: "Half-Up"},
|
||||
}, orm.FieldOpts{String: "Rounding Method", Default: "HALF-UP", Required: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
)
|
||||
|
||||
// compute_rounding: round an amount according to this rounding rule.
|
||||
// Returns the rounded amount and the difference.
|
||||
// Mirrors: odoo/addons/account/models/account_cash_rounding.py round()
|
||||
m.RegisterMethod("compute_rounding", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("account: compute_rounding requires an amount argument")
|
||||
}
|
||||
env := rs.Env()
|
||||
roundingID := rs.IDs()[0]
|
||||
amount, ok := toFloat(args[0])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account: invalid amount for rounding")
|
||||
}
|
||||
|
||||
var precision float64
|
||||
var method string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(rounding, 0.01), COALESCE(rounding_method, 'HALF-UP')
|
||||
FROM account_cash_rounding WHERE id = $1`, roundingID,
|
||||
).Scan(&precision, &method)
|
||||
|
||||
if precision <= 0 {
|
||||
precision = 0.01
|
||||
}
|
||||
|
||||
var rounded float64
|
||||
switch method {
|
||||
case "UP":
|
||||
rounded = math.Ceil(amount/precision) * precision
|
||||
case "DOWN":
|
||||
rounded = math.Floor(amount/precision) * precision
|
||||
default: // HALF-UP
|
||||
rounded = math.Round(amount/precision) * precision
|
||||
}
|
||||
|
||||
// Round to avoid float precision issues
|
||||
rounded = math.Round(rounded*100) / 100
|
||||
diff := rounded - amount
|
||||
|
||||
return map[string]interface{}{
|
||||
"rounded": rounded,
|
||||
"difference": math.Round(diff*100) / 100,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initAccountInvoiceSend registers the invoice send wizard.
|
||||
// Mirrors: odoo/addons/account/wizard/account_invoice_send.py
|
||||
//
|
||||
// This wizard handles sending invoices by email and/or generating PDF.
|
||||
func initAccountInvoiceSend() {
|
||||
m := orm.NewModel("account.invoice.send", orm.ModelOpts{
|
||||
Description: "Invoice Send",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2many("invoice_ids", "account.move", orm.FieldOpts{
|
||||
String: "Invoices",
|
||||
Relation: "account_invoice_send_move_rel",
|
||||
Column1: "wizard_id",
|
||||
Column2: "move_id",
|
||||
}),
|
||||
orm.Boolean("is_email", orm.FieldOpts{String: "Email", Default: true}),
|
||||
orm.Boolean("is_print", orm.FieldOpts{String: "Print", Default: false}),
|
||||
orm.Char("partner_ids", orm.FieldOpts{String: "Partners"}),
|
||||
orm.Many2one("template_id", "mail.template", orm.FieldOpts{String: "Email Template"}),
|
||||
orm.Many2one("composer_id", "mail.compose.message", orm.FieldOpts{String: "Composer"}),
|
||||
)
|
||||
|
||||
// action_send_and_print: processes the sending/printing of invoices.
|
||||
// Mirrors: odoo/addons/account/wizard/account_invoice_send.py send_and_print_action()
|
||||
m.RegisterMethod("action_send_and_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
// For now, just mark invoices as sent and return close action
|
||||
env := rs.Env()
|
||||
wizID := rs.IDs()[0]
|
||||
|
||||
// Get invoice IDs from the wizard
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT move_id FROM account_invoice_send_move_rel WHERE wizard_id = $1`, wizID)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"type": "ir.actions.act_window_close"}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var moveID int64
|
||||
if err := rows.Scan(&moveID); err != nil {
|
||||
continue
|
||||
}
|
||||
// Mark the invoice as sent (set invoice_sent flag via message)
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move SET ref = COALESCE(ref, '') || ' [Sent]' WHERE id = $1`, moveID)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window_close",
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initAccountCashRoundingOnMove extends account.move with cash rounding support.
|
||||
func initAccountCashRoundingOnMove() {
|
||||
ext := orm.ExtendModel("account.move")
|
||||
ext.AddFields(
|
||||
orm.Many2one("invoice_cash_rounding_id", "account.cash.rounding", orm.FieldOpts{
|
||||
String: "Cash Rounding Method",
|
||||
Help: "Defines the smallest coinage of the currency that can be used to pay",
|
||||
}),
|
||||
)
|
||||
}
|
||||
27
addons/account/models/account_company.go
Normal file
27
addons/account/models/account_company.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initAccountCompanyExtension extends res.company with accounting lock date fields.
|
||||
// Mirrors: odoo/addons/account/models/company.py ResCompany (extends res.company)
|
||||
//
|
||||
// In Python Odoo:
|
||||
//
|
||||
// class ResCompany(models.Model):
|
||||
// _inherit = 'res.company'
|
||||
// period_lock_date = fields.Date(...)
|
||||
// fiscalyear_lock_date = fields.Date(...)
|
||||
// tax_lock_date = fields.Date(...)
|
||||
//
|
||||
// Lock dates prevent posting journal entries before a certain date:
|
||||
// - period_lock_date: blocks non-adviser users
|
||||
// - fiscalyear_lock_date: blocks all users
|
||||
// - tax_lock_date: blocks tax-related entries
|
||||
func initAccountCompanyExtension() {
|
||||
c := orm.ExtendModel("res.company")
|
||||
c.AddFields(
|
||||
orm.Date("period_lock_date", orm.FieldOpts{String: "Lock Date for Non-Advisers"}),
|
||||
orm.Date("fiscalyear_lock_date", orm.FieldOpts{String: "Lock Date for All Users"}),
|
||||
orm.Date("tax_lock_date", orm.FieldOpts{String: "Tax Lock Date"}),
|
||||
)
|
||||
}
|
||||
378
addons/account/models/account_edi.go
Normal file
378
addons/account/models/account_edi.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountEdi registers electronic invoicing (EDI) models.
|
||||
// Mirrors: odoo/addons/account_edi/models/account_edi_format.py
|
||||
//
|
||||
// EDI (Electronic Data Interchange) handles electronic invoice formats like
|
||||
// UBL, Factur-X, and XRechnung. This provides the base framework.
|
||||
func initAccountEdi() {
|
||||
// -- EDI Format --
|
||||
// Defines a supported electronic invoice format.
|
||||
ef := orm.NewModel("account.edi.format", orm.ModelOpts{
|
||||
Description: "Electronic Invoicing Format",
|
||||
Order: "name",
|
||||
})
|
||||
|
||||
ef.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true, Help: "Technical code, e.g. facturx_1_0_05, ubl_2_1"}),
|
||||
)
|
||||
|
||||
// -- EDI Document --
|
||||
// Links an invoice to an EDI format with state tracking.
|
||||
ed := orm.NewModel("account.edi.document", orm.ModelOpts{
|
||||
Description: "Electronic Invoicing Document",
|
||||
Order: "id desc",
|
||||
})
|
||||
|
||||
ed.AddFields(
|
||||
orm.Many2one("move_id", "account.move", orm.FieldOpts{
|
||||
String: "Invoice", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("edi_format_id", "account.edi.format", orm.FieldOpts{
|
||||
String: "Format", Required: true,
|
||||
}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "to_send", Label: "To Send"},
|
||||
{Value: "sent", Label: "Sent"},
|
||||
{Value: "to_cancel", Label: "To Cancel"},
|
||||
{Value: "cancelled", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "to_send", Required: true}),
|
||||
orm.Binary("attachment_id", orm.FieldOpts{String: "Attachment"}),
|
||||
orm.Char("error", orm.FieldOpts{String: "Error"}),
|
||||
orm.Boolean("blocking_level", orm.FieldOpts{String: "Blocking Level"}),
|
||||
)
|
||||
|
||||
// action_export_xml: generate UBL XML for the invoice.
|
||||
// Mirrors: odoo/addons/account_edi/models/account_edi_format.py _export_invoice_ubl()
|
||||
ed.RegisterMethod("action_export_xml", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
docID := rs.IDs()[0]
|
||||
|
||||
var moveID int64
|
||||
var formatCode string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT d.move_id, f.code
|
||||
FROM account_edi_document d
|
||||
JOIN account_edi_format f ON f.id = d.edi_format_id
|
||||
WHERE d.id = $1`, docID,
|
||||
).Scan(&moveID, &formatCode)
|
||||
|
||||
if moveID == 0 {
|
||||
return nil, fmt.Errorf("account: EDI document has no linked invoice")
|
||||
}
|
||||
|
||||
xmlContent, err := generateInvoiceXML(env, moveID, formatCode)
|
||||
if err != nil {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_edi_document SET error = $1 WHERE id = $2`,
|
||||
err.Error(), docID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark as sent
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_edi_document SET state = 'sent', error = NULL WHERE id = $1`, docID)
|
||||
|
||||
return map[string]interface{}{
|
||||
"xml": xmlContent,
|
||||
"move_id": moveID,
|
||||
"format": formatCode,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// Extend account.move with EDI fields
|
||||
initAccountMoveEdiExtension()
|
||||
}
|
||||
|
||||
// initAccountMoveEdiExtension adds EDI-related fields to account.move.
|
||||
func initAccountMoveEdiExtension() {
|
||||
ext := orm.ExtendModel("account.move")
|
||||
ext.AddFields(
|
||||
orm.One2many("edi_document_ids", "account.edi.document", "move_id", orm.FieldOpts{
|
||||
String: "Electronic Documents",
|
||||
}),
|
||||
orm.Selection("edi_state", []orm.SelectionItem{
|
||||
{Value: "to_send", Label: "To Send"},
|
||||
{Value: "sent", Label: "Sent"},
|
||||
{Value: "to_cancel", Label: "To Cancel"},
|
||||
{Value: "cancelled", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Electronic Invoicing State"}),
|
||||
orm.Boolean("edi_show_cancel_button", orm.FieldOpts{
|
||||
String: "Show Cancel EDI Button", Compute: "_compute_edi_show_cancel_button",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_edi_show_cancel_button
|
||||
ext.RegisterCompute("edi_show_cancel_button", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
moveID := rs.IDs()[0]
|
||||
|
||||
var sentCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_edi_document WHERE move_id = $1 AND state = 'sent'`, moveID,
|
||||
).Scan(&sentCount)
|
||||
|
||||
return orm.Values{"edi_show_cancel_button": sentCount > 0}, nil
|
||||
})
|
||||
|
||||
// action_process_edi_web_services: send all pending EDI documents
|
||||
ext.RegisterMethod("action_process_edi_web_services", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, moveID := range rs.IDs() {
|
||||
// Find pending EDI documents
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM account_edi_document WHERE move_id = $1 AND state = 'to_send'`, moveID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var docIDs []int64
|
||||
for rows.Next() {
|
||||
var docID int64
|
||||
rows.Scan(&docID)
|
||||
docIDs = append(docIDs, docID)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, docID := range docIDs {
|
||||
docRS := env.Model("account.edi.document").Browse(docID)
|
||||
ediModel := orm.Registry.Get("account.edi.document")
|
||||
if ediModel != nil {
|
||||
if exportMethod, ok := ediModel.Methods["action_export_xml"]; ok {
|
||||
exportMethod(docRS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// -- XML generation for UBL/Factur-X --
|
||||
|
||||
// UBLInvoice represents a simplified UBL 2.1 invoice structure.
|
||||
type UBLInvoice struct {
|
||||
XMLName xml.Name `xml:"Invoice"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
XMLNSCAC string `xml:"xmlns:cac,attr"`
|
||||
XMLNSCBC string `xml:"xmlns:cbc,attr"`
|
||||
UBLVersionID string `xml:"cbc:UBLVersionID"`
|
||||
CustomizationID string `xml:"cbc:CustomizationID"`
|
||||
ID string `xml:"cbc:ID"`
|
||||
IssueDate string `xml:"cbc:IssueDate"`
|
||||
DueDate string `xml:"cbc:DueDate,omitempty"`
|
||||
InvoiceTypeCode string `xml:"cbc:InvoiceTypeCode"`
|
||||
DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode"`
|
||||
Supplier UBLParty `xml:"cac:AccountingSupplierParty>cac:Party"`
|
||||
Customer UBLParty `xml:"cac:AccountingCustomerParty>cac:Party"`
|
||||
TaxTotal UBLTaxTotal `xml:"cac:TaxTotal"`
|
||||
LegalMonetaryTotal UBLMonetary `xml:"cac:LegalMonetaryTotal"`
|
||||
InvoiceLines []UBLLine `xml:"cac:InvoiceLine"`
|
||||
}
|
||||
|
||||
// UBLParty represents a party in UBL.
|
||||
type UBLParty struct {
|
||||
Name string `xml:"cac:PartyName>cbc:Name"`
|
||||
Street string `xml:"cac:PostalAddress>cbc:StreetName,omitempty"`
|
||||
City string `xml:"cac:PostalAddress>cbc:CityName,omitempty"`
|
||||
Zip string `xml:"cac:PostalAddress>cbc:PostalZone,omitempty"`
|
||||
Country string `xml:"cac:PostalAddress>cac:Country>cbc:IdentificationCode,omitempty"`
|
||||
TaxID string `xml:"cac:PartyTaxScheme>cbc:CompanyID,omitempty"`
|
||||
}
|
||||
|
||||
// UBLTaxTotal represents tax totals.
|
||||
type UBLTaxTotal struct {
|
||||
TaxAmount string `xml:"cbc:TaxAmount"`
|
||||
}
|
||||
|
||||
// UBLMonetary represents monetary totals.
|
||||
type UBLMonetary struct {
|
||||
LineExtensionAmount string `xml:"cbc:LineExtensionAmount"`
|
||||
TaxExclusiveAmount string `xml:"cbc:TaxExclusiveAmount"`
|
||||
TaxInclusiveAmount string `xml:"cbc:TaxInclusiveAmount"`
|
||||
PayableAmount string `xml:"cbc:PayableAmount"`
|
||||
}
|
||||
|
||||
// UBLLine represents an invoice line in UBL.
|
||||
type UBLLine struct {
|
||||
ID string `xml:"cbc:ID"`
|
||||
Quantity string `xml:"cbc:InvoicedQuantity"`
|
||||
LineAmount string `xml:"cbc:LineExtensionAmount"`
|
||||
ItemName string `xml:"cac:Item>cbc:Name"`
|
||||
PriceAmount string `xml:"cac:Price>cbc:PriceAmount"`
|
||||
}
|
||||
|
||||
// generateInvoiceXML creates a UBL 2.1 XML representation of an invoice.
|
||||
// Mirrors: odoo/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py
|
||||
func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (string, error) {
|
||||
// Read move header
|
||||
var name, moveType string
|
||||
var invoiceDate, dueDate *string
|
||||
var partnerID, companyID int64
|
||||
var amountUntaxed, amountTax, amountTotal float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, '/'), COALESCE(move_type, 'out_invoice'),
|
||||
invoice_date::text, invoice_date_due::text,
|
||||
COALESCE(partner_id, 0), COALESCE(company_id, 0),
|
||||
COALESCE(amount_untaxed::float8, 0), COALESCE(amount_tax::float8, 0),
|
||||
COALESCE(amount_total::float8, 0)
|
||||
FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&name, &moveType, &invoiceDate, &dueDate, &partnerID, &companyID,
|
||||
&amountUntaxed, &amountTax, &amountTotal)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("account: read move for XML: %w", err)
|
||||
}
|
||||
|
||||
// Determine invoice type code (UBL standard)
|
||||
typeCode := "380" // Commercial Invoice
|
||||
switch moveType {
|
||||
case "out_refund", "in_refund":
|
||||
typeCode = "381" // Credit Note
|
||||
case "out_receipt", "in_receipt":
|
||||
typeCode = "325" // Receipt
|
||||
}
|
||||
|
||||
// Read supplier (company)
|
||||
var companyName string
|
||||
var companyStreet, companyCity, companyZip, companyCountry *string
|
||||
var companyVat *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(c.name, ''), p.street, p.city, p.zip,
|
||||
co.code, c.vat
|
||||
FROM res_company c
|
||||
LEFT JOIN res_partner p ON p.id = c.partner_id
|
||||
LEFT JOIN res_country co ON co.id = p.country_id
|
||||
WHERE c.id = $1`, companyID,
|
||||
).Scan(&companyName, &companyStreet, &companyCity, &companyZip, &companyCountry, &companyVat)
|
||||
|
||||
// Read customer (partner)
|
||||
var customerName string
|
||||
var customerStreet, customerCity, customerZip, customerCountry *string
|
||||
var customerVat *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(p.name, ''), p.street, p.city, p.zip,
|
||||
co.code, p.vat
|
||||
FROM res_partner p
|
||||
LEFT JOIN res_country co ON co.id = p.country_id
|
||||
WHERE p.id = $1`, partnerID,
|
||||
).Scan(&customerName, &customerStreet, &customerCity, &customerZip, &customerCountry, &customerVat)
|
||||
|
||||
// Read invoice lines
|
||||
lineRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
|
||||
COALESCE(price_subtotal::float8, 0)
|
||||
FROM account_move_line
|
||||
WHERE move_id = $1 AND (display_type IS NULL OR display_type = '' OR display_type = 'product')
|
||||
ORDER BY sequence, id`, moveID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("account: read lines for XML: %w", err)
|
||||
}
|
||||
defer lineRows.Close()
|
||||
|
||||
var ublLines []UBLLine
|
||||
lineNum := 1
|
||||
for lineRows.Next() {
|
||||
var lineName string
|
||||
var qty, price, subtotal float64
|
||||
if err := lineRows.Scan(&lineName, &qty, &price, &subtotal); err != nil {
|
||||
continue
|
||||
}
|
||||
ublLines = append(ublLines, UBLLine{
|
||||
ID: fmt.Sprintf("%d", lineNum),
|
||||
Quantity: fmt.Sprintf("%.2f", qty),
|
||||
LineAmount: fmt.Sprintf("%.2f", subtotal),
|
||||
ItemName: lineName,
|
||||
PriceAmount: fmt.Sprintf("%.2f", price),
|
||||
})
|
||||
lineNum++
|
||||
}
|
||||
|
||||
issueDateStr := time.Now().Format("2006-01-02")
|
||||
if invoiceDate != nil && *invoiceDate != "" {
|
||||
issueDateStr = *invoiceDate
|
||||
}
|
||||
dueDateStr := ""
|
||||
if dueDate != nil {
|
||||
dueDateStr = *dueDate
|
||||
}
|
||||
|
||||
invoice := UBLInvoice{
|
||||
XMLNS: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
|
||||
XMLNSCAC: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
|
||||
XMLNSCBC: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
|
||||
UBLVersionID: "2.1",
|
||||
CustomizationID: "urn:cen.eu:en16931:2017",
|
||||
ID: name,
|
||||
IssueDate: issueDateStr,
|
||||
DueDate: dueDateStr,
|
||||
InvoiceTypeCode: typeCode,
|
||||
DocumentCurrencyCode: getCurrencyCode(env, moveID),
|
||||
Supplier: UBLParty{
|
||||
Name: companyName,
|
||||
Street: ptrStr(companyStreet),
|
||||
City: ptrStr(companyCity),
|
||||
Zip: ptrStr(companyZip),
|
||||
Country: ptrStr(companyCountry),
|
||||
TaxID: ptrStr(companyVat),
|
||||
},
|
||||
Customer: UBLParty{
|
||||
Name: customerName,
|
||||
Street: ptrStr(customerStreet),
|
||||
City: ptrStr(customerCity),
|
||||
Zip: ptrStr(customerZip),
|
||||
Country: ptrStr(customerCountry),
|
||||
TaxID: ptrStr(customerVat),
|
||||
},
|
||||
TaxTotal: UBLTaxTotal{
|
||||
TaxAmount: fmt.Sprintf("%.2f", amountTax),
|
||||
},
|
||||
LegalMonetaryTotal: UBLMonetary{
|
||||
LineExtensionAmount: fmt.Sprintf("%.2f", amountUntaxed),
|
||||
TaxExclusiveAmount: fmt.Sprintf("%.2f", amountUntaxed),
|
||||
TaxInclusiveAmount: fmt.Sprintf("%.2f", amountTotal),
|
||||
PayableAmount: fmt.Sprintf("%.2f", amountTotal),
|
||||
},
|
||||
InvoiceLines: ublLines,
|
||||
}
|
||||
|
||||
output, err := xml.MarshalIndent(invoice, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("account: marshal XML: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.Write(output)
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// getCurrencyCode returns the ISO currency code for an invoice, defaulting to "EUR".
|
||||
func getCurrencyCode(env *orm.Environment, moveID int64) string {
|
||||
var code string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(c.name, 'EUR') FROM account_move m
|
||||
JOIN res_currency c ON c.id = m.currency_id
|
||||
WHERE m.id = $1`, moveID).Scan(&code)
|
||||
if code == "" {
|
||||
return "EUR"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// ptrStr safely dereferences a *string, returning "" if nil.
|
||||
func ptrStr(s *string) string {
|
||||
if s != nil {
|
||||
return *s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
319
addons/account/models/account_followup.go
Normal file
319
addons/account/models/account_followup.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountFollowup registers payment follow-up models.
|
||||
// Mirrors: odoo/addons/account_followup/models/account_followup.py
|
||||
//
|
||||
// Follow-up levels define escalation steps for overdue payments:
|
||||
// Level 1: Friendly reminder after X days
|
||||
// Level 2: Formal notice after X days
|
||||
// Level 3: Final warning / legal action after X days
|
||||
func initAccountFollowup() {
|
||||
// -- Follow-up Level --
|
||||
fl := orm.NewModel("account.followup.line", orm.ModelOpts{
|
||||
Description: "Follow-up Criteria",
|
||||
Order: "delay",
|
||||
})
|
||||
|
||||
fl.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Follow-Up Action", Required: true, Translate: true}),
|
||||
orm.Integer("delay", orm.FieldOpts{
|
||||
String: "Due Days",
|
||||
Required: true,
|
||||
Help: "Number of days after the due date of the invoice to trigger this action",
|
||||
}),
|
||||
orm.Text("description", orm.FieldOpts{
|
||||
String: "Printed Message",
|
||||
Translate: true,
|
||||
Help: "Message printed on the follow-up report",
|
||||
}),
|
||||
orm.Text("email_body", orm.FieldOpts{
|
||||
String: "Email Body",
|
||||
Translate: true,
|
||||
Help: "Email body for the follow-up email",
|
||||
}),
|
||||
orm.Char("email_subject", orm.FieldOpts{
|
||||
String: "Email Subject",
|
||||
Translate: true,
|
||||
}),
|
||||
orm.Boolean("send_email", orm.FieldOpts{
|
||||
String: "Send Email",
|
||||
Default: true,
|
||||
}),
|
||||
orm.Boolean("send_letter", orm.FieldOpts{
|
||||
String: "Send Letter",
|
||||
Default: false,
|
||||
}),
|
||||
orm.Boolean("manual_action", orm.FieldOpts{
|
||||
String: "Manual Action",
|
||||
Help: "Assign a manual action to be done",
|
||||
}),
|
||||
orm.Text("manual_action_note", orm.FieldOpts{
|
||||
String: "Action To Do",
|
||||
Help: "Description of the manual action to be taken",
|
||||
}),
|
||||
orm.Many2one("manual_action_responsible_id", "res.users", orm.FieldOpts{
|
||||
String: "Assign a Responsible",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company", Required: true,
|
||||
}),
|
||||
orm.Selection("sms_template", []orm.SelectionItem{
|
||||
{Value: "none", Label: "None"},
|
||||
{Value: "default", Label: "Default"},
|
||||
}, orm.FieldOpts{String: "SMS Template", Default: "none"}),
|
||||
orm.Boolean("join_invoices", orm.FieldOpts{
|
||||
String: "Attach Invoices",
|
||||
Default: false,
|
||||
Help: "Attach open invoice PDFs to the follow-up email",
|
||||
}),
|
||||
orm.Selection("auto_execute", []orm.SelectionItem{
|
||||
{Value: "auto", Label: "Automatic"},
|
||||
{Value: "manual", Label: "Manual"},
|
||||
}, orm.FieldOpts{String: "Execution", Default: "manual"}),
|
||||
)
|
||||
|
||||
// -- Follow-up Report (partner-level) --
|
||||
// Extends res.partner with follow-up state tracking.
|
||||
initFollowupPartnerExtension()
|
||||
|
||||
// -- Follow-up processing method on partner --
|
||||
initFollowupProcess()
|
||||
}
|
||||
|
||||
// initFollowupPartnerExtension adds follow-up tracking fields to res.partner.
|
||||
func initFollowupPartnerExtension() {
|
||||
ext := orm.ExtendModel("res.partner")
|
||||
ext.AddFields(
|
||||
orm.Selection("followup_status", []orm.SelectionItem{
|
||||
{Value: "no_action_needed", Label: "No Action Needed"},
|
||||
{Value: "with_overdue_invoices", Label: "With Overdue Invoices"},
|
||||
{Value: "in_need_of_action", Label: "In Need of Action"},
|
||||
}, orm.FieldOpts{String: "Follow-up Status", Compute: "_compute_followup_status"}),
|
||||
orm.Many2one("followup_level_id", "account.followup.line", orm.FieldOpts{
|
||||
String: "Follow-up Level",
|
||||
}),
|
||||
orm.Date("followup_next_action_date", orm.FieldOpts{
|
||||
String: "Next Follow-up Date",
|
||||
}),
|
||||
orm.Text("followup_reminder", orm.FieldOpts{
|
||||
String: "Customer Follow-up Note",
|
||||
}),
|
||||
orm.Many2one("followup_responsible_id", "res.users", orm.FieldOpts{
|
||||
String: "Follow-up Responsible",
|
||||
}),
|
||||
orm.Integer("total_overdue_invoices", orm.FieldOpts{
|
||||
String: "Total Overdue Invoices", Compute: "_compute_total_overdue",
|
||||
}),
|
||||
orm.Monetary("total_overdue_amount", orm.FieldOpts{
|
||||
String: "Total Overdue Amount", Compute: "_compute_total_overdue",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_followup_status
|
||||
ext.RegisterCompute("followup_status", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
partnerID := rs.IDs()[0]
|
||||
|
||||
var overdueCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move m
|
||||
WHERE m.partner_id = $1 AND m.state = 'posted'
|
||||
AND m.move_type IN ('out_invoice', 'out_receipt')
|
||||
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
|
||||
AND m.invoice_date_due < CURRENT_DATE`, partnerID,
|
||||
).Scan(&overdueCount)
|
||||
|
||||
status := "no_action_needed"
|
||||
if overdueCount > 0 {
|
||||
// Check if there's a follow-up level set
|
||||
var levelID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT followup_level_id FROM res_partner WHERE id = $1`, partnerID,
|
||||
).Scan(&levelID)
|
||||
|
||||
if levelID != nil && *levelID > 0 {
|
||||
status = "in_need_of_action"
|
||||
} else {
|
||||
status = "with_overdue_invoices"
|
||||
}
|
||||
}
|
||||
|
||||
return orm.Values{"followup_status": status}, nil
|
||||
})
|
||||
|
||||
// _compute_total_overdue: computes both count and amount of overdue invoices
|
||||
computeOverdue := func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
partnerID := rs.IDs()[0]
|
||||
|
||||
var count int
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*), COALESCE(SUM(amount_residual::float8), 0)
|
||||
FROM account_move
|
||||
WHERE partner_id = $1 AND state = 'posted'
|
||||
AND move_type IN ('out_invoice', 'out_receipt')
|
||||
AND payment_state NOT IN ('paid', 'in_payment', 'reversed')
|
||||
AND invoice_date_due < CURRENT_DATE`, partnerID,
|
||||
).Scan(&count, &total)
|
||||
|
||||
return orm.Values{
|
||||
"total_overdue_invoices": count,
|
||||
"total_overdue_amount": total,
|
||||
}, nil
|
||||
}
|
||||
ext.RegisterCompute("total_overdue_invoices", computeOverdue)
|
||||
ext.RegisterCompute("total_overdue_amount", computeOverdue)
|
||||
}
|
||||
|
||||
// initFollowupProcess registers the follow-up processing methods.
|
||||
func initFollowupProcess() {
|
||||
ext := orm.ExtendModel("res.partner")
|
||||
|
||||
// action_execute_followup: run follow-up for the partner.
|
||||
// Mirrors: odoo/addons/account_followup/models/res_partner.py execute_followup()
|
||||
ext.RegisterMethod("action_execute_followup", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, partnerID := range rs.IDs() {
|
||||
// Find overdue invoices for this partner
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.name, m.invoice_date_due, m.amount_residual::float8,
|
||||
CURRENT_DATE - m.invoice_date_due as overdue_days
|
||||
FROM account_move m
|
||||
WHERE m.partner_id = $1 AND m.state = 'posted'
|
||||
AND m.move_type IN ('out_invoice', 'out_receipt')
|
||||
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
|
||||
AND m.invoice_date_due < CURRENT_DATE
|
||||
ORDER BY m.invoice_date_due`, partnerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: query overdue invoices for partner %d: %w", partnerID, err)
|
||||
}
|
||||
|
||||
var maxOverdueDays int
|
||||
var overdueInvoiceIDs []int64
|
||||
for rows.Next() {
|
||||
var invID int64
|
||||
var invName string
|
||||
var dueDate interface{}
|
||||
var residual float64
|
||||
var overdueDays int
|
||||
if err := rows.Scan(&invID, &invName, &dueDate, &residual, &overdueDays); err != nil {
|
||||
continue
|
||||
}
|
||||
overdueInvoiceIDs = append(overdueInvoiceIDs, invID)
|
||||
if overdueDays > maxOverdueDays {
|
||||
maxOverdueDays = overdueDays
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(overdueInvoiceIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the appropriate follow-up level based on overdue days
|
||||
var companyID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(company_id, 1) FROM res_partner WHERE id = $1`, partnerID,
|
||||
).Scan(&companyID)
|
||||
|
||||
var levelID *int64
|
||||
var levelName *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id, name FROM account_followup_line
|
||||
WHERE company_id = $1 AND delay <= $2
|
||||
ORDER BY delay DESC LIMIT 1`, companyID, maxOverdueDays,
|
||||
).Scan(&levelID, &levelName)
|
||||
|
||||
if levelID != nil {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE res_partner SET followup_level_id = $1,
|
||||
followup_next_action_date = CURRENT_DATE + INTERVAL '14 days'
|
||||
WHERE id = $2`, *levelID, partnerID)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// get_followup_html: generate HTML report of overdue invoices for a partner.
|
||||
// Mirrors: odoo/addons/account_followup/models/res_partner.py get_followup_html()
|
||||
ext.RegisterMethod("get_followup_html", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
partnerID := rs.IDs()[0]
|
||||
|
||||
var partnerName string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, 'Unknown') FROM res_partner WHERE id = $1`, partnerID,
|
||||
).Scan(&partnerName)
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.name, m.invoice_date_due, m.amount_residual::float8,
|
||||
m.amount_total::float8, CURRENT_DATE - m.invoice_date_due as overdue_days
|
||||
FROM account_move m
|
||||
WHERE m.partner_id = $1 AND m.state = 'posted'
|
||||
AND m.move_type IN ('out_invoice', 'out_receipt')
|
||||
AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed')
|
||||
ORDER BY m.invoice_date_due`, partnerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("account: query invoices for followup: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<style>
|
||||
body{font-family:Arial;margin:20px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:12px}
|
||||
th,td{border:1px solid #ddd;padding:6px 8px;text-align:right}
|
||||
th{background:#875a7b;color:white}
|
||||
td:first-child,th:first-child{text-align:left}
|
||||
.overdue{color:#d9534f;font-weight:bold}
|
||||
h2{color:#875a7b}
|
||||
</style>`)
|
||||
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", html.EscapeString(partnerName)))
|
||||
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
|
||||
|
||||
var totalDue float64
|
||||
for rows.Next() {
|
||||
var invName string
|
||||
var dueDate interface{}
|
||||
var residual, total float64
|
||||
var overdueDays int
|
||||
if err := rows.Scan(&invName, &dueDate, &residual, &total, &overdueDays); err != nil {
|
||||
continue
|
||||
}
|
||||
totalDue += residual
|
||||
|
||||
overdueClass := ""
|
||||
if overdueDays > 30 {
|
||||
overdueClass = ` class="overdue"`
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%v</td><td>%.2f</td><td>%.2f</td><td%s>%d</td></tr>`,
|
||||
invName, dueDate, total, residual, overdueClass, overdueDays))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(`<tr style="font-weight:bold;background:#f5f5f5"><td colspan="3">Total Due</td><td>%.2f</td><td></td></tr>`, totalDue))
|
||||
b.WriteString("</table>")
|
||||
|
||||
// Add follow-up message if a level is set
|
||||
var description *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT fl.description FROM res_partner p
|
||||
JOIN account_followup_line fl ON fl.id = p.followup_level_id
|
||||
WHERE p.id = $1`, partnerID,
|
||||
).Scan(&description)
|
||||
|
||||
if description != nil && *description != "" {
|
||||
b.WriteString(fmt.Sprintf(`<div style="margin-top:20px;padding:10px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px">%s</div>`, *description))
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
})
|
||||
}
|
||||
299
addons/account/models/account_lock.go
Normal file
299
addons/account/models/account_lock.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountLock registers entry locking and hash chain integrity features.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py (hash chain features)
|
||||
//
|
||||
// France, Belgium and other countries require an immutable audit trail.
|
||||
// Posted entries are hashed in sequence; any tampering breaks the chain.
|
||||
func initAccountLock() {
|
||||
ext := orm.ExtendModel("account.move")
|
||||
|
||||
ext.AddFields(
|
||||
orm.Char("inalterable_hash", orm.FieldOpts{
|
||||
String: "Inalterability Hash",
|
||||
Readonly: true,
|
||||
Help: "Secure hash for preventing tampering with posted entries",
|
||||
}),
|
||||
orm.Char("secure_sequence_number", orm.FieldOpts{
|
||||
String: "Inalterability No.",
|
||||
Readonly: true,
|
||||
}),
|
||||
orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{
|
||||
String: "Lock with Hash",
|
||||
Related: "journal_id.restrict_mode_hash_table",
|
||||
}),
|
||||
orm.Char("string_to_hash", orm.FieldOpts{
|
||||
String: "Data to Hash",
|
||||
Compute: "_compute_string_to_hash",
|
||||
Help: "Concatenation of fields used for hashing",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_string_to_hash: generates the string representation of the move
|
||||
// used for hash computation. Includes date, journal, partner, amounts, and company VAT.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash()
|
||||
//
|
||||
// The company VAT is included to ensure entries from different legal entities
|
||||
// produce distinct hashes even when all other fields match.
|
||||
ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
moveID := rs.IDs()[0]
|
||||
|
||||
var name, moveType, state string
|
||||
var date interface{}
|
||||
var companyID, journalID int64
|
||||
var partnerID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, '/'), COALESCE(move_type, 'entry'), COALESCE(state, 'draft'),
|
||||
date, COALESCE(company_id, 0), COALESCE(journal_id, 0), partner_id
|
||||
FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID)
|
||||
|
||||
// Fetch company VAT for inclusion in the hash
|
||||
var companyVAT string
|
||||
if companyID > 0 {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(p.vat, '')
|
||||
FROM res_company c
|
||||
LEFT JOIN res_partner p ON p.id = c.partner_id
|
||||
WHERE c.id = $1`, companyID,
|
||||
).Scan(&companyVAT)
|
||||
}
|
||||
|
||||
// Include line amounts
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
|
||||
COALESCE(name, '')
|
||||
FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID)
|
||||
if err != nil {
|
||||
return orm.Values{"string_to_hash": ""}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lineData []string
|
||||
for rows.Next() {
|
||||
var accID int64
|
||||
var debit, credit float64
|
||||
var label string
|
||||
rows.Scan(&accID, &debit, &credit, &label)
|
||||
lineData = append(lineData, fmt.Sprintf("%d|%.2f|%.2f|%s", accID, debit, credit, label))
|
||||
}
|
||||
|
||||
pid := int64(0)
|
||||
if partnerID != nil {
|
||||
pid = *partnerID
|
||||
}
|
||||
|
||||
hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s|%s",
|
||||
name, moveType, date, companyID, journalID, pid,
|
||||
strings.Join(lineData, ";"), companyVAT)
|
||||
|
||||
return orm.Values{"string_to_hash": hashStr}, nil
|
||||
})
|
||||
|
||||
// action_hash_entry: computes and stores the hash for a posted entry.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _hash_move()
|
||||
ext.RegisterMethod("action_hash_entry", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, moveID := range rs.IDs() {
|
||||
var state string
|
||||
var journalID int64
|
||||
var restrictHash bool
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(m.state, 'draft'), COALESCE(m.journal_id, 0),
|
||||
COALESCE(j.restrict_mode_hash_table, false)
|
||||
FROM account_move m
|
||||
LEFT JOIN account_journal j ON j.id = m.journal_id
|
||||
WHERE m.id = $1`, moveID,
|
||||
).Scan(&state, &journalID, &restrictHash)
|
||||
|
||||
if state != "posted" || !restrictHash {
|
||||
continue
|
||||
}
|
||||
|
||||
// Already hashed?
|
||||
var existingHash *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT inalterable_hash FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&existingHash)
|
||||
if existingHash != nil && *existingHash != "" {
|
||||
continue // already hashed
|
||||
}
|
||||
|
||||
// Get the string to hash
|
||||
var stringToHash string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' ||
|
||||
COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0')
|
||||
FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&stringToHash)
|
||||
|
||||
// Include lines
|
||||
lineRows, _ := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0)
|
||||
FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID)
|
||||
if lineRows != nil {
|
||||
for lineRows.Next() {
|
||||
var accID int64
|
||||
var debit, credit float64
|
||||
lineRows.Scan(&accID, &debit, &credit)
|
||||
stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit)
|
||||
}
|
||||
lineRows.Close()
|
||||
}
|
||||
|
||||
// Get previous hash in the chain
|
||||
var prevHash string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(inalterable_hash, '')
|
||||
FROM account_move
|
||||
WHERE journal_id = $1 AND state = 'posted' AND inalterable_hash IS NOT NULL
|
||||
AND inalterable_hash != '' AND id < $2
|
||||
ORDER BY secure_sequence_number DESC, id DESC LIMIT 1`,
|
||||
journalID, moveID,
|
||||
).Scan(&prevHash)
|
||||
|
||||
// Compute hash: SHA-256 of (previous_hash + string_to_hash)
|
||||
hashInput := prevHash + stringToHash
|
||||
hash := sha256.Sum256([]byte(hashInput))
|
||||
hashHex := fmt.Sprintf("%x", hash)
|
||||
|
||||
// Get next secure sequence number
|
||||
var nextSeq int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(MAX(CAST(secure_sequence_number AS INTEGER)), 0) + 1
|
||||
FROM account_move WHERE journal_id = $1 AND secure_sequence_number IS NOT NULL`,
|
||||
journalID,
|
||||
).Scan(&nextSeq)
|
||||
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move SET inalterable_hash = $1, secure_sequence_number = $2 WHERE id = $3`,
|
||||
hashHex, fmt.Sprintf("%d", nextSeq), moveID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_check_hash_integrity: verify the hash chain for a journal.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _check_hash_integrity()
|
||||
ext.RegisterMethod("action_check_hash_integrity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
moveID := rs.IDs()[0]
|
||||
|
||||
var journalID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&journalID)
|
||||
|
||||
if journalID == 0 {
|
||||
return nil, fmt.Errorf("account: no journal found for move %d", moveID)
|
||||
}
|
||||
|
||||
// Read all hashed entries for this journal in order
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, COALESCE(inalterable_hash, ''),
|
||||
COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' ||
|
||||
COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0')
|
||||
FROM account_move
|
||||
WHERE journal_id = $1 AND state = 'posted'
|
||||
AND inalterable_hash IS NOT NULL AND inalterable_hash != ''
|
||||
ORDER BY CAST(secure_sequence_number AS INTEGER), id`, journalID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: query hashed entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
prevHash := ""
|
||||
entryCount := 0
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var storedHash, baseStr string
|
||||
if err := rows.Scan(&id, &storedHash, &baseStr); err != nil {
|
||||
return nil, fmt.Errorf("account: scan hash entry: %w", err)
|
||||
}
|
||||
|
||||
// Include lines for this entry
|
||||
lineRows, _ := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0)
|
||||
FROM account_move_line WHERE move_id = $1 ORDER BY id`, id)
|
||||
stringToHash := baseStr
|
||||
if lineRows != nil {
|
||||
for lineRows.Next() {
|
||||
var accID int64
|
||||
var debit, credit float64
|
||||
lineRows.Scan(&accID, &debit, &credit)
|
||||
stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit)
|
||||
}
|
||||
lineRows.Close()
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
hashInput := prevHash + stringToHash
|
||||
expectedHash := sha256.Sum256([]byte(hashInput))
|
||||
expectedHex := fmt.Sprintf("%x", expectedHash)
|
||||
|
||||
if storedHash != expectedHex {
|
||||
return map[string]interface{}{
|
||||
"status": "corrupted",
|
||||
"message": fmt.Sprintf("Hash chain broken at entry %d", id),
|
||||
"entries_checked": entryCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
prevHash = storedHash
|
||||
entryCount++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "valid",
|
||||
"message": "All entries verified successfully",
|
||||
"entries_checked": entryCount,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initAccountSequence registers sequence generation helpers for accounting.
|
||||
// Mirrors: odoo/addons/account/models/sequence_mixin.py
|
||||
func initAccountSequence() {
|
||||
// account.sequence.mixin fields on account.move (already mostly handled via sequence_prefix/number)
|
||||
// This extends with the date-range based sequences
|
||||
ext := orm.ExtendModel("account.move")
|
||||
ext.AddFields(
|
||||
orm.Char("highest_name", orm.FieldOpts{
|
||||
String: "Highest Name",
|
||||
Compute: "_compute_highest_name",
|
||||
Help: "Technical: highest sequence name in the same journal for ordering",
|
||||
}),
|
||||
orm.Boolean("made_sequence_hole", orm.FieldOpts{
|
||||
String: "Sequence Hole",
|
||||
Help: "Technical: whether this entry created a gap in the sequence",
|
||||
Default: false,
|
||||
}),
|
||||
)
|
||||
|
||||
ext.RegisterCompute("highest_name", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
moveID := rs.IDs()[0]
|
||||
|
||||
var journalID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&journalID)
|
||||
|
||||
var highest string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(MAX(name), '/') FROM account_move
|
||||
WHERE journal_id = $1 AND name != '/' AND state = 'posted'`,
|
||||
journalID,
|
||||
).Scan(&highest)
|
||||
|
||||
return orm.Values{"highest_name": highest}, nil
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
58
addons/account/models/account_payment_method.go
Normal file
58
addons/account/models/account_payment_method.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initAccountPaymentMethod registers account.payment.method and account.payment.method.line.
|
||||
// Mirrors: odoo/addons/account/models/account_payment_method.py
|
||||
//
|
||||
// account.payment.method defines how payments are processed (e.g. manual, check, electronic).
|
||||
// account.payment.method.line links a payment method to a journal with a specific sequence.
|
||||
func initAccountPaymentMethod() {
|
||||
m := orm.NewModel("account.payment.method", orm.ModelOpts{
|
||||
Description: "Payment Method",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||
orm.Selection("payment_type", []orm.SelectionItem{
|
||||
{Value: "inbound", Label: "Inbound"},
|
||||
{Value: "outbound", Label: "Outbound"},
|
||||
}, orm.FieldOpts{String: "Payment Type", Required: true}),
|
||||
)
|
||||
|
||||
// -- Payment Method Line --
|
||||
// Links a payment method to a journal, controlling which methods are available per journal.
|
||||
// Mirrors: odoo/addons/account/models/account_payment_method.py AccountPaymentMethodLine
|
||||
pml := orm.NewModel("account.payment.method.line", orm.ModelOpts{
|
||||
Description: "Payment Method Line",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
pml.AddFields(
|
||||
orm.Many2one("payment_method_id", "account.payment.method", orm.FieldOpts{
|
||||
String: "Payment Method", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
|
||||
String: "Journal", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Char("name", orm.FieldOpts{
|
||||
String: "Name", Related: "payment_method_id.name",
|
||||
}),
|
||||
orm.Char("code", orm.FieldOpts{
|
||||
String: "Code", Related: "payment_method_id.code",
|
||||
}),
|
||||
orm.Selection("payment_type", []orm.SelectionItem{
|
||||
{Value: "inbound", Label: "Inbound"},
|
||||
{Value: "outbound", Label: "Outbound"},
|
||||
}, orm.FieldOpts{String: "Payment Type", Related: "payment_method_id.payment_type"}),
|
||||
orm.Many2one("payment_account_id", "account.account", orm.FieldOpts{
|
||||
String: "Outstanding Receipts/Payments Account",
|
||||
Help: "Account used for outstanding payments/receipts with this method",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company", Related: "journal_id.company_id",
|
||||
}),
|
||||
)
|
||||
}
|
||||
369
addons/account/models/account_reconcile_model.go
Normal file
369
addons/account/models/account_reconcile_model.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountReconcileModel registers account.reconcile.model — automatic reconciliation rules.
|
||||
// Mirrors: odoo/addons/account/models/account_reconcile_model.py
|
||||
//
|
||||
// Reconcile models define rules that automatically match bank statement lines
|
||||
// with open invoices or create write-off entries. Three rule types:
|
||||
// - "writeoff_button": manual write-off via button
|
||||
// - "writeoff_suggestion": auto-suggest write-off in bank reconciliation
|
||||
// - "invoice_matching": auto-match with open invoices based on criteria
|
||||
func initAccountReconcileModel() {
|
||||
m := orm.NewModel("account.reconcile.model", orm.ModelOpts{
|
||||
Description: "Reconcile Model",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
||||
orm.Selection("rule_type", []orm.SelectionItem{
|
||||
{Value: "writeoff_button", Label: "Button to generate counterpart entry"},
|
||||
{Value: "writeoff_suggestion", Label: "Rule to suggest counterpart entry"},
|
||||
{Value: "invoice_matching", Label: "Rule to match invoices/bills"},
|
||||
}, orm.FieldOpts{String: "Type", Default: "writeoff_button", Required: true}),
|
||||
orm.Boolean("auto_reconcile", orm.FieldOpts{
|
||||
String: "Auto-validate",
|
||||
Help: "Validate the statement line automatically if the matched amount is close enough",
|
||||
}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
// Matching criteria
|
||||
orm.Boolean("match_nature", orm.FieldOpts{String: "Amount Nature", Default: true}),
|
||||
orm.Selection("match_amount", []orm.SelectionItem{
|
||||
{Value: "lower", Label: "Is Lower Than"},
|
||||
{Value: "greater", Label: "Is Greater Than"},
|
||||
{Value: "between", Label: "Is Between"},
|
||||
}, orm.FieldOpts{String: "Amount Condition"}),
|
||||
orm.Float("match_amount_min", orm.FieldOpts{String: "Amount Min"}),
|
||||
orm.Float("match_amount_max", orm.FieldOpts{String: "Amount Max"}),
|
||||
orm.Char("match_label", orm.FieldOpts{
|
||||
String: "Label", Help: "Regex pattern to match the bank statement line label",
|
||||
}),
|
||||
orm.Selection("match_label_param", []orm.SelectionItem{
|
||||
{Value: "contains", Label: "Contains"},
|
||||
{Value: "not_contains", Label: "Not Contains"},
|
||||
{Value: "match_regex", Label: "Match Regex"},
|
||||
}, orm.FieldOpts{String: "Label Parameter", Default: "contains"}),
|
||||
orm.Char("match_note", orm.FieldOpts{String: "Note", Help: "Match on the statement line notes"}),
|
||||
orm.Selection("match_note_param", []orm.SelectionItem{
|
||||
{Value: "contains", Label: "Contains"},
|
||||
{Value: "not_contains", Label: "Not Contains"},
|
||||
{Value: "match_regex", Label: "Match Regex"},
|
||||
}, orm.FieldOpts{String: "Note Parameter", Default: "contains"}),
|
||||
orm.Many2many("match_journal_ids", "account.journal", orm.FieldOpts{
|
||||
String: "Journals",
|
||||
Relation: "reconcile_model_journal_rel",
|
||||
Column1: "model_id",
|
||||
Column2: "journal_id",
|
||||
}),
|
||||
orm.Many2many("match_partner_ids", "res.partner", orm.FieldOpts{
|
||||
String: "Partners",
|
||||
Relation: "reconcile_model_partner_rel",
|
||||
Column1: "model_id",
|
||||
Column2: "partner_id",
|
||||
}),
|
||||
orm.Selection("match_partner_category_id", []orm.SelectionItem{
|
||||
{Value: "customer", Label: "Customer"},
|
||||
{Value: "supplier", Label: "Vendor"},
|
||||
}, orm.FieldOpts{String: "Partner Category"}),
|
||||
orm.Float("match_total_amount_param", orm.FieldOpts{
|
||||
String: "Amount matching %",
|
||||
Default: 100,
|
||||
Help: "Percentage of the transaction amount to consider for matching",
|
||||
}),
|
||||
orm.Boolean("match_same_currency", orm.FieldOpts{
|
||||
String: "Same Currency", Default: true,
|
||||
}),
|
||||
orm.Integer("past_months_limit", orm.FieldOpts{
|
||||
String: "Search Months Limit",
|
||||
Default: 18,
|
||||
Help: "Number of months in the past to consider for matching",
|
||||
}),
|
||||
orm.Float("decimal_separator", orm.FieldOpts{String: "Decimal Separator"}),
|
||||
// Write-off lines
|
||||
orm.One2many("line_ids", "account.reconcile.model.line", "model_id", orm.FieldOpts{
|
||||
String: "Write-off Lines",
|
||||
}),
|
||||
)
|
||||
|
||||
// apply_rules: try to match a bank statement line against this reconcile model.
|
||||
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_rules()
|
||||
m.RegisterMethod("apply_rules", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("account: apply_rules requires a statement line ID")
|
||||
}
|
||||
env := rs.Env()
|
||||
modelID := rs.IDs()[0]
|
||||
|
||||
stLineID, ok := toInt64Arg(args[0])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account: invalid statement line ID")
|
||||
}
|
||||
|
||||
// Read model config
|
||||
var ruleType string
|
||||
var autoReconcile bool
|
||||
var matchLabel, matchLabelParam *string
|
||||
var matchAmountMin, matchAmountMax float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(rule_type, 'writeoff_button'), COALESCE(auto_reconcile, false),
|
||||
match_label, match_label_param,
|
||||
COALESCE(match_amount_min, 0), COALESCE(match_amount_max, 0)
|
||||
FROM account_reconcile_model WHERE id = $1`, modelID,
|
||||
).Scan(&ruleType, &autoReconcile, &matchLabel, &matchLabelParam, &matchAmountMin, &matchAmountMax)
|
||||
|
||||
// Read statement line
|
||||
var stAmount float64
|
||||
var stLabel, stPaymentRef *string
|
||||
var stPartnerID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(amount::float8, 0), payment_ref, narration, partner_id
|
||||
FROM account_bank_statement_line WHERE id = $1`, stLineID,
|
||||
).Scan(&stAmount, &stPaymentRef, &stLabel, &stPartnerID)
|
||||
|
||||
// Apply label matching
|
||||
if matchLabel != nil && *matchLabel != "" && matchLabelParam != nil {
|
||||
labelToCheck := ""
|
||||
if stPaymentRef != nil {
|
||||
labelToCheck = *stPaymentRef
|
||||
}
|
||||
if stLabel != nil {
|
||||
labelToCheck += " " + *stLabel
|
||||
}
|
||||
|
||||
switch *matchLabelParam {
|
||||
case "contains":
|
||||
if !strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) {
|
||||
return map[string]interface{}{"matched": false}, nil
|
||||
}
|
||||
case "not_contains":
|
||||
if strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) {
|
||||
return map[string]interface{}{"matched": false}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch ruleType {
|
||||
case "invoice_matching":
|
||||
return applyInvoiceMatching(env, stLineID, stAmount, stPartnerID, autoReconcile)
|
||||
case "writeoff_suggestion":
|
||||
return applyWriteoffSuggestion(env, modelID, stLineID, stAmount)
|
||||
default:
|
||||
return map[string]interface{}{"matched": false, "rule_type": ruleType}, nil
|
||||
}
|
||||
})
|
||||
|
||||
// -- Reconcile Model Line (write-off definition) --
|
||||
rml := orm.NewModel("account.reconcile.model.line", orm.ModelOpts{
|
||||
Description: "Reconcile Model Line",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
rml.AddFields(
|
||||
orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{
|
||||
String: "Model", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("account_id", "account.account", orm.FieldOpts{
|
||||
String: "Account", Required: true,
|
||||
}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Char("label", orm.FieldOpts{String: "Journal Item Label"}),
|
||||
orm.Selection("amount_type", []orm.SelectionItem{
|
||||
{Value: "percentage", Label: "Percentage of balance"},
|
||||
{Value: "fixed", Label: "Fixed"},
|
||||
{Value: "percentage_st_line", Label: "Percentage of statement line"},
|
||||
{Value: "regex", Label: "From label"},
|
||||
}, orm.FieldOpts{String: "Amount Type", Default: "percentage", Required: true}),
|
||||
orm.Float("amount", orm.FieldOpts{String: "Write-off Amount", Default: 100}),
|
||||
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{
|
||||
String: "Taxes",
|
||||
Relation: "reconcile_model_line_tax_rel",
|
||||
Column1: "line_id",
|
||||
Column2: "tax_id",
|
||||
}),
|
||||
orm.Boolean("force_tax_included", orm.FieldOpts{String: "Tax Included in Price"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company", Related: "model_id.company_id",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// applyInvoiceMatching tries to find an open invoice matching the statement line amount.
|
||||
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_invoice_matching()
|
||||
func applyInvoiceMatching(env *orm.Environment, stLineID int64, amount float64, partnerID *int64, autoReconcile bool) (interface{}, error) {
|
||||
if partnerID == nil || *partnerID == 0 {
|
||||
return map[string]interface{}{"matched": false, "reason": "no partner"}, nil
|
||||
}
|
||||
|
||||
absAmount := amount
|
||||
if absAmount < 0 {
|
||||
absAmount = -absAmount
|
||||
}
|
||||
|
||||
// Find open invoices for the partner with matching residual amount
|
||||
var matchedMoveLineID int64
|
||||
var matchedResidual float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT l.id, ABS(COALESCE(l.amount_residual::float8, 0))
|
||||
FROM account_move_line l
|
||||
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||
JOIN account_account a ON a.id = l.account_id
|
||||
WHERE l.partner_id = $1
|
||||
AND a.account_type IN ('asset_receivable', 'liability_payable')
|
||||
AND ABS(COALESCE(l.amount_residual::float8, 0)) > 0.005
|
||||
AND ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) < $2 * 0.05
|
||||
ORDER BY ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2)
|
||||
LIMIT 1`, *partnerID, absAmount,
|
||||
).Scan(&matchedMoveLineID, &matchedResidual)
|
||||
|
||||
if err != nil || matchedMoveLineID == 0 {
|
||||
return map[string]interface{}{"matched": false, "reason": "no matching invoice"}, nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"matched": true,
|
||||
"move_line_id": matchedMoveLineID,
|
||||
"residual": matchedResidual,
|
||||
"auto_validate": autoReconcile,
|
||||
}
|
||||
|
||||
// If auto-reconcile, mark the statement line as reconciled
|
||||
if autoReconcile {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`,
|
||||
matchedMoveLineID, stLineID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyWriteoffSuggestion suggests a write-off entry based on the reconcile model's lines.
|
||||
// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_writeoff()
|
||||
func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amount float64) (interface{}, error) {
|
||||
// Read write-off lines for this model
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT account_id, COALESCE(label, ''), COALESCE(amount_type, 'percentage'),
|
||||
COALESCE(amount, 100)
|
||||
FROM account_reconcile_model_line
|
||||
WHERE model_id = $1
|
||||
ORDER BY sequence, id`, modelID)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"matched": false}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var suggestions []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var accountID int64
|
||||
var label, amountType string
|
||||
var pct float64
|
||||
if err := rows.Scan(&accountID, &label, &amountType, &pct); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
writeoffAmount := 0.0
|
||||
switch amountType {
|
||||
case "percentage":
|
||||
writeoffAmount = amount * pct / 100
|
||||
case "fixed":
|
||||
writeoffAmount = pct
|
||||
case "percentage_st_line":
|
||||
writeoffAmount = amount * pct / 100
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, map[string]interface{}{
|
||||
"account_id": accountID,
|
||||
"label": label,
|
||||
"amount": writeoffAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if len(suggestions) == 0 {
|
||||
return map[string]interface{}{"matched": false}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"matched": true,
|
||||
"rule_type": "writeoff_suggestion",
|
||||
"suggestions": suggestions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initAccountReconcilePreview registers account.reconcile.model.preview (Odoo 18+).
|
||||
// Transient model for previewing reconciliation results before applying.
|
||||
// Mirrors: odoo/addons/account/wizard/account_reconcile_model_preview.py
|
||||
func initAccountReconcilePreview() {
|
||||
m := orm.NewModel("account.reconcile.model.preview", orm.ModelOpts{
|
||||
Description: "Reconcile Model Preview",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{
|
||||
String: "Reconcile Model", Required: true,
|
||||
}),
|
||||
orm.Many2one("statement_line_id", "account.bank.statement.line", orm.FieldOpts{
|
||||
String: "Statement Line",
|
||||
}),
|
||||
orm.Text("preview_data", orm.FieldOpts{
|
||||
String: "Preview Data", Compute: "_compute_preview",
|
||||
}),
|
||||
)
|
||||
|
||||
m.RegisterCompute("preview_data", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
|
||||
var modelID int64
|
||||
var stLineID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(model_id, 0), statement_line_id
|
||||
FROM account_reconcile_model_preview WHERE id = $1`, id,
|
||||
).Scan(&modelID, &stLineID)
|
||||
|
||||
if modelID == 0 {
|
||||
return orm.Values{"preview_data": "[]"}, nil
|
||||
}
|
||||
|
||||
// Read reconcile model lines to preview what would be created
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT rml.label, rml.amount_type, rml.amount,
|
||||
COALESCE(a.code, ''), COALESCE(a.name, '')
|
||||
FROM account_reconcile_model_line rml
|
||||
LEFT JOIN account_account a ON a.id = rml.account_id
|
||||
WHERE rml.model_id = $1
|
||||
ORDER BY rml.sequence`, modelID)
|
||||
if err != nil {
|
||||
return orm.Values{"preview_data": "[]"}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var preview []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var label, amountType, accCode, accName string
|
||||
var amount float64
|
||||
if err := rows.Scan(&label, &amountType, &amount, &accCode, &accName); err != nil {
|
||||
continue
|
||||
}
|
||||
preview = append(preview, map[string]interface{}{
|
||||
"label": label,
|
||||
"amount_type": amountType,
|
||||
"amount": amount,
|
||||
"account_code": accCode,
|
||||
"account_name": accName,
|
||||
})
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(preview)
|
||||
return orm.Values{"preview_data": string(data)}, nil
|
||||
})
|
||||
}
|
||||
195
addons/account/models/account_recurring.go
Normal file
195
addons/account/models/account_recurring.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountRecurring registers account.move.recurring — recurring entry templates.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature)
|
||||
//
|
||||
// Allows defining templates that automatically generate journal entries
|
||||
// on a schedule (daily, weekly, monthly, quarterly, yearly).
|
||||
func initAccountRecurring() {
|
||||
m := orm.NewModel("account.move.recurring", orm.ModelOpts{
|
||||
Description: "Recurring Entry",
|
||||
Order: "name",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Selection("period", []orm.SelectionItem{
|
||||
{Value: "daily", Label: "Daily"},
|
||||
{Value: "weekly", Label: "Weekly"},
|
||||
{Value: "monthly", Label: "Monthly"},
|
||||
{Value: "quarterly", Label: "Quarterly"},
|
||||
{Value: "yearly", Label: "Yearly"},
|
||||
}, orm.FieldOpts{String: "Period", Required: true, Default: "monthly"}),
|
||||
orm.Date("date_next", orm.FieldOpts{String: "Next Date", Required: true}),
|
||||
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Many2one("template_move_id", "account.move", orm.FieldOpts{String: "Template Entry"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "running", Label: "Running"},
|
||||
{Value: "done", Label: "Done"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
)
|
||||
|
||||
// action_start: draft -> running
|
||||
m.RegisterMethod("action_start", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move_recurring SET state = 'running' WHERE id = $1 AND state = 'draft'`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_done: running -> done
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move_recurring SET state = 'done' WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_generate: create journal entries from the template and advance next date.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _cron_recurring_entries()
|
||||
m.RegisterMethod("action_generate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, recID := range rs.IDs() {
|
||||
var templateID *int64
|
||||
var dateNext, period string
|
||||
var dateEnd *string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT template_move_id, date_next::text, period, date_end::text
|
||||
FROM account_move_recurring WHERE id = $1 AND state = 'running'`, recID,
|
||||
).Scan(&templateID, &dateNext, &period, &dateEnd)
|
||||
if err != nil {
|
||||
continue // not running or not found
|
||||
}
|
||||
|
||||
if templateID == nil || *templateID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if past end date
|
||||
if dateEnd != nil && dateNext > *dateEnd {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move_recurring SET state = 'done' WHERE id = $1`, recID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Copy the template move with the next date
|
||||
templateRS := env.Model("account.move").Browse(*templateID)
|
||||
newMove, err := templateRS.Copy(orm.Values{"date": dateNext})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = newMove
|
||||
|
||||
// Advance next date based on period
|
||||
var interval string
|
||||
switch period {
|
||||
case "daily":
|
||||
interval = "1 day"
|
||||
case "weekly":
|
||||
interval = "7 days"
|
||||
case "monthly":
|
||||
interval = "1 month"
|
||||
case "quarterly":
|
||||
interval = "3 months"
|
||||
case "yearly":
|
||||
interval = "1 year"
|
||||
default:
|
||||
interval = "1 month"
|
||||
}
|
||||
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE account_move_recurring SET date_next = date_next + $1::interval WHERE id = $2`,
|
||||
interval, recID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// _cron_auto_post: cron job that auto-posts draft moves and generates recurring entries.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _cron_auto_post_draft_entry()
|
||||
//
|
||||
// 1) Find draft account.move entries with auto_post=true and date <= today, post them.
|
||||
// 2) Find recurring entries (state='running') with date_next <= today, generate them.
|
||||
m.RegisterMethod("_cron_auto_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// --- Part 1: Auto-post draft moves ---
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM account_move
|
||||
WHERE auto_post = true AND state = 'draft' AND date <= CURRENT_DATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var moveIDs []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
moveIDs = append(moveIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(moveIDs) > 0 {
|
||||
moveModelDef := orm.Registry.Get("account.move")
|
||||
if moveModelDef != nil {
|
||||
for _, mid := range moveIDs {
|
||||
moveRS := env.Model("account.move").Browse(mid)
|
||||
if postFn, ok := moveModelDef.Methods["action_post"]; ok {
|
||||
if _, err := postFn(moveRS); err != nil {
|
||||
log.Printf("account: auto-post move %d failed: %v", mid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Part 2: Generate recurring entries due today ---
|
||||
recRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM account_move_recurring
|
||||
WHERE state = 'running' AND date_next <= CURRENT_DATE AND active = true`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var recIDs []int64
|
||||
for recRows.Next() {
|
||||
var id int64
|
||||
if err := recRows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
recIDs = append(recIDs, id)
|
||||
}
|
||||
recRows.Close()
|
||||
|
||||
if len(recIDs) > 0 {
|
||||
recModelDef := orm.Registry.Get("account.move.recurring")
|
||||
if recModelDef != nil {
|
||||
for _, rid := range recIDs {
|
||||
recRS := env.Model("account.move.recurring").Browse(rid)
|
||||
if genFn, ok := recModelDef.Methods["action_generate"]; ok {
|
||||
if _, err := genFn(recRS); err != nil {
|
||||
log.Printf("account: recurring generate %d failed: %v", rid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
129
addons/account/models/account_report_html.go
Normal file
129
addons/account/models/account_report_html.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// RenderReportHTML generates an HTML table for a report type.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_report.py _get_html()
|
||||
//
|
||||
// Supports: trial_balance, balance_sheet, profit_loss, aged_receivable,
|
||||
// aged_payable, general_ledger.
|
||||
func RenderReportHTML(env *orm.Environment, reportType string) (string, error) {
|
||||
var data interface{}
|
||||
var err error
|
||||
|
||||
switch reportType {
|
||||
case "trial_balance":
|
||||
data, err = generateTrialBalance(env)
|
||||
case "balance_sheet":
|
||||
data, err = generateBalanceSheet(env)
|
||||
case "profit_loss":
|
||||
data, err = generateProfitLoss(env)
|
||||
case "aged_receivable":
|
||||
data, err = generateAgedReport(env, "asset_receivable")
|
||||
case "aged_payable":
|
||||
data, err = generateAgedReport(env, "liability_payable")
|
||||
case "general_ledger":
|
||||
data, err = generateGeneralLedger(env)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown report: %s", reportType)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid report data")
|
||||
}
|
||||
lines, _ := result["lines"].([]map[string]interface{})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
body{font-family:Arial,Helvetica,sans-serif;margin:20px;color:#333}
|
||||
table{width:100%;border-collapse:collapse;margin-top:12px}
|
||||
th,td{border:1px solid #ddd;padding:6px 8px;text-align:right}
|
||||
th{background:#875a7b;color:white;font-weight:600}
|
||||
td:first-child,th:first-child{text-align:left}
|
||||
tr:last-child{font-weight:bold;background:#f5f5f5}
|
||||
tr:hover{background:#faf5f8}
|
||||
h2{color:#875a7b;margin-bottom:4px}
|
||||
.report-date{color:#888;font-size:0.85em;margin-bottom:12px}
|
||||
</style></head><body>`)
|
||||
|
||||
// Build table based on report type
|
||||
switch reportType {
|
||||
case "trial_balance":
|
||||
b.WriteString("<h2>Trial Balance</h2>")
|
||||
b.WriteString(`<table><tr><th>Code</th><th>Account</th><th>Debit</th><th>Credit</th><th>Balance</th></tr>`)
|
||||
for _, l := range lines {
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%.2f</td><td>%.2f</td><td>%.2f</td></tr>",
|
||||
l["code"], l["name"], toF(l["debit"]), toF(l["credit"]), toF(l["balance"])))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
|
||||
case "balance_sheet":
|
||||
b.WriteString("<h2>Balance Sheet</h2>")
|
||||
b.WriteString(`<table><tr><th>Section</th><th>Code</th><th>Account</th><th>Balance</th></tr>`)
|
||||
for _, l := range lines {
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td></tr>",
|
||||
l["section"], l["code"], l["name"], toF(l["balance"])))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
|
||||
case "profit_loss":
|
||||
b.WriteString("<h2>Profit & Loss</h2>")
|
||||
b.WriteString(`<table><tr><th>Section</th><th>Code</th><th>Account</th><th>Amount</th></tr>`)
|
||||
for _, l := range lines {
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td></tr>",
|
||||
l["section"], l["code"], l["name"], toF(l["balance"])))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
|
||||
case "aged_receivable", "aged_payable":
|
||||
title := "Aged Receivable"
|
||||
if reportType == "aged_payable" {
|
||||
title = "Aged Payable"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("<h2>%s</h2>", title))
|
||||
b.WriteString(`<table><tr><th>Partner</th><th>Current</th><th>1-30</th><th>31-60</th><th>61-90+</th><th>Total</th></tr>`)
|
||||
for _, l := range lines {
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%.2f</td><td>%.2f</td><td>%.2f</td><td>%.2f</td><td>%.2f</td></tr>",
|
||||
l["partner"], toF(l["current"]), toF(l["1-30"]), toF(l["31-60"]), toF(l["61-90+"]), toF(l["total"])))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
|
||||
case "general_ledger":
|
||||
b.WriteString("<h2>General Ledger</h2>")
|
||||
b.WriteString(`<table><tr><th>Account</th><th>Move</th><th>Date</th><th>Label</th><th>Debit</th><th>Credit</th></tr>`)
|
||||
for _, l := range lines {
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%v %v</td><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td><td>%.2f</td></tr>",
|
||||
l["account_code"], l["account_name"], l["move"], l["date"], l["label"],
|
||||
toF(l["debit"]), toF(l["credit"])))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
}
|
||||
|
||||
b.WriteString("</body></html>")
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// toF converts various numeric types to float64 for formatting.
|
||||
func toF(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int64:
|
||||
return float64(n)
|
||||
case int:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
450
addons/account/models/account_reports.go
Normal file
450
addons/account/models/account_reports.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initAccountTaxReport registers accounting report models.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_report.py
|
||||
func initAccountTaxReport() {
|
||||
m := orm.NewModel("account.report", orm.ModelOpts{
|
||||
Description: "Accounting Report",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
|
||||
orm.Selection("report_type", []orm.SelectionItem{
|
||||
{Value: "tax_report", Label: "Tax Report"},
|
||||
{Value: "general_ledger", Label: "General Ledger"},
|
||||
{Value: "partner_ledger", Label: "Partner Ledger"},
|
||||
{Value: "aged_receivable", Label: "Aged Receivable"},
|
||||
{Value: "aged_payable", Label: "Aged Payable"},
|
||||
{Value: "balance_sheet", Label: "Balance Sheet"},
|
||||
{Value: "profit_loss", Label: "Profit and Loss"},
|
||||
{Value: "trial_balance", Label: "Trial Balance"},
|
||||
}, orm.FieldOpts{String: "Type"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.One2many("line_ids", "account.report.line", "report_id", orm.FieldOpts{String: "Lines"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
|
||||
// Generate report data
|
||||
// Mirrors: odoo/addons/account_reports/models/account_report.py AccountReport._get_report_data()
|
||||
m.RegisterMethod("get_report_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
reportID := rs.IDs()[0]
|
||||
|
||||
var reportType string
|
||||
err := env.Tx().QueryRow(env.Ctx(), `SELECT report_type FROM account_report WHERE id = $1`, reportID).Scan(&reportType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read report type: %w", err)
|
||||
}
|
||||
|
||||
switch reportType {
|
||||
case "trial_balance":
|
||||
return generateTrialBalance(env)
|
||||
case "balance_sheet":
|
||||
return generateBalanceSheet(env)
|
||||
case "profit_loss":
|
||||
return generateProfitLoss(env)
|
||||
case "aged_receivable":
|
||||
return generateAgedReport(env, "asset_receivable")
|
||||
case "aged_payable":
|
||||
return generateAgedReport(env, "liability_payable")
|
||||
case "general_ledger":
|
||||
return generateGeneralLedger(env)
|
||||
case "tax_report":
|
||||
return generateTaxReport(env)
|
||||
default:
|
||||
return map[string]interface{}{"lines": []interface{}{}}, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// initAccountReportLine registers the report line model.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_report.py AccountReportLine
|
||||
func initAccountReportLine() {
|
||||
orm.NewModel("account.report.line", orm.ModelOpts{
|
||||
Description: "Report Line",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Many2one("report_id", "account.report", orm.FieldOpts{String: "Report", Required: true, OnDelete: orm.OnDeleteCascade}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code"}),
|
||||
orm.Char("formula", orm.FieldOpts{String: "Formula"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
|
||||
orm.Many2one("parent_id", "account.report.line", orm.FieldOpts{String: "Parent"}),
|
||||
orm.Integer("level", orm.FieldOpts{String: "Level"}),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Report generation functions --
|
||||
|
||||
// reportOpts holds optional date/state filters for report generation.
|
||||
type reportOpts struct {
|
||||
DateFrom string // YYYY-MM-DD, empty = no lower bound
|
||||
DateTo string // YYYY-MM-DD, empty = no upper bound
|
||||
TargetMove string // "posted" or "all"
|
||||
}
|
||||
|
||||
// reportStateFilter returns the SQL WHERE clause fragment for move state filtering.
|
||||
func reportStateFilter(opts reportOpts) string {
|
||||
if opts.TargetMove == "all" {
|
||||
return "(m.state IS NOT NULL OR m.id IS NULL)"
|
||||
}
|
||||
return "(m.state = 'posted' OR m.id IS NULL)"
|
||||
}
|
||||
|
||||
// reportDateFilter returns SQL WHERE clause fragment for date filtering.
|
||||
func reportDateFilter(opts reportOpts) string {
|
||||
clause := ""
|
||||
if opts.DateFrom != "" {
|
||||
clause += " AND m.date >= '" + opts.DateFrom + "'"
|
||||
}
|
||||
if opts.DateTo != "" {
|
||||
clause += " AND m.date <= '" + opts.DateTo + "'"
|
||||
}
|
||||
return clause
|
||||
}
|
||||
|
||||
// generateTrialBalance produces a trial balance report.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
|
||||
func generateTrialBalance(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||
opt := reportOpts{TargetMove: "posted"}
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||
SELECT a.code, a.name, a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||
COALESCE(SUM(l.credit), 0) as total_credit,
|
||||
COALESCE(SUM(l.balance), 0) as balance
|
||||
FROM account_account a
|
||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||
LEFT JOIN account_move m ON m.id = l.move_id
|
||||
WHERE %s %s
|
||||
GROUP BY a.id, a.code, a.name, a.account_type
|
||||
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
|
||||
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: trial balance query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var totalDebit, totalCredit float64
|
||||
for rows.Next() {
|
||||
var code, name, accType string
|
||||
var debit, credit, balance float64
|
||||
if err := rows.Scan(&code, &name, &accType, &debit, &credit, &balance); err != nil {
|
||||
return nil, fmt.Errorf("account: trial balance scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"code": code, "name": name, "account_type": accType,
|
||||
"debit": debit, "credit": credit, "balance": balance,
|
||||
})
|
||||
totalDebit += debit
|
||||
totalCredit += credit
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"code": "", "name": "TOTAL", "account_type": "",
|
||||
"debit": totalDebit, "credit": totalCredit, "balance": totalDebit - totalCredit,
|
||||
})
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// generateBalanceSheet produces assets vs liabilities+equity.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
|
||||
func generateBalanceSheet(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||
opt := reportOpts{TargetMove: "posted"}
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN a.account_type LIKE 'asset%%' THEN 'Assets'
|
||||
WHEN a.account_type LIKE 'liability%%' THEN 'Liabilities'
|
||||
WHEN a.account_type LIKE 'equity%%' THEN 'Equity'
|
||||
ELSE 'Other'
|
||||
END as section,
|
||||
a.code, a.name,
|
||||
COALESCE(SUM(l.balance), 0) as balance
|
||||
FROM account_account a
|
||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||
LEFT JOIN account_move m ON m.id = l.move_id
|
||||
WHERE %s %s
|
||||
AND (a.account_type LIKE 'asset%%' OR a.account_type LIKE 'liability%%' OR a.account_type LIKE 'equity%%')
|
||||
GROUP BY a.id, a.code, a.name, a.account_type
|
||||
HAVING COALESCE(SUM(l.balance), 0) != 0
|
||||
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: balance sheet query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var section, code, name string
|
||||
var balance float64
|
||||
if err := rows.Scan(§ion, &code, &name, &balance); err != nil {
|
||||
return nil, fmt.Errorf("account: balance sheet scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"section": section, "code": code, "name": name, "balance": balance,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// generateProfitLoss produces income vs expenses.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
|
||||
func generateProfitLoss(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||
opt := reportOpts{TargetMove: "posted"}
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN a.account_type LIKE 'income%%' THEN 'Income'
|
||||
WHEN a.account_type LIKE 'expense%%' THEN 'Expenses'
|
||||
ELSE 'Other'
|
||||
END as section,
|
||||
a.code, a.name,
|
||||
COALESCE(SUM(l.balance), 0) as balance
|
||||
FROM account_account a
|
||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||
LEFT JOIN account_move m ON m.id = l.move_id
|
||||
WHERE %s %s
|
||||
AND (a.account_type LIKE 'income%%' OR a.account_type LIKE 'expense%%')
|
||||
GROUP BY a.id, a.code, a.name, a.account_type
|
||||
HAVING COALESCE(SUM(l.balance), 0) != 0
|
||||
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: profit loss query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var totalIncome, totalExpense float64
|
||||
for rows.Next() {
|
||||
var section, code, name string
|
||||
var balance float64
|
||||
if err := rows.Scan(§ion, &code, &name, &balance); err != nil {
|
||||
return nil, fmt.Errorf("account: profit loss scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"section": section, "code": code, "name": name, "balance": balance,
|
||||
})
|
||||
if section == "Income" {
|
||||
totalIncome += balance
|
||||
}
|
||||
if section == "Expenses" {
|
||||
totalExpense += balance
|
||||
}
|
||||
}
|
||||
// income is negative (credit), expenses positive (debit)
|
||||
profit := totalIncome + totalExpense
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"section": "Result", "code": "", "name": "Net Profit/Loss", "balance": -profit,
|
||||
})
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// generateAgedReport produces aged receivable/payable.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_aged_partner_balance.py
|
||||
func generateAgedReport(env *orm.Environment, accountType string) (interface{}, error) {
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(p.name, 'Unknown') as partner,
|
||||
COALESCE(SUM(CASE WHEN m.date >= CURRENT_DATE - 30 THEN l.amount_residual ELSE 0 END), 0) as current_amount,
|
||||
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 30 AND m.date >= CURRENT_DATE - 60 THEN l.amount_residual ELSE 0 END), 0) as days_30,
|
||||
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 60 AND m.date >= CURRENT_DATE - 90 THEN l.amount_residual ELSE 0 END), 0) as days_60,
|
||||
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 90 THEN l.amount_residual ELSE 0 END), 0) as days_90_plus,
|
||||
COALESCE(SUM(l.amount_residual), 0) as total
|
||||
FROM account_move_line l
|
||||
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||
JOIN account_account a ON a.id = l.account_id AND a.account_type = $1
|
||||
LEFT JOIN res_partner p ON p.id = l.partner_id
|
||||
WHERE l.amount_residual != 0
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY total DESC`, accountType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: aged report query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var partner string
|
||||
var current, d30, d60, d90, total float64
|
||||
if err := rows.Scan(&partner, ¤t, &d30, &d60, &d90, &total); err != nil {
|
||||
return nil, fmt.Errorf("account: aged report scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"partner": partner, "current": current, "1-30": d30,
|
||||
"31-60": d60, "61-90+": d90, "total": total,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// generateGeneralLedger produces detailed journal entries per account.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_general_ledger.py
|
||||
func generateGeneralLedger(env *orm.Environment) (interface{}, error) {
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT a.code, a.name, m.name as move_name, m.date, l.name as label,
|
||||
l.debit, l.credit, l.balance
|
||||
FROM account_move_line l
|
||||
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||
JOIN account_account a ON a.id = l.account_id
|
||||
ORDER BY a.code, m.date, l.id
|
||||
LIMIT 1000`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: general ledger query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var code, name, moveName, label string
|
||||
var date interface{}
|
||||
var debit, credit, balance float64
|
||||
if err := rows.Scan(&code, &name, &moveName, &date, &label, &debit, &credit, &balance); err != nil {
|
||||
return nil, fmt.Errorf("account: general ledger scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"account_code": code, "account_name": name, "move": moveName,
|
||||
"date": date, "label": label, "debit": debit, "credit": credit, "balance": balance,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// generateTaxReport produces a tax report grouped by tax name and rate.
|
||||
// Mirrors: odoo/addons/account_reports/models/account_tax_report.py
|
||||
// Aggregates tax amounts from posted move lines with display_type='tax'.
|
||||
func generateTaxReport(env *orm.Environment) (interface{}, error) {
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(t.name, 'Undefined Tax'),
|
||||
COALESCE(t.amount, 0) AS tax_rate,
|
||||
COALESCE(SUM(ABS(l.balance::float8)), 0) AS tax_amount,
|
||||
COALESCE(SUM(ABS(l.tax_base_amount::float8)), 0) AS base_amount,
|
||||
COUNT(*) AS line_count
|
||||
FROM account_move_line l
|
||||
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||
LEFT JOIN account_tax t ON t.id = l.tax_line_id
|
||||
WHERE l.display_type = 'tax'
|
||||
GROUP BY t.name, t.amount
|
||||
ORDER BY t.name, t.amount`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: tax report query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var totalTax, totalBase float64
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var rate, taxAmount, baseAmount float64
|
||||
var lineCount int
|
||||
if err := rows.Scan(&name, &rate, &taxAmount, &baseAmount, &lineCount); err != nil {
|
||||
return nil, fmt.Errorf("account: tax report scan: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"tax_name": name,
|
||||
"tax_rate": rate,
|
||||
"tax_amount": taxAmount,
|
||||
"base_amount": baseAmount,
|
||||
"line_count": lineCount,
|
||||
})
|
||||
totalTax += taxAmount
|
||||
totalBase += baseAmount
|
||||
}
|
||||
|
||||
// Totals row
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"tax_name": "TOTAL",
|
||||
"tax_rate": 0.0,
|
||||
"tax_amount": totalTax,
|
||||
"base_amount": totalBase,
|
||||
"line_count": 0,
|
||||
})
|
||||
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Financial Report Wizard
|
||||
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// initAccountReportWizard registers a transient model that lets the user
|
||||
// choose date range, target-move filter and report type, then dispatches
|
||||
// to the appropriate generateXXX function.
|
||||
func initAccountReportWizard() {
|
||||
m := orm.NewModel("account.report.wizard", orm.ModelOpts{
|
||||
Description: "Financial Report Wizard",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
|
||||
orm.Selection("target_move", []orm.SelectionItem{
|
||||
{Value: "all", Label: "All Entries"},
|
||||
{Value: "posted", Label: "All Posted Entries"},
|
||||
}, orm.FieldOpts{String: "Target Moves", Default: "posted", Required: true}),
|
||||
orm.Selection("report_type", []orm.SelectionItem{
|
||||
{Value: "trial_balance", Label: "Trial Balance"},
|
||||
{Value: "balance_sheet", Label: "Balance Sheet"},
|
||||
{Value: "profit_loss", Label: "Profit and Loss"},
|
||||
{Value: "aged_receivable", Label: "Aged Receivable"},
|
||||
{Value: "aged_payable", Label: "Aged Payable"},
|
||||
{Value: "general_ledger", Label: "General Ledger"},
|
||||
{Value: "tax_report", Label: "Tax Report"},
|
||||
}, orm.FieldOpts{String: "Report Type", Required: true}),
|
||||
)
|
||||
|
||||
// action_generate_report dispatches to the matching report generator.
|
||||
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py action_generate_report()
|
||||
m.RegisterMethod("action_generate_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
data, err := rs.Read([]string{"date_from", "date_to", "target_move", "report_type"})
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, fmt.Errorf("account: cannot read report wizard data")
|
||||
}
|
||||
wiz := data[0]
|
||||
|
||||
reportType, _ := wiz["report_type"].(string)
|
||||
dateFrom, _ := wiz["date_from"].(string)
|
||||
dateTo, _ := wiz["date_to"].(string)
|
||||
targetMove, _ := wiz["target_move"].(string)
|
||||
if targetMove == "" {
|
||||
targetMove = "posted"
|
||||
}
|
||||
opt := reportOpts{DateFrom: dateFrom, DateTo: dateTo, TargetMove: targetMove}
|
||||
|
||||
switch reportType {
|
||||
case "trial_balance":
|
||||
return generateTrialBalance(env, opt)
|
||||
case "balance_sheet":
|
||||
return generateBalanceSheet(env, opt)
|
||||
case "profit_loss":
|
||||
return generateProfitLoss(env, opt)
|
||||
case "aged_receivable":
|
||||
return generateAgedReport(env, "asset_receivable")
|
||||
case "aged_payable":
|
||||
return generateAgedReport(env, "liability_payable")
|
||||
case "general_ledger":
|
||||
return generateGeneralLedger(env)
|
||||
case "tax_report":
|
||||
return generateTaxReport(env)
|
||||
default:
|
||||
return nil, fmt.Errorf("account: unknown report type %q", reportType)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// TaxResult holds the result of computing a tax on an amount.
|
||||
type TaxResult struct {
|
||||
@@ -13,6 +17,9 @@ type TaxResult struct {
|
||||
|
||||
// ComputeTax calculates tax for a given base amount.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount()
|
||||
//
|
||||
// Supports amount_type: percent, fixed, division, group.
|
||||
// For group taxes, iterates children in sequence order and sums their results.
|
||||
func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResult, error) {
|
||||
var name string
|
||||
var amount float64
|
||||
@@ -27,6 +34,12 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle group taxes: iterate child taxes and sum their amounts.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() group branch
|
||||
if amountType == "group" {
|
||||
return computeGroupTax(env, taxID, name, baseAmount)
|
||||
}
|
||||
|
||||
var taxAmount float64
|
||||
switch amountType {
|
||||
case "percent":
|
||||
@@ -38,14 +51,108 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
|
||||
case "fixed":
|
||||
taxAmount = amount
|
||||
case "division":
|
||||
// Division tax: price = base / (1 - rate/100)
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py _compute_amount (division case)
|
||||
if priceInclude {
|
||||
taxAmount = baseAmount - (baseAmount / (1 + amount/100))
|
||||
taxAmount = baseAmount - (baseAmount * (100 - amount) / 100)
|
||||
} else {
|
||||
taxAmount = baseAmount * amount / 100
|
||||
taxAmount = baseAmount/(1-amount/100) - baseAmount
|
||||
}
|
||||
}
|
||||
|
||||
// Find the tax account (from repartition lines)
|
||||
accountID := findTaxAccount(env, taxID)
|
||||
|
||||
return &TaxResult{
|
||||
TaxID: taxID,
|
||||
TaxName: name,
|
||||
Amount: taxAmount,
|
||||
Base: baseAmount,
|
||||
AccountID: accountID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComputeTaxes calculates multiple taxes on a base amount, returning all results.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py AccountTax.compute_all()
|
||||
func ComputeTaxes(env *orm.Environment, taxIDs []int64, baseAmount float64) ([]*TaxResult, error) {
|
||||
var results []*TaxResult
|
||||
for _, taxID := range taxIDs {
|
||||
result, err := ComputeTax(env, taxID, baseAmount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: compute tax %d: %w", taxID, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// computeGroupTax handles amount_type='group': reads child taxes and sums their amounts.
|
||||
// Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() for groups
|
||||
func computeGroupTax(env *orm.Environment, parentID int64, parentName string, baseAmount float64) (*TaxResult, error) {
|
||||
// Read child taxes via the many2many relation table
|
||||
// The ORM creates: account_tax_children_tax_ids_rel (or similar)
|
||||
// We query the children_tax_ids M2M relationship
|
||||
childRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT ct.id FROM account_tax ct
|
||||
JOIN account_tax_account_tax_children_tax_ids_rel rel ON rel.account_tax_id2 = ct.id
|
||||
WHERE rel.account_tax_id1 = $1
|
||||
ORDER BY ct.sequence, ct.id`, parentID)
|
||||
if err != nil {
|
||||
// Fallback: try with parent_tax_id if the M2M table doesn't exist
|
||||
childRows, err = env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM account_tax WHERE parent_tax_id = $1 ORDER BY sequence, id`, parentID)
|
||||
if err != nil {
|
||||
return &TaxResult{
|
||||
TaxID: parentID, TaxName: parentName, Amount: 0, Base: baseAmount,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
defer childRows.Close()
|
||||
|
||||
var totalTax float64
|
||||
var lastAccountID int64
|
||||
currentBase := baseAmount
|
||||
|
||||
for childRows.Next() {
|
||||
var childID int64
|
||||
if err := childRows.Scan(&childID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
childResult, err := ComputeTax(env, childID, currentBase)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalTax += childResult.Amount
|
||||
|
||||
if childResult.AccountID > 0 {
|
||||
lastAccountID = childResult.AccountID
|
||||
}
|
||||
|
||||
// Check if this child tax affects the base of subsequent taxes
|
||||
var includeBase bool
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(include_base_amount, false) FROM account_tax WHERE id = $1`, childID,
|
||||
).Scan(&includeBase)
|
||||
if includeBase {
|
||||
currentBase += childResult.Amount
|
||||
}
|
||||
}
|
||||
|
||||
if lastAccountID == 0 {
|
||||
lastAccountID = findTaxAccount(env, parentID)
|
||||
}
|
||||
|
||||
return &TaxResult{
|
||||
TaxID: parentID,
|
||||
TaxName: parentName,
|
||||
Amount: totalTax,
|
||||
Base: baseAmount,
|
||||
AccountID: lastAccountID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findTaxAccount looks up the account for a tax from its repartition lines.
|
||||
func findTaxAccount(env *orm.Environment, taxID int64) int64 {
|
||||
var accountID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(account_id, 0) FROM account_tax_repartition_line
|
||||
@@ -60,11 +167,5 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
|
||||
).Scan(&accountID)
|
||||
}
|
||||
|
||||
return &TaxResult{
|
||||
TaxID: taxID,
|
||||
TaxName: name,
|
||||
Amount: taxAmount,
|
||||
Base: baseAmount,
|
||||
AccountID: accountID,
|
||||
}, nil
|
||||
return accountID
|
||||
}
|
||||
|
||||
@@ -12,4 +12,28 @@ func Init() {
|
||||
initAccountReconcile()
|
||||
initAccountBankStatement()
|
||||
initAccountFiscalPosition()
|
||||
initAccountTaxReport()
|
||||
initAccountReportLine()
|
||||
initAccountAnalytic()
|
||||
initAccountRecurring()
|
||||
initAccountCompanyExtension()
|
||||
initAccountPaymentMethod()
|
||||
initAccountAsset()
|
||||
initAccountBudget()
|
||||
initAccountCashRounding()
|
||||
initAccountInvoiceSend()
|
||||
initAccountCashRoundingOnMove()
|
||||
initAccountFollowup()
|
||||
initAccountLock()
|
||||
initAccountSequence()
|
||||
initAccountEdi()
|
||||
initAccountReconcileModel()
|
||||
initAccountMoveInvoiceExtensions()
|
||||
initAccountPaymentExtensions()
|
||||
initAccountJournalExtensions()
|
||||
initAccountTaxComputes()
|
||||
initAccountReportWizard()
|
||||
initAccountMoveReversal()
|
||||
initAccountMoveTemplate()
|
||||
initAccountReconcilePreview()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initIrCron registers ir.cron — Scheduled actions.
|
||||
// Mirrors: odoo/addons/base/models/ir_cron.py class IrCron
|
||||
@@ -30,5 +34,86 @@ func initIrCron() {
|
||||
orm.Integer("priority", orm.FieldOpts{String: "Priority", Default: 5}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Python Code"}),
|
||||
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model"}),
|
||||
|
||||
// Execution target (simplified: direct model+method instead of ir.actions.server)
|
||||
orm.Char("model_name", orm.FieldOpts{String: "Model Name"}),
|
||||
orm.Char("method_name", orm.FieldOpts{String: "Method Name"}),
|
||||
|
||||
// Failure tracking
|
||||
orm.Integer("failure_count", orm.FieldOpts{String: "Failure Count", Default: 0}),
|
||||
orm.Datetime("first_failure_date", orm.FieldOpts{String: "First Failure Date"}),
|
||||
)
|
||||
|
||||
// Constraint: validate model_name and method_name against the registry.
|
||||
// Prevents setting arbitrary/invalid model+method combos on cron jobs.
|
||||
m.AddConstraint(func(rs *orm.Recordset) error {
|
||||
records, err := rs.Read([]string{"model_name", "method_name"})
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
rec := records[0]
|
||||
modelName, _ := rec["model_name"].(string)
|
||||
methodName, _ := rec["method_name"].(string)
|
||||
if modelName == "" && methodName == "" {
|
||||
return nil // both empty is OK (legacy code-based crons)
|
||||
}
|
||||
if modelName != "" {
|
||||
model := orm.Registry.Get(modelName)
|
||||
if model == nil {
|
||||
return fmt.Errorf("ir.cron: model %q not found in registry", modelName)
|
||||
}
|
||||
if methodName != "" && model.Methods != nil {
|
||||
if _, ok := model.Methods[methodName]; !ok {
|
||||
return fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// method_direct_trigger — manually trigger a cron job.
|
||||
// Mirrors: odoo/addons/base/models/ir_cron.py method_direct_trigger
|
||||
m.RegisterMethod("method_direct_trigger", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
// Admin-only: only uid=1 or superuser may trigger cron jobs directly
|
||||
env := rs.Env()
|
||||
if env.UID() != 1 && !env.IsSuperuser() {
|
||||
return nil, fmt.Errorf("ir.cron: method_direct_trigger requires admin privileges")
|
||||
}
|
||||
|
||||
records, err := rs.Read([]string{"model_name", "method_name"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ir.cron: method_direct_trigger read failed: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("ir.cron: method_direct_trigger: no record found")
|
||||
}
|
||||
|
||||
rec := records[0]
|
||||
modelName, _ := rec["model_name"].(string)
|
||||
methodName, _ := rec["method_name"].(string)
|
||||
|
||||
if modelName == "" || methodName == "" {
|
||||
return nil, fmt.Errorf("ir.cron: model_name or method_name not set")
|
||||
}
|
||||
|
||||
// Validate model_name against registry (prevents calling arbitrary models)
|
||||
model := orm.Registry.Get(modelName)
|
||||
if model == nil {
|
||||
return nil, fmt.Errorf("ir.cron: model %q not found in registry", modelName)
|
||||
}
|
||||
if model.Methods == nil {
|
||||
return nil, fmt.Errorf("ir.cron: model %q has no methods", modelName)
|
||||
}
|
||||
method, ok := model.Methods[methodName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
|
||||
}
|
||||
|
||||
result, err := method(env.Model(modelName), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ir.cron: %s.%s failed: %w", modelName, methodName, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,9 +127,28 @@ func initIrActions() {
|
||||
{Value: "object_write", Label: "Update Record"},
|
||||
{Value: "object_create", Label: "Create Record"},
|
||||
{Value: "multi", Label: "Execute Several Actions"},
|
||||
{Value: "email", Label: "Send Email"},
|
||||
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
|
||||
orm.Text("code", orm.FieldOpts{String: "Code"}),
|
||||
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
|
||||
// Automated action fields
|
||||
orm.Selection("trigger", []orm.SelectionItem{
|
||||
{Value: "on_create", Label: "On Creation"},
|
||||
{Value: "on_write", Label: "On Update"},
|
||||
{Value: "on_create_or_write", Label: "On Creation & Update"},
|
||||
{Value: "on_unlink", Label: "On Deletion"},
|
||||
{Value: "on_time", Label: "Based on Time Condition"},
|
||||
}, orm.FieldOpts{String: "Trigger"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
// object_write: fields to update
|
||||
orm.Text("update_field_id", orm.FieldOpts{String: "Field to Update"}),
|
||||
orm.Char("update_value", orm.FieldOpts{String: "Value"}),
|
||||
// email: template fields
|
||||
orm.Char("email_to", orm.FieldOpts{String: "Email To", Help: "Field name on the record (e.g. email, partner_id.email)"}),
|
||||
orm.Char("email_subject", orm.FieldOpts{String: "Email Subject"}),
|
||||
orm.Text("email_body", orm.FieldOpts{String: "Email Body", Help: "HTML body. Use {{field_name}} for record values."}),
|
||||
// filter domain
|
||||
orm.Text("filter_domain", orm.FieldOpts{String: "Filter Domain", Help: "Only trigger when record matches this domain"}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,39 @@ func initResUsers() {
|
||||
String: "Share User", Compute: "_compute_share", Store: true,
|
||||
Help: "External user with limited access (portal/public)",
|
||||
}),
|
||||
orm.Char("signup_token", orm.FieldOpts{String: "Signup Token"}),
|
||||
orm.Datetime("signup_expiration", orm.FieldOpts{String: "Signup Token Expiration"}),
|
||||
)
|
||||
|
||||
// _compute_share: portal/public users have share=true (not in group_user).
|
||||
// Mirrors: odoo/addons/base/models/res_users.py Users._compute_share()
|
||||
m.RegisterMethod("_compute_share", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
// Look up group_user ID
|
||||
var groupUserID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT g.id FROM res_groups g
|
||||
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||
WHERE imd.module = 'base' AND imd.name = 'group_user'`).Scan(&groupUserID)
|
||||
if err != nil {
|
||||
return nil, nil // Can't determine, skip
|
||||
}
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
var inGroup bool
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM res_groups_res_users_rel
|
||||
WHERE res_groups_id = $1 AND res_users_id = $2
|
||||
)`, groupUserID, id).Scan(&inGroup)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env.Model("res.users").Browse(id).Write(orm.Values{"share": !inGroup})
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
// -- Methods --
|
||||
|
||||
// action_get returns the "Change My Preferences" action for the current user.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initCRMLead registers the crm.lead model.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py
|
||||
@@ -67,73 +72,210 @@ func initCRMLead() {
|
||||
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
|
||||
)
|
||||
|
||||
// DefaultGet: set company_id from the session so that DB NOT NULL constraint is satisfied
|
||||
// Onchange: stage_id -> auto-update probability from stage.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_stage_id
|
||||
m.RegisterOnchange("stage_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||
result := make(orm.Values)
|
||||
stageID, ok := vals["stage_id"]
|
||||
if !ok || stageID == nil {
|
||||
return result
|
||||
}
|
||||
var sid float64
|
||||
switch v := stageID.(type) {
|
||||
case float64:
|
||||
sid = v
|
||||
case int64:
|
||||
sid = float64(v)
|
||||
case int:
|
||||
sid = float64(v)
|
||||
default:
|
||||
return result
|
||||
}
|
||||
if sid == 0 {
|
||||
return result
|
||||
}
|
||||
var probability float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(probability, 10) FROM crm_stage WHERE id = $1`, int64(sid),
|
||||
).Scan(&probability); err != nil {
|
||||
return result
|
||||
}
|
||||
result["probability"] = probability
|
||||
result["date_last_stage_update"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
return result
|
||||
})
|
||||
|
||||
// DefaultGet: set company_id, user_id, team_id, type from session/defaults.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py default_get
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
if env.CompanyID() > 0 {
|
||||
vals["company_id"] = env.CompanyID()
|
||||
}
|
||||
if env.UID() > 0 {
|
||||
vals["user_id"] = env.UID()
|
||||
}
|
||||
vals["type"] = "lead"
|
||||
// Try to find a default sales team for the user
|
||||
var teamID int64
|
||||
if env.UID() > 0 {
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT ct.id FROM crm_team ct
|
||||
JOIN crm_team_member ctm ON ctm.crm_team_id = ct.id
|
||||
WHERE ctm.user_id = $1 AND ct.active = true
|
||||
ORDER BY ct.sequence LIMIT 1`, env.UID()).Scan(&teamID); err != nil {
|
||||
// No team found for user — not an error, just no default
|
||||
teamID = 0
|
||||
}
|
||||
}
|
||||
if teamID > 0 {
|
||||
vals["team_id"] = teamID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// action_set_won: mark lead as won
|
||||
// action_set_won: mark lead as won, set date_closed, find won stage.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won
|
||||
m.RegisterMethod("action_set_won", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
var wonStageID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'won', probability = 100 WHERE id = $1`, id)
|
||||
var err error
|
||||
if wonStageID > 0 {
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||
date_closed = NOW(), active = true, stage_id = $2
|
||||
WHERE id = $1`, id, wonStageID)
|
||||
} else {
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||
date_closed = NOW(), active = true
|
||||
WHERE id = $1`, id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crm.lead: set_won %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_set_lost: mark lead as lost
|
||||
// action_set_lost: mark lead as lost, accept lost_reason_id from kwargs.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_lost
|
||||
m.RegisterMethod("action_set_lost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Extract lost_reason_id from kwargs if provided
|
||||
var lostReasonID int64
|
||||
if len(args) > 0 {
|
||||
if kwargs, ok := args[0].(map[string]interface{}); ok {
|
||||
if rid, ok := kwargs["lost_reason_id"]; ok {
|
||||
switch v := rid.(type) {
|
||||
case float64:
|
||||
lostReasonID = int64(v)
|
||||
case int64:
|
||||
lostReasonID = v
|
||||
case int:
|
||||
lostReasonID = int64(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'lost', probability = 0, active = false WHERE id = $1`, id)
|
||||
var err error
|
||||
if lostReasonID > 0 {
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
|
||||
active = false, date_closed = NOW(), lost_reason_id = $2
|
||||
WHERE id = $1`, id, lostReasonID)
|
||||
} else {
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
|
||||
active = false, date_closed = NOW()
|
||||
WHERE id = $1`, id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crm.lead: set_lost %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// convert_to_opportunity: lead → opportunity
|
||||
// convert_to_opportunity: lead -> opportunity, set date_conversion.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py _convert_opportunity_data
|
||||
m.RegisterMethod("convert_to_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1 AND type = 'lead'`, id)
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||
date_open = COALESCE(date_open, NOW())
|
||||
WHERE id = $1 AND type = 'lead'`, id); err != nil {
|
||||
return nil, fmt.Errorf("crm.lead: convert_to_opportunity %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// convert_opportunity: alias for convert_to_opportunity
|
||||
// convert_opportunity: convert lead to opportunity with optional partner/team assignment.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py convert_opportunity
|
||||
m.RegisterMethod("convert_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Optional partner_id from args
|
||||
var partnerID int64
|
||||
if len(args) > 0 {
|
||||
if pid, ok := args[0].(float64); ok {
|
||||
partnerID = int64(pid)
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1`, id)
|
||||
if partnerID > 0 {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||
date_open = COALESCE(date_open, NOW()), partner_id = $2
|
||||
WHERE id = $1`, id, partnerID)
|
||||
} else {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||
date_open = COALESCE(date_open, NOW())
|
||||
WHERE id = $1`, id)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_set_won_rainbowman: set won stage + rainbow effect
|
||||
// action_set_won_rainbowman: set won + rainbow effect.
|
||||
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won_rainbowman
|
||||
m.RegisterMethod("action_set_won_rainbowman", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
// Find Won stage
|
||||
// Find the first won stage
|
||||
var wonStageID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM crm_stage WHERE is_won = true LIMIT 1`).Scan(&wonStageID)
|
||||
if wonStageID == 0 {
|
||||
wonStageID = 4 // fallback
|
||||
}
|
||||
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET stage_id = $1, probability = 100 WHERE id = $2`, wonStageID, id)
|
||||
if wonStageID > 0 {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||
date_closed = NOW(), active = true, stage_id = $2
|
||||
WHERE id = $1`, id, wonStageID)
|
||||
} else {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||
date_closed = NOW(), active = true
|
||||
WHERE id = $1`, id)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"effect": map[string]interface{}{
|
||||
"type": "rainbow_man",
|
||||
"message": "Congrats, you won this opportunity!",
|
||||
"fadeout": "slow",
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
@@ -152,6 +294,11 @@ func initCRMStage() {
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
|
||||
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
|
||||
orm.Boolean("is_won", orm.FieldOpts{String: "Is Won Stage"}),
|
||||
orm.Float("probability", orm.FieldOpts{
|
||||
String: "Probability (%)",
|
||||
Help: "Default probability when a lead enters this stage.",
|
||||
Default: float64(10),
|
||||
}),
|
||||
orm.Many2many("team_ids", "crm.team", orm.FieldOpts{String: "Sales Teams"}),
|
||||
orm.Text("requirements", orm.FieldOpts{String: "Requirements"}),
|
||||
)
|
||||
|
||||
241
addons/crm/models/crm_analysis.go
Normal file
241
addons/crm/models/crm_analysis.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initCrmAnalysis registers the crm.lead.analysis transient model
|
||||
// for pipeline reporting and dashboard data.
|
||||
// Mirrors: odoo/addons/crm/report/crm_activity_report.py (simplified)
|
||||
func initCrmAnalysis() {
|
||||
m := orm.NewModel("crm.lead.analysis", orm.ModelOpts{
|
||||
Description: "Pipeline Analysis",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("team_id", "crm.team", orm.FieldOpts{
|
||||
String: "Sales Team",
|
||||
Help: "Filter analysis by sales team.",
|
||||
}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{
|
||||
String: "Salesperson",
|
||||
Help: "Filter analysis by salesperson.",
|
||||
}),
|
||||
orm.Date("date_from", orm.FieldOpts{
|
||||
String: "From",
|
||||
Help: "Start date for the analysis period.",
|
||||
}),
|
||||
orm.Date("date_to", orm.FieldOpts{
|
||||
String: "To",
|
||||
Help: "End date for the analysis period.",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company",
|
||||
Help: "Filter analysis by company.",
|
||||
}),
|
||||
)
|
||||
|
||||
// get_pipeline_data: return pipeline statistics grouped by stage.
|
||||
// Mirrors: odoo/addons/crm/report/crm_activity_report.py read_group
|
||||
m.RegisterMethod("get_pipeline_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Pipeline by stage
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT s.name, COUNT(l.id), COALESCE(SUM(l.expected_revenue::float8), 0)
|
||||
FROM crm_lead l
|
||||
JOIN crm_stage s ON s.id = l.stage_id
|
||||
WHERE l.active = true AND l.type = 'opportunity'
|
||||
GROUP BY s.id, s.name, s.sequence
|
||||
ORDER BY s.sequence`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get_pipeline_data: stages query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var count int64
|
||||
var revenue float64
|
||||
if err := rows.Scan(&name, &count, &revenue); err != nil {
|
||||
return nil, fmt.Errorf("get_pipeline_data: scan stage: %w", err)
|
||||
}
|
||||
stages = append(stages, map[string]interface{}{
|
||||
"stage": name,
|
||||
"count": count,
|
||||
"revenue": revenue,
|
||||
})
|
||||
}
|
||||
|
||||
// Win rate
|
||||
var total, won int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_won THEN 1 ELSE 0 END), 0)
|
||||
FROM crm_lead l
|
||||
JOIN crm_stage s ON s.id = l.stage_id
|
||||
WHERE l.type = 'opportunity'`,
|
||||
).Scan(&total, &won); err != nil {
|
||||
log.Printf("warning: crm win rate query failed: %v", err)
|
||||
}
|
||||
|
||||
winRate := float64(0)
|
||||
if total > 0 {
|
||||
winRate = float64(won) / float64(total) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"stages": stages,
|
||||
"total": total,
|
||||
"won": won,
|
||||
"win_rate": winRate,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_conversion_data: return lead-to-opportunity conversion statistics.
|
||||
// Mirrors: odoo/addons/crm/report/crm_activity_report.py (conversion metrics)
|
||||
m.RegisterMethod("get_conversion_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
var totalLeads, convertedLeads int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE type = 'lead'),
|
||||
COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL)
|
||||
FROM crm_lead WHERE active = true`,
|
||||
).Scan(&totalLeads, &convertedLeads); err != nil {
|
||||
log.Printf("warning: crm conversion data query failed: %v", err)
|
||||
}
|
||||
|
||||
conversionRate := float64(0)
|
||||
if totalLeads > 0 {
|
||||
conversionRate = float64(convertedLeads) / float64(totalLeads) * 100
|
||||
}
|
||||
|
||||
// Average days to convert
|
||||
var avgDaysConvert float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_conversion - create_date)) / 86400), 0)
|
||||
FROM crm_lead
|
||||
WHERE type = 'opportunity' AND date_conversion IS NOT NULL AND active = true`,
|
||||
).Scan(&avgDaysConvert); err != nil {
|
||||
log.Printf("warning: crm avg days to convert query failed: %v", err)
|
||||
}
|
||||
|
||||
// Average days to close (won)
|
||||
var avgDaysClose float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400), 0)
|
||||
FROM crm_lead
|
||||
WHERE state = 'won' AND date_closed IS NOT NULL`,
|
||||
).Scan(&avgDaysClose); err != nil {
|
||||
log.Printf("warning: crm avg days to close query failed: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_leads": totalLeads,
|
||||
"converted_leads": convertedLeads,
|
||||
"conversion_rate": conversionRate,
|
||||
"avg_days_convert": avgDaysConvert,
|
||||
"avg_days_close": avgDaysClose,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_team_performance: return per-team performance comparison.
|
||||
// Mirrors: odoo/addons/crm/report/crm_activity_report.py (team grouping)
|
||||
m.RegisterMethod("get_team_performance", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT
|
||||
t.name,
|
||||
COUNT(l.id) AS opp_count,
|
||||
COALESCE(SUM(l.expected_revenue::float8), 0) AS total_revenue,
|
||||
COALESCE(AVG(l.probability), 0) AS avg_probability,
|
||||
COUNT(l.id) FILTER (WHERE l.state = 'won') AS won_count
|
||||
FROM crm_lead l
|
||||
JOIN crm_team t ON t.id = l.team_id
|
||||
WHERE l.active = true AND l.type = 'opportunity'
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY total_revenue DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get_team_performance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var teams []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var oppCount, wonCount int64
|
||||
var totalRevenue, avgProb float64
|
||||
if err := rows.Scan(&name, &oppCount, &totalRevenue, &avgProb, &wonCount); err != nil {
|
||||
return nil, fmt.Errorf("get_team_performance: scan: %w", err)
|
||||
}
|
||||
winRate := float64(0)
|
||||
if oppCount > 0 {
|
||||
winRate = float64(wonCount) / float64(oppCount) * 100
|
||||
}
|
||||
teams = append(teams, map[string]interface{}{
|
||||
"team": name,
|
||||
"opportunities": oppCount,
|
||||
"revenue": totalRevenue,
|
||||
"avg_probability": avgProb,
|
||||
"won": wonCount,
|
||||
"win_rate": winRate,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"teams": teams}, nil
|
||||
})
|
||||
|
||||
// get_salesperson_performance: return per-salesperson performance data.
|
||||
// Mirrors: odoo/addons/crm/report/crm_activity_report.py (user grouping)
|
||||
m.RegisterMethod("get_salesperson_performance", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT
|
||||
u.login,
|
||||
COUNT(l.id) AS opp_count,
|
||||
COALESCE(SUM(l.expected_revenue::float8), 0) AS total_revenue,
|
||||
COUNT(l.id) FILTER (WHERE l.state = 'won') AS won_count,
|
||||
COUNT(l.id) FILTER (WHERE l.state = 'lost') AS lost_count
|
||||
FROM crm_lead l
|
||||
JOIN res_users u ON u.id = l.user_id
|
||||
WHERE l.active = true AND l.type = 'opportunity'
|
||||
GROUP BY u.id, u.login
|
||||
ORDER BY total_revenue DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get_salesperson_performance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var login string
|
||||
var oppCount, wonCount, lostCount int64
|
||||
var totalRevenue float64
|
||||
if err := rows.Scan(&login, &oppCount, &totalRevenue, &wonCount, &lostCount); err != nil {
|
||||
return nil, fmt.Errorf("get_salesperson_performance: scan: %w", err)
|
||||
}
|
||||
winRate := float64(0)
|
||||
if oppCount > 0 {
|
||||
winRate = float64(wonCount) / float64(oppCount) * 100
|
||||
}
|
||||
users = append(users, map[string]interface{}{
|
||||
"salesperson": login,
|
||||
"opportunities": oppCount,
|
||||
"revenue": totalRevenue,
|
||||
"won": wonCount,
|
||||
"lost": lostCount,
|
||||
"win_rate": winRate,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"salespersons": users}, nil
|
||||
})
|
||||
}
|
||||
1056
addons/crm/models/crm_lead_ext.go
Normal file
1056
addons/crm/models/crm_lead_ext.go
Normal file
File diff suppressed because it is too large
Load Diff
471
addons/crm/models/crm_team.go
Normal file
471
addons/crm/models/crm_team.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initCrmTeamExpanded extends the basic crm.team with CRM-specific fields
|
||||
// (pipeline dashboard, lead assignment, team configuration).
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py CrmTeam
|
||||
func initCrmTeamExpanded() {
|
||||
m := orm.ExtendModel("crm.team")
|
||||
|
||||
m.AddFields(
|
||||
// Team configuration
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Team Leader"}),
|
||||
orm.One2many("crm_team_member_ids", "crm.team.member", "crm_team_id", orm.FieldOpts{
|
||||
String: "Sales Team Members",
|
||||
}),
|
||||
orm.Boolean("use_leads", orm.FieldOpts{
|
||||
String: "Leads",
|
||||
Default: true,
|
||||
Help: "Check this box to filter and qualify leads.",
|
||||
}),
|
||||
orm.Boolean("use_opportunities", orm.FieldOpts{
|
||||
String: "Pipeline",
|
||||
Default: true,
|
||||
Help: "Check this box to manage a pipeline of opportunities.",
|
||||
}),
|
||||
orm.Many2one("alias_id", "mail.alias", orm.FieldOpts{
|
||||
String: "Email Alias",
|
||||
Help: "Incoming emails on this alias will create leads/opportunities.",
|
||||
}),
|
||||
|
||||
// Dashboard computed fields
|
||||
// Mirrors: _compute_opportunities_data in crm.team
|
||||
orm.Integer("opportunities_count", orm.FieldOpts{
|
||||
String: "Number of Opportunities",
|
||||
Compute: "_compute_counts",
|
||||
Help: "Number of active opportunities in this team's pipeline.",
|
||||
}),
|
||||
orm.Monetary("opportunities_amount", orm.FieldOpts{
|
||||
String: "Opportunities Revenue",
|
||||
Compute: "_compute_counts",
|
||||
CurrencyField: "currency_id",
|
||||
Help: "Total expected revenue of active opportunities.",
|
||||
}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
|
||||
String: "Currency",
|
||||
}),
|
||||
orm.Integer("unassigned_leads_count", orm.FieldOpts{
|
||||
String: "Unassigned Leads",
|
||||
Compute: "_compute_counts",
|
||||
Help: "Number of leads without a salesperson assigned.",
|
||||
}),
|
||||
|
||||
// Assignment settings
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py assignment fields
|
||||
orm.Selection("assignment_domain", []orm.SelectionItem{
|
||||
{Value: "manual", Label: "Manual"},
|
||||
{Value: "auto", Label: "Auto Assignment"},
|
||||
}, orm.FieldOpts{
|
||||
String: "Assignment Method",
|
||||
Default: "manual",
|
||||
Help: "How new leads are distributed among team members.",
|
||||
}),
|
||||
orm.Integer("assignment_max", orm.FieldOpts{
|
||||
String: "Max Leads per Cycle",
|
||||
Default: 30,
|
||||
Help: "Maximum leads assigned to a member per assignment cycle.",
|
||||
}),
|
||||
orm.Boolean("assignment_enabled", orm.FieldOpts{
|
||||
String: "Auto-Assignment Active",
|
||||
Help: "Enable automatic lead assignment for this team.",
|
||||
}),
|
||||
orm.Integer("assignment_optout_count", orm.FieldOpts{
|
||||
String: "Members Opted-Out",
|
||||
Compute: "_compute_assignment_optout",
|
||||
Help: "Number of members who opted out of automatic assignment.",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_assignment_optout: count members who opted out of auto-assignment.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_assignment_optout
|
||||
m.RegisterCompute("assignment_optout_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
teamID := rs.IDs()[0]
|
||||
|
||||
var count int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM crm_team_member
|
||||
WHERE crm_team_id = $1 AND active = true AND assignment_optout = true`,
|
||||
teamID,
|
||||
).Scan(&count); err != nil {
|
||||
log.Printf("warning: crm.team _compute_assignment_optout query failed: %v", err)
|
||||
}
|
||||
|
||||
return orm.Values{"assignment_optout_count": count}, nil
|
||||
})
|
||||
|
||||
// _compute_counts: compute dashboard KPIs for the sales team.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data
|
||||
m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
teamID := rs.IDs()[0]
|
||||
|
||||
var count int64
|
||||
var amount float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0)
|
||||
FROM crm_lead
|
||||
WHERE team_id = $1 AND active = true AND type = 'opportunity'`,
|
||||
teamID,
|
||||
).Scan(&count, &amount); err != nil {
|
||||
log.Printf("warning: crm.team _compute_counts opportunities query failed: %v", err)
|
||||
}
|
||||
|
||||
var unassigned int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*)
|
||||
FROM crm_lead
|
||||
WHERE team_id = $1 AND active = true AND user_id IS NULL`,
|
||||
teamID,
|
||||
).Scan(&unassigned); err != nil {
|
||||
log.Printf("warning: crm.team _compute_counts unassigned query failed: %v", err)
|
||||
}
|
||||
|
||||
return orm.Values{
|
||||
"opportunities_count": count,
|
||||
"opportunities_amount": amount,
|
||||
"unassigned_leads_count": unassigned,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_crm_dashboard_data: KPIs — total pipeline value, won count, lost count, conversion rate.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_dashboard_data
|
||||
m.RegisterMethod("get_crm_dashboard_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
teamID := rs.IDs()[0]
|
||||
|
||||
var totalPipeline float64
|
||||
var wonCount, lostCount, totalOpps int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT
|
||||
COALESCE(SUM(expected_revenue::float8), 0),
|
||||
COUNT(*) FILTER (WHERE state = 'won'),
|
||||
COUNT(*) FILTER (WHERE state = 'lost'),
|
||||
COUNT(*)
|
||||
FROM crm_lead
|
||||
WHERE team_id = $1 AND type = 'opportunity'`,
|
||||
teamID,
|
||||
).Scan(&totalPipeline, &wonCount, &lostCount, &totalOpps); err != nil {
|
||||
log.Printf("warning: crm.team get_crm_dashboard_data query failed: %v", err)
|
||||
}
|
||||
|
||||
conversionRate := float64(0)
|
||||
decided := wonCount + lostCount
|
||||
if decided > 0 {
|
||||
conversionRate = float64(wonCount) / float64(decided) * 100
|
||||
}
|
||||
|
||||
// Active pipeline (open opportunities only)
|
||||
var activePipeline float64
|
||||
var activeCount int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(SUM(expected_revenue::float8), 0), COUNT(*)
|
||||
FROM crm_lead
|
||||
WHERE team_id = $1 AND type = 'opportunity' AND active = true AND state = 'open'`,
|
||||
teamID,
|
||||
).Scan(&activePipeline, &activeCount); err != nil {
|
||||
log.Printf("warning: crm.team get_crm_dashboard_data active pipeline query failed: %v", err)
|
||||
}
|
||||
|
||||
// Overdue activities count
|
||||
var overdueCount int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COUNT(DISTINCT l.id)
|
||||
FROM crm_lead l
|
||||
JOIN mail_activity a ON a.res_model = 'crm.lead' AND a.res_id = l.id
|
||||
WHERE l.team_id = $1 AND a.date_deadline < CURRENT_DATE AND a.done = false`,
|
||||
teamID,
|
||||
).Scan(&overdueCount); err != nil {
|
||||
log.Printf("warning: crm.team get_crm_dashboard_data overdue query failed: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_pipeline": totalPipeline,
|
||||
"active_pipeline": activePipeline,
|
||||
"active_count": activeCount,
|
||||
"won_count": wonCount,
|
||||
"lost_count": lostCount,
|
||||
"total_opportunities": totalOpps,
|
||||
"conversion_rate": conversionRate,
|
||||
"overdue_activities": overdueCount,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// action_assign_leads: trigger automatic lead assignment.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads
|
||||
m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
teamID := rs.IDs()[0]
|
||||
|
||||
// Fetch active members with remaining capacity
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT m.user_id, COALESCE(m.assignment_max, 30) AS max_leads,
|
||||
COALESCE((SELECT COUNT(*) FROM crm_lead l
|
||||
WHERE l.user_id = m.user_id AND l.team_id = $1
|
||||
AND l.active = true
|
||||
AND l.create_date >= date_trunc('month', CURRENT_DATE)), 0) AS current_count
|
||||
FROM crm_team_member m
|
||||
WHERE m.crm_team_id = $1 AND m.active = true
|
||||
ORDER BY current_count ASC`,
|
||||
teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action_assign_leads: query members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type memberCap struct {
|
||||
userID int64
|
||||
capacity int64
|
||||
}
|
||||
var members []memberCap
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
var maxLeads, current int64
|
||||
if err := rows.Scan(&uid, &maxLeads, ¤t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remaining := maxLeads - current; remaining > 0 {
|
||||
members = append(members, memberCap{userID: uid, capacity: remaining})
|
||||
}
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return map[string]interface{}{"assigned": 0}, nil
|
||||
}
|
||||
|
||||
// Fetch unassigned leads
|
||||
leadRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT id FROM crm_lead
|
||||
WHERE team_id = $1 AND active = true AND user_id IS NULL
|
||||
ORDER BY priority DESC, id ASC`,
|
||||
teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action_assign_leads: query leads: %w", err)
|
||||
}
|
||||
defer leadRows.Close()
|
||||
|
||||
var assigned int64
|
||||
memberIdx := 0
|
||||
for leadRows.Next() {
|
||||
var leadID int64
|
||||
if err := leadRows.Scan(&leadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if memberIdx >= len(members) {
|
||||
break
|
||||
}
|
||||
mc := &members[memberIdx]
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID); err != nil {
|
||||
log.Printf("warning: crm.team action_assign_leads update failed for lead %d: %v", leadID, err)
|
||||
}
|
||||
assigned++
|
||||
mc.capacity--
|
||||
if mc.capacity <= 0 {
|
||||
memberIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{"assigned": assigned}, nil
|
||||
})
|
||||
|
||||
// action_open_opportunities: return window action for team's opportunities.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py action_open_opportunities
|
||||
m.RegisterMethod("action_open_opportunities", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Opportunities",
|
||||
"res_model": "crm.lead",
|
||||
"view_mode": "kanban,tree,form",
|
||||
"domain": fmt.Sprintf("[('team_id','=',%d),('type','=','opportunity')]", rs.IDs()[0]),
|
||||
"context": map[string]interface{}{"default_team_id": rs.IDs()[0], "default_type": "opportunity"},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// action_open_unassigned: return window action for unassigned leads.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team.py (dashboard actions)
|
||||
m.RegisterMethod("action_open_unassigned", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Unassigned Leads",
|
||||
"res_model": "crm.lead",
|
||||
"view_mode": "tree,form",
|
||||
"domain": fmt.Sprintf("[('team_id','=',%d),('user_id','=',False)]", rs.IDs()[0]),
|
||||
"context": map[string]interface{}{"default_team_id": rs.IDs()[0]},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initCrmTeamMember registers the crm.team.member model.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team_member.py CrmTeamMember
|
||||
func initCrmTeamMember() {
|
||||
m := orm.NewModel("crm.team.member", orm.ModelOpts{
|
||||
Description: "Sales Team Member",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("crm_team_id", "crm.team", orm.FieldOpts{
|
||||
String: "Sales Team",
|
||||
Required: true,
|
||||
OnDelete: orm.OnDeleteCascade,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{
|
||||
String: "Salesperson",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Selection("role", []orm.SelectionItem{
|
||||
{Value: "member", Label: "Member"},
|
||||
{Value: "leader", Label: "Team Leader"},
|
||||
{Value: "manager", Label: "Sales Manager"},
|
||||
}, orm.FieldOpts{
|
||||
String: "Role",
|
||||
Default: "member",
|
||||
Help: "Role of this member within the sales team.",
|
||||
}),
|
||||
orm.Float("assignment_max", orm.FieldOpts{
|
||||
String: "Max Leads",
|
||||
Help: "Maximum number of leads this member should be assigned per month.",
|
||||
Default: float64(30),
|
||||
}),
|
||||
orm.Integer("lead_month_count", orm.FieldOpts{
|
||||
String: "Leads This Month",
|
||||
Compute: "_compute_lead_count",
|
||||
Help: "Number of leads assigned to this member in the current month.",
|
||||
}),
|
||||
orm.Boolean("assignment_optout", orm.FieldOpts{
|
||||
String: "Opt-Out",
|
||||
Help: "If checked, this member will not receive automatically assigned leads.",
|
||||
}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
String: "Company",
|
||||
Help: "Company of the sales team.",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_lead_count: count leads assigned to this member this month.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team_member.py _compute_lead_month_count
|
||||
m.RegisterCompute("lead_month_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
memberID := rs.IDs()[0]
|
||||
|
||||
var userID, teamID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID,
|
||||
).Scan(&userID, &teamID); err != nil {
|
||||
log.Printf("warning: crm.team.member _compute_lead_count member lookup failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM crm_lead
|
||||
WHERE user_id = $1 AND team_id = $2 AND active = true
|
||||
AND create_date >= date_trunc('month', CURRENT_DATE)`,
|
||||
userID, teamID,
|
||||
).Scan(&count); err != nil {
|
||||
log.Printf("warning: crm.team.member _compute_lead_count query failed: %v", err)
|
||||
}
|
||||
|
||||
return orm.Values{"lead_month_count": count}, nil
|
||||
})
|
||||
|
||||
// SQL constraint: one member per team
|
||||
m.AddSQLConstraint(
|
||||
"unique_member_per_team",
|
||||
"UNIQUE(crm_team_id, user_id)",
|
||||
"A user can only be a member of a team once.",
|
||||
)
|
||||
|
||||
// action_assign_to_team: add a user to a team as a member.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team_member.py _assign_to_team
|
||||
m.RegisterMethod("action_assign_to_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("user_id and team_id required")
|
||||
}
|
||||
var userID, teamID int64
|
||||
switch v := args[0].(type) {
|
||||
case float64:
|
||||
userID = int64(v)
|
||||
case int64:
|
||||
userID = v
|
||||
case int:
|
||||
userID = int64(v)
|
||||
}
|
||||
switch v := args[1].(type) {
|
||||
case float64:
|
||||
teamID = int64(v)
|
||||
case int64:
|
||||
teamID = v
|
||||
case int:
|
||||
teamID = int64(v)
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
var exists bool
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT EXISTS(SELECT 1 FROM crm_team_member WHERE user_id = $1 AND crm_team_id = $2)`,
|
||||
userID, teamID,
|
||||
).Scan(&exists)
|
||||
|
||||
if exists {
|
||||
// Ensure active
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_team_member SET active = true WHERE user_id = $1 AND crm_team_id = $2`,
|
||||
userID, teamID); err != nil {
|
||||
return nil, fmt.Errorf("action_assign_to_team reactivate: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var newID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO crm_team_member (user_id, crm_team_id, active, role)
|
||||
VALUES ($1, $2, true, 'member')
|
||||
RETURNING id`, userID, teamID,
|
||||
).Scan(&newID); err != nil {
|
||||
return nil, fmt.Errorf("action_assign_to_team insert: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"id": newID}, nil
|
||||
})
|
||||
|
||||
// action_remove_from_team: deactivate membership (soft delete).
|
||||
// Mirrors: odoo/addons/crm/models/crm_team_member.py unlink
|
||||
m.RegisterMethod("action_remove_from_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_team_member SET active = false WHERE id = $1`, id); err != nil {
|
||||
log.Printf("warning: crm.team.member action_remove_from_team failed for member %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_set_role: change the role of a team member.
|
||||
// Mirrors: odoo/addons/crm/models/crm_team_member.py write
|
||||
m.RegisterMethod("action_set_role", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("role value required ('member', 'leader', 'manager')")
|
||||
}
|
||||
role, ok := args[0].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("role must be a string")
|
||||
}
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE crm_team_member SET role = $1 WHERE id = $2`, role, id); err != nil {
|
||||
log.Printf("warning: crm.team.member action_set_role failed for member %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,11 @@ func Init() {
|
||||
initCRMTag()
|
||||
initCRMLostReason()
|
||||
initCRMTeam()
|
||||
initCrmTeamMember()
|
||||
initCRMStage()
|
||||
initCRMLead()
|
||||
// Extensions (must come after base models are registered)
|
||||
initCrmTeamExpanded()
|
||||
initCRMLeadExtended()
|
||||
initCrmAnalysis()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initResourceCalendar registers resource.calendar — working schedules.
|
||||
// Mirrors: odoo/addons/resource/models/resource.py
|
||||
@@ -98,15 +103,468 @@ func initHREmployee() {
|
||||
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
|
||||
)
|
||||
|
||||
// DefaultGet: provide dynamic defaults for new employees.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.default_get()
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
// Default company from current user's session
|
||||
companyID := env.CompanyID()
|
||||
if companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// toggle_active: archive/unarchive employee
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.toggle_active()
|
||||
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET active = NOT active WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: toggle_active for %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_archive: Archive employee (set active=false).
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.action_archive()
|
||||
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET active = false WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: action_archive for %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// _compute_remaining_leaves: Compute remaining leave days for the employee.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee._compute_remaining_leaves()
|
||||
m.RegisterCompute("leaves_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var allocated float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||
FROM hr_leave_allocation
|
||||
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||
).Scan(&allocated); err != nil {
|
||||
return orm.Values{"leaves_count": float64(0)}, nil
|
||||
}
|
||||
|
||||
var used float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||
FROM hr_leave
|
||||
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||
).Scan(&used); err != nil {
|
||||
return orm.Values{"leaves_count": float64(0)}, nil
|
||||
}
|
||||
|
||||
return orm.Values{"leaves_count": allocated - used}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("attendance_state", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var checkOut *string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT check_out FROM hr_attendance
|
||||
WHERE employee_id = $1
|
||||
ORDER BY check_in DESC LIMIT 1`, empID,
|
||||
).Scan(&checkOut)
|
||||
|
||||
if err != nil {
|
||||
// No attendance records or DB error → checked out
|
||||
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||
}
|
||||
if checkOut == nil {
|
||||
return orm.Values{"attendance_state": "checked_in"}, nil
|
||||
}
|
||||
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrEmployeeCategory registers the hr.employee.category model.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee_category.py
|
||||
func initHrEmployeeCategory() {
|
||||
orm.NewModel("hr.employee.category", orm.ModelOpts{
|
||||
Description: "Employee Tag",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initHrEmployeePublic registers the hr.employee.public model with limited fields.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic
|
||||
func initHrEmployeePublic() {
|
||||
m := orm.NewModel("hr.employee.public", orm.ModelOpts{
|
||||
Description: "Public Employee",
|
||||
Order: "name",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department", Readonly: true}),
|
||||
orm.Char("job_title", orm.FieldOpts{String: "Job Title", Readonly: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Readonly: true}),
|
||||
orm.Many2one("parent_id", "hr.employee.public", orm.FieldOpts{String: "Manager", Readonly: true}),
|
||||
orm.Char("work_email", orm.FieldOpts{String: "Work Email", Readonly: true}),
|
||||
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone", Readonly: true}),
|
||||
orm.Binary("image_1920", orm.FieldOpts{String: "Image", Readonly: true}),
|
||||
)
|
||||
|
||||
// get_public_data: Reads limited public fields from hr.employee.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic.read()
|
||||
m.RegisterMethod("get_public_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Accept employee_id from kwargs or use IDs
|
||||
var employeeIDs []int64
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["employee_ids"]; ok {
|
||||
if ids, ok := v.([]int64); ok {
|
||||
employeeIDs = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(employeeIDs) == 0 {
|
||||
employeeIDs = rs.IDs()
|
||||
}
|
||||
if len(employeeIDs) == 0 {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, COALESCE(name, ''), COALESCE(department_id, 0),
|
||||
COALESCE(job_title, ''), COALESCE(company_id, 0),
|
||||
COALESCE(work_email, ''), COALESCE(work_phone, '')
|
||||
FROM hr_employee
|
||||
WHERE id = ANY($1) AND COALESCE(active, true) = true`, employeeIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee.public: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, deptID, companyID int64
|
||||
var name, jobTitle, email, phone string
|
||||
if err := rows.Scan(&id, &name, &deptID, &jobTitle, &companyID, &email, &phone); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"department_id": deptID,
|
||||
"job_title": jobTitle,
|
||||
"company_id": companyID,
|
||||
"work_email": email,
|
||||
"work_phone": phone,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrEmployeeExtensions adds skill, resume, attendance and leave fields
|
||||
// to hr.employee after the related models have been registered.
|
||||
func initHrEmployeeExtensions() {
|
||||
emp := orm.ExtendModel("hr.employee")
|
||||
emp.AddFields(
|
||||
orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}),
|
||||
orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}),
|
||||
orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}),
|
||||
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Current Contract"}),
|
||||
orm.One2many("contract_ids", "hr.contract", "employee_id", orm.FieldOpts{String: "Contracts"}),
|
||||
orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}),
|
||||
orm.Selection("attendance_state", []orm.SelectionItem{
|
||||
{Value: "checked_out", Label: "Checked Out"},
|
||||
{Value: "checked_in", Label: "Checked In"},
|
||||
}, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}),
|
||||
orm.Float("seniority_years", orm.FieldOpts{
|
||||
String: "Seniority (Years)", Compute: "_compute_seniority_years",
|
||||
}),
|
||||
orm.Date("first_contract_date", orm.FieldOpts{String: "First Contract Date"}),
|
||||
orm.Many2many("category_ids", "hr.employee.category", orm.FieldOpts{String: "Tags"}),
|
||||
orm.Integer("age", orm.FieldOpts{
|
||||
String: "Age", Compute: "_compute_age",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_seniority_years: Years since first contract start date.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_employee.py _compute_first_contract_date
|
||||
emp.RegisterCompute("seniority_years", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Find earliest contract start date
|
||||
var firstDate *time.Time
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT MIN(date_start) FROM hr_contract
|
||||
WHERE employee_id = $1 AND state NOT IN ('cancel')`, empID,
|
||||
).Scan(&firstDate); err != nil {
|
||||
return orm.Values{"seniority_years": float64(0)}, nil
|
||||
}
|
||||
|
||||
if firstDate == nil {
|
||||
return orm.Values{"seniority_years": float64(0)}, nil
|
||||
}
|
||||
|
||||
years := time.Since(*firstDate).Hours() / (24 * 365.25)
|
||||
if years < 0 {
|
||||
years = 0
|
||||
}
|
||||
return orm.Values{"seniority_years": years}, nil
|
||||
})
|
||||
|
||||
// get_attendance_by_date_range: Return attendance summary for an employee.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py
|
||||
emp.RegisterMethod("get_attendance_by_date_range", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Parse date_from / date_to from kwargs
|
||||
dateFrom := time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||
dateTo := time.Now().Format("2006-01-02")
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||
dateFrom = v
|
||||
}
|
||||
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||
dateTo = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Daily attendance summary
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT check_in::date AS day,
|
||||
COUNT(*) AS entries,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0), 0) AS total_hours
|
||||
FROM hr_attendance
|
||||
WHERE employee_id = $1 AND check_in::date >= $2 AND check_in::date <= $3
|
||||
GROUP BY check_in::date
|
||||
ORDER BY check_in::date`, empID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: attendance report: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var days []map[string]interface{}
|
||||
var totalHours float64
|
||||
var totalDays int
|
||||
for rows.Next() {
|
||||
var day time.Time
|
||||
var entries int
|
||||
var hours float64
|
||||
if err := rows.Scan(&day, &entries, &hours); err != nil {
|
||||
continue
|
||||
}
|
||||
days = append(days, map[string]interface{}{
|
||||
"date": day.Format("2006-01-02"),
|
||||
"entries": entries,
|
||||
"hours": hours,
|
||||
})
|
||||
totalHours += hours
|
||||
totalDays++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"employee_id": empID,
|
||||
"date_from": dateFrom,
|
||||
"date_to": dateTo,
|
||||
"days": days,
|
||||
"total_days": totalDays,
|
||||
"total_hours": totalHours,
|
||||
"avg_hours": func() float64 { if totalDays > 0 { return totalHours / float64(totalDays) }; return 0 }(),
|
||||
}, nil
|
||||
})
|
||||
|
||||
// _compute_age: Compute employee age from birthday.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py _compute_age()
|
||||
emp.RegisterCompute("age", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var birthday *time.Time
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT birthday FROM hr_employee WHERE id = $1`, empID,
|
||||
).Scan(&birthday); err != nil || birthday == nil {
|
||||
return orm.Values{"age": int64(0)}, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
age := now.Year() - birthday.Year()
|
||||
// Adjust if birthday has not occurred yet this year
|
||||
if now.Month() < birthday.Month() ||
|
||||
(now.Month() == birthday.Month() && now.Day() < birthday.Day()) {
|
||||
age--
|
||||
}
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return orm.Values{"age": int64(age)}, nil
|
||||
})
|
||||
|
||||
// action_check_in: Create a new attendance record with check_in = now.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_in()
|
||||
emp.RegisterMethod("action_check_in", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
for _, empID := range rs.IDs() {
|
||||
// Verify employee is not already checked in
|
||||
var openCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_attendance
|
||||
WHERE employee_id = $1 AND check_out IS NULL`, empID,
|
||||
).Scan(&openCount)
|
||||
if openCount > 0 {
|
||||
return nil, fmt.Errorf("hr.employee: employee %d is already checked in", empID)
|
||||
}
|
||||
|
||||
// Get company_id from employee
|
||||
var companyID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT company_id FROM hr_employee WHERE id = $1`, empID,
|
||||
).Scan(&companyID)
|
||||
|
||||
// Create attendance record
|
||||
attRS := env.Model("hr.attendance")
|
||||
vals := orm.Values{
|
||||
"employee_id": empID,
|
||||
"check_in": now,
|
||||
}
|
||||
if companyID != nil {
|
||||
vals["company_id"] = *companyID
|
||||
}
|
||||
if _, err := attRS.Create(vals); err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: check_in for %d: %w", empID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_check_out: Set check_out on the latest open attendance record.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_out()
|
||||
emp.RegisterMethod("action_check_out", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
for _, empID := range rs.IDs() {
|
||||
result, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_attendance SET check_out = $1
|
||||
WHERE employee_id = $2 AND check_out IS NULL`, now, empID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: check_out for %d: %w", empID, err)
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return nil, fmt.Errorf("hr.employee: employee %d is not checked in", empID)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// get_org_chart: Return hierarchical org chart data for the employee.
|
||||
// Mirrors: odoo/addons/hr_org_chart/models/hr_employee.py get_org_chart()
|
||||
emp.RegisterMethod("get_org_chart", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Recursive function to build the tree
|
||||
var buildNode func(id int64, depth int) (map[string]interface{}, error)
|
||||
buildNode = func(id int64, depth int) (map[string]interface{}, error) {
|
||||
// Prevent infinite recursion
|
||||
if depth > 20 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var name, jobTitle string
|
||||
var deptID, parentID *int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), COALESCE(job_title, ''),
|
||||
department_id, parent_id
|
||||
FROM hr_employee
|
||||
WHERE id = $1 AND COALESCE(active, true) = true`, id,
|
||||
).Scan(&name, &jobTitle, &deptID, &parentID)
|
||||
if err != nil {
|
||||
return nil, nil // employee not found or inactive
|
||||
}
|
||||
|
||||
node := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"job_title": jobTitle,
|
||||
}
|
||||
if deptID != nil {
|
||||
node["department_id"] = *deptID
|
||||
}
|
||||
if parentID != nil {
|
||||
node["parent_id"] = *parentID
|
||||
}
|
||||
|
||||
// Find subordinates
|
||||
subRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM hr_employee
|
||||
WHERE parent_id = $1 AND COALESCE(active, true) = true
|
||||
ORDER BY name`, id)
|
||||
if err != nil {
|
||||
node["subordinates"] = []map[string]interface{}{}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
var subIDs []int64
|
||||
for subRows.Next() {
|
||||
var subID int64
|
||||
if err := subRows.Scan(&subID); err != nil {
|
||||
continue
|
||||
}
|
||||
subIDs = append(subIDs, subID)
|
||||
}
|
||||
subRows.Close()
|
||||
|
||||
var subordinates []map[string]interface{}
|
||||
for _, subID := range subIDs {
|
||||
subNode, err := buildNode(subID, depth+1)
|
||||
if err != nil || subNode == nil {
|
||||
continue
|
||||
}
|
||||
subordinates = append(subordinates, subNode)
|
||||
}
|
||||
|
||||
if subordinates == nil {
|
||||
subordinates = []map[string]interface{}{}
|
||||
}
|
||||
node["subordinates"] = subordinates
|
||||
node["subordinate_count"] = len(subordinates)
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
chart, err := buildNode(empID, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: get_org_chart: %w", err)
|
||||
}
|
||||
if chart == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return chart, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHRDepartment registers the hr.department model.
|
||||
@@ -133,6 +591,14 @@ func initHRDepartment() {
|
||||
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
|
||||
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
|
||||
)
|
||||
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
if companyID := env.CompanyID(); companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
// initHRJob registers the hr.job model.
|
||||
@@ -158,4 +624,12 @@ func initHRJob() {
|
||||
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
|
||||
)
|
||||
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
if companyID := env.CompanyID(); companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
32
addons/hr/models/hr_attendance.go
Normal file
32
addons/hr/models/hr_attendance.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initHrAttendance registers the hr.attendance model.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_attendance.py
|
||||
func initHrAttendance() {
|
||||
m := orm.NewModel("hr.attendance", orm.ModelOpts{
|
||||
Description: "Attendance",
|
||||
Order: "check_in desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Datetime("check_in", orm.FieldOpts{String: "Check In", Required: true}),
|
||||
orm.Datetime("check_out", orm.FieldOpts{String: "Check Out"}),
|
||||
orm.Float("worked_hours", orm.FieldOpts{String: "Worked Hours", Compute: "_compute_worked_hours", Store: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
)
|
||||
|
||||
m.RegisterCompute("worked_hours", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
attID := rs.IDs()[0]
|
||||
var hours float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0, 0)
|
||||
FROM hr_attendance WHERE id = $1`, attID,
|
||||
).Scan(&hours); err != nil {
|
||||
return orm.Values{"worked_hours": float64(0)}, nil
|
||||
}
|
||||
return orm.Values{"worked_hours": hours}, nil
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// initHrContract registers the hr.contract model.
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrContract registers the hr.contract model with full lifecycle.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
|
||||
func initHrContract() {
|
||||
m := orm.NewModel("hr.contract", orm.ModelOpts{
|
||||
@@ -10,22 +15,383 @@ func initHrContract() {
|
||||
Order: "date_start desc",
|
||||
})
|
||||
|
||||
// -- Core Fields --
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
|
||||
// -- Contract Type & Duration --
|
||||
m.AddFields(
|
||||
orm.Selection("contract_type", []orm.SelectionItem{
|
||||
{Value: "permanent", Label: "Permanent"},
|
||||
{Value: "fixed_term", Label: "Fixed Term"},
|
||||
{Value: "probation", Label: "Probation"},
|
||||
{Value: "freelance", Label: "Freelance / Contractor"},
|
||||
{Value: "internship", Label: "Internship"},
|
||||
}, orm.FieldOpts{String: "Contract Type", Default: "permanent"}),
|
||||
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}),
|
||||
orm.Date("trial_date_end", orm.FieldOpts{String: "Trial End Date"}),
|
||||
orm.Integer("notice_period_days", orm.FieldOpts{
|
||||
String: "Notice Period (Days)", Default: 30,
|
||||
}),
|
||||
)
|
||||
|
||||
// -- State Machine --
|
||||
m.AddFields(
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "New"},
|
||||
{Value: "open", Label: "Running"},
|
||||
{Value: "pending", Label: "To Renew"},
|
||||
{Value: "close", Label: "Expired"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||
)
|
||||
|
||||
// -- Compensation --
|
||||
m.AddFields(
|
||||
orm.Monetary("wage", orm.FieldOpts{
|
||||
String: "Wage (Gross)", Required: true, CurrencyField: "currency_id",
|
||||
Help: "Gross monthly salary",
|
||||
}),
|
||||
orm.Selection("schedule_pay", []orm.SelectionItem{
|
||||
{Value: "monthly", Label: "Monthly"},
|
||||
{Value: "weekly", Label: "Weekly"},
|
||||
{Value: "bi_weekly", Label: "Bi-Weekly"},
|
||||
{Value: "yearly", Label: "Yearly"},
|
||||
}, orm.FieldOpts{String: "Scheduled Pay", Default: "monthly"}),
|
||||
orm.Monetary("wage_annual", orm.FieldOpts{
|
||||
String: "Annual Wage", Compute: "_compute_wage_annual", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("bonus", orm.FieldOpts{
|
||||
String: "Bonus", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("transport_allowance", orm.FieldOpts{
|
||||
String: "Transport Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("meal_allowance", orm.FieldOpts{
|
||||
String: "Meal Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("other_allowance", orm.FieldOpts{
|
||||
String: "Other Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("total_compensation", orm.FieldOpts{
|
||||
String: "Total Compensation", Compute: "_compute_total_compensation", CurrencyField: "currency_id",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Working Schedule --
|
||||
m.AddFields(
|
||||
orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{
|
||||
String: "Working Schedule",
|
||||
}),
|
||||
orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week", Default: 40.0}),
|
||||
)
|
||||
|
||||
// -- History & Links --
|
||||
m.AddFields(
|
||||
orm.Many2one("previous_contract_id", "hr.contract", orm.FieldOpts{
|
||||
String: "Previous Contract",
|
||||
}),
|
||||
orm.Text("notes", orm.FieldOpts{String: "Notes"}),
|
||||
)
|
||||
|
||||
// -- Computed: days_remaining --
|
||||
m.AddFields(
|
||||
orm.Integer("days_remaining", orm.FieldOpts{
|
||||
String: "Days Remaining", Compute: "_compute_days_remaining",
|
||||
}),
|
||||
orm.Boolean("is_expired", orm.FieldOpts{
|
||||
String: "Is Expired", Compute: "_compute_is_expired",
|
||||
}),
|
||||
orm.Boolean("is_expiring_soon", orm.FieldOpts{
|
||||
String: "Expiring Soon", Compute: "_compute_is_expiring_soon",
|
||||
Help: "Contract expires within 30 days",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Computes --
|
||||
|
||||
m.RegisterCompute("days_remaining", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end FROM hr_contract WHERE id = $1`, id).Scan(&dateEnd)
|
||||
if dateEnd == nil {
|
||||
return orm.Values{"days_remaining": int64(0)}, nil
|
||||
}
|
||||
days := int64(time.Until(*dateEnd).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return orm.Values{"days_remaining": days}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&dateEnd, &state)
|
||||
expired := dateEnd != nil && dateEnd.Before(time.Now()) && state != "close" && state != "cancel"
|
||||
return orm.Values{"is_expired": expired}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("is_expiring_soon", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&dateEnd, &state)
|
||||
soon := false
|
||||
if dateEnd != nil && state == "open" {
|
||||
daysLeft := time.Until(*dateEnd).Hours() / 24
|
||||
soon = daysLeft > 0 && daysLeft <= 30
|
||||
}
|
||||
return orm.Values{"is_expiring_soon": soon}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("wage_annual", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var wage float64
|
||||
var schedulePay string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0), COALESCE(schedule_pay, 'monthly') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&wage, &schedulePay)
|
||||
var annual float64
|
||||
switch schedulePay {
|
||||
case "monthly":
|
||||
annual = wage * 12
|
||||
case "weekly":
|
||||
annual = wage * 52
|
||||
case "bi_weekly":
|
||||
annual = wage * 26
|
||||
case "yearly":
|
||||
annual = wage
|
||||
}
|
||||
return orm.Values{"wage_annual": annual}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("total_compensation", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var wage, bonus, transport, meal, other float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||
COALESCE(meal_allowance,0), COALESCE(other_allowance,0)
|
||||
FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&wage, &bonus, &transport, &meal, &other)
|
||||
return orm.Values{"total_compensation": wage + bonus + transport + meal + other}, nil
|
||||
})
|
||||
|
||||
// -- State Machine Methods --
|
||||
|
||||
// action_open: draft/pending → open (activate contract)
|
||||
m.RegisterMethod("action_open", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'open'
|
||||
WHERE id = $1 AND state IN ('draft', 'pending')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: open %d: %w", id, err)
|
||||
}
|
||||
// Set as current contract on employee
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET contract_id = $1
|
||||
WHERE id = (SELECT employee_id FROM hr_contract WHERE id = $1)`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: update employee contract link %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_pending: open → pending (mark for renewal)
|
||||
m.RegisterMethod("action_pending", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'pending' WHERE id = $1 AND state = 'open'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: pending %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_close: → close (expire contract)
|
||||
m.RegisterMethod("action_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close' WHERE id = $1 AND state NOT IN ('cancel')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: close %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: → cancel
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'cancel'
|
||||
WHERE id = $1 AND state IN ('draft', 'open', 'pending')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: → draft (reset)
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: draft %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_renew: Create a new contract from this one (close current, create copy)
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py action_renew()
|
||||
m.RegisterMethod("action_renew", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
|
||||
// Read current contract
|
||||
var employeeID, departmentID, jobID, companyID, currencyID, calendarID int64
|
||||
var wage, bonus, transport, meal, other, hoursPerWeek float64
|
||||
var contractType, schedulePay, name string
|
||||
var noticePeriod int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT employee_id, COALESCE(department_id,0), COALESCE(job_id,0),
|
||||
COALESCE(company_id,0), COALESCE(currency_id,0),
|
||||
COALESCE(resource_calendar_id,0),
|
||||
COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||
COALESCE(meal_allowance,0), COALESCE(other_allowance,0),
|
||||
COALESCE(hours_per_week,40),
|
||||
COALESCE(contract_type,'permanent'), COALESCE(schedule_pay,'monthly'),
|
||||
COALESCE(name,''), COALESCE(notice_period_days,30)
|
||||
FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&employeeID, &departmentID, &jobID, &companyID, ¤cyID, &calendarID,
|
||||
&wage, &bonus, &transport, &meal, &other, &hoursPerWeek,
|
||||
&contractType, &schedulePay, &name, ¬icePeriod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: read for renew %d: %w", id, err)
|
||||
}
|
||||
|
||||
// Close current contract
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: close for renewal %d: %w", id, err)
|
||||
}
|
||||
|
||||
// Create new contract
|
||||
newVals := orm.Values{
|
||||
"name": name + " (Renewal)",
|
||||
"employee_id": employeeID,
|
||||
"date_start": time.Now().Format("2006-01-02"),
|
||||
"wage": wage,
|
||||
"contract_type": contractType,
|
||||
"schedule_pay": schedulePay,
|
||||
"notice_period_days": noticePeriod,
|
||||
"bonus": bonus,
|
||||
"transport_allowance": transport,
|
||||
"meal_allowance": meal,
|
||||
"other_allowance": other,
|
||||
"hours_per_week": hoursPerWeek,
|
||||
"previous_contract_id": id,
|
||||
"state": "draft",
|
||||
}
|
||||
if departmentID > 0 {
|
||||
newVals["department_id"] = departmentID
|
||||
}
|
||||
if jobID > 0 {
|
||||
newVals["job_id"] = jobID
|
||||
}
|
||||
if companyID > 0 {
|
||||
newVals["company_id"] = companyID
|
||||
}
|
||||
if currencyID > 0 {
|
||||
newVals["currency_id"] = currencyID
|
||||
}
|
||||
if calendarID > 0 {
|
||||
newVals["resource_calendar_id"] = calendarID
|
||||
}
|
||||
|
||||
contractRS := env.Model("hr.contract")
|
||||
newContract, err := contractRS.Create(newVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: create renewal: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "hr.contract",
|
||||
"res_id": newContract.ID(),
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
|
||||
// -- BeforeWrite: State Guard --
|
||||
m.BeforeWrite = orm.StateGuard("hr_contract", "state IN ('close', 'cancel')",
|
||||
[]string{"write_uid", "write_date", "state", "active"},
|
||||
"cannot modify closed/cancelled contracts")
|
||||
}
|
||||
|
||||
// initHrContractCron registers the contract expiration check cron job.
|
||||
// Should be called after initHrContract and cron system is ready.
|
||||
func initHrContractCron() {
|
||||
m := orm.ExtendModel("hr.contract")
|
||||
|
||||
// _cron_check_expiring: Auto-close expired contracts, set pending for expiring soon.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py _cron_check_expiring()
|
||||
m.RegisterMethod("_cron_check_expiring", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// Close expired contracts
|
||||
result, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close'
|
||||
WHERE state = 'open' AND date_end IS NOT NULL AND date_end < $1`, today)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cron close expired: %w", err)
|
||||
}
|
||||
closed := result.RowsAffected()
|
||||
|
||||
// Mark contracts expiring within 30 days as pending
|
||||
thirtyDays := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
|
||||
result2, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'pending'
|
||||
WHERE state = 'open' AND date_end IS NOT NULL
|
||||
AND date_end >= $1 AND date_end <= $2`, today, thirtyDays)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cron mark pending: %w", err)
|
||||
}
|
||||
pending := result2.RowsAffected()
|
||||
|
||||
if closed > 0 || pending > 0 {
|
||||
fmt.Printf("hr.contract cron: closed %d, marked pending %d\n", closed, pending)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
352
addons/hr/models/hr_expense.go
Normal file
352
addons/hr/models/hr_expense.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrExpense registers the hr.expense and hr.expense.sheet models.
|
||||
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
|
||||
func initHrExpense() {
|
||||
orm.NewModel("hr.expense", orm.ModelOpts{
|
||||
Description: "Expense",
|
||||
Order: "date desc, id desc",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Expense Type"}),
|
||||
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
||||
orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Required: true, CurrencyField: "currency_id"}),
|
||||
orm.Monetary("unit_amount", orm.FieldOpts{String: "Unit Price", CurrencyField: "currency_id"}),
|
||||
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("sheet_id", "hr.expense.sheet", orm.FieldOpts{String: "Expense Report"}),
|
||||
orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "To Submit"},
|
||||
{Value: "reported", Label: "Submitted"},
|
||||
{Value: "approved", Label: "Approved"},
|
||||
{Value: "done", Label: "Paid"},
|
||||
{Value: "refused", Label: "Refused"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Selection("payment_mode", []orm.SelectionItem{
|
||||
{Value: "own_account", Label: "Employee (to reimburse)"},
|
||||
{Value: "company_account", Label: "Company"},
|
||||
}, orm.FieldOpts{String: "Payment By", Default: "own_account"}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Notes"}),
|
||||
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
|
||||
)
|
||||
|
||||
// -- Expense Methods --
|
||||
|
||||
// action_submit: draft → reported
|
||||
exp := orm.Registry.Get("hr.expense")
|
||||
if exp != nil {
|
||||
exp.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'reported' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: submit %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// _action_validate_expense: Check that expense has amount > 0 and a receipt.
|
||||
exp.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var amount float64
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(total_amount, 0), COALESCE(state, 'draft') FROM hr_expense WHERE id = $1`, id,
|
||||
).Scan(&amount, &state)
|
||||
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("hr.expense: expense %d has no amount", id)
|
||||
}
|
||||
if state != "reported" {
|
||||
return nil, fmt.Errorf("hr.expense: expense %d must be submitted first (state: %s)", id, state)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'approved' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: validate %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
exp.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'refused' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: refuse %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
sheet := orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
||||
Description: "Expense Report",
|
||||
Order: "create_date desc",
|
||||
})
|
||||
sheet.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
|
||||
orm.One2many("expense_line_ids", "hr.expense", "sheet_id", orm.FieldOpts{String: "Expenses"}),
|
||||
orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Compute: "_compute_total", Store: true, CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "submit", Label: "Submitted"},
|
||||
{Value: "approve", Label: "Approved"},
|
||||
{Value: "post", Label: "Posted"},
|
||||
{Value: "done", Label: "Paid"},
|
||||
{Value: "cancel", Label: "Refused"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
||||
orm.Integer("expense_count", orm.FieldOpts{String: "Expense Count", Compute: "_compute_expense_count"}),
|
||||
)
|
||||
|
||||
// _compute_total: Sum of expense amounts.
|
||||
sheet.RegisterCompute("total_amount", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(total_amount::float8), 0) FROM hr_expense WHERE sheet_id = $1`, id,
|
||||
).Scan(&total)
|
||||
return orm.Values{"total_amount": total}, nil
|
||||
})
|
||||
|
||||
sheet.RegisterCompute("expense_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var count int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||
return orm.Values{"expense_count": count}, nil
|
||||
})
|
||||
|
||||
// -- Expense Sheet Workflow Methods --
|
||||
|
||||
sheet.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
// Validate: must have at least one expense line
|
||||
var count int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: cannot submit empty report %d", id)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'submit' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: submit %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'reported' WHERE sheet_id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for submit %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'approve' WHERE id = $1 AND state = 'submit'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: approve %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'approved' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for approve %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: refuse %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'refused' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for refuse %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_post: Create a journal entry (account.move) from approved expense sheet.
|
||||
// Debit: expense account, Credit: payable account.
|
||||
// Mirrors: odoo/addons/hr_expense/models/hr_expense_sheet.py action_sheet_move_create()
|
||||
sheet.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, sheetID := range rs.IDs() {
|
||||
// Validate state = approve
|
||||
var state string
|
||||
var employeeID int64
|
||||
var companyID *int64
|
||||
var currencyID *int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(state, 'draft'), employee_id,
|
||||
company_id, currency_id
|
||||
FROM hr_expense_sheet WHERE id = $1`, sheetID,
|
||||
).Scan(&state, &employeeID, &companyID, ¤cyID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: read %d: %w", sheetID, err)
|
||||
}
|
||||
if state != "approve" {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: can only post approved reports (sheet %d is %q)", sheetID, state)
|
||||
}
|
||||
|
||||
// Fetch expense lines
|
||||
expRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, name, COALESCE(total_amount, 0), account_id
|
||||
FROM hr_expense WHERE sheet_id = $1`, sheetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: fetch expenses for %d: %w", sheetID, err)
|
||||
}
|
||||
|
||||
type expLine struct {
|
||||
id int64
|
||||
name string
|
||||
amount float64
|
||||
accountID *int64
|
||||
}
|
||||
var lines []expLine
|
||||
var total float64
|
||||
for expRows.Next() {
|
||||
var l expLine
|
||||
if err := expRows.Scan(&l.id, &l.name, &l.amount, &l.accountID); err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, l)
|
||||
total += l.amount
|
||||
}
|
||||
expRows.Close()
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: no expenses to post on sheet %d", sheetID)
|
||||
}
|
||||
|
||||
// Get employee's home address partner for payable line
|
||||
var partnerID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT address_home_id FROM hr_employee WHERE id = $1`, employeeID,
|
||||
).Scan(&partnerID)
|
||||
|
||||
// Create account.move
|
||||
moveVals := orm.Values{
|
||||
"move_type": "in_invoice",
|
||||
"state": "draft",
|
||||
"date": time.Now().Format("2006-01-02"),
|
||||
}
|
||||
if companyID != nil {
|
||||
moveVals["company_id"] = *companyID
|
||||
}
|
||||
if currencyID != nil {
|
||||
moveVals["currency_id"] = *currencyID
|
||||
}
|
||||
if partnerID != nil {
|
||||
moveVals["partner_id"] = *partnerID
|
||||
}
|
||||
|
||||
moveRS := env.Model("account.move")
|
||||
move, err := moveRS.Create(moveVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create journal entry for %d: %w", sheetID, err)
|
||||
}
|
||||
moveID := move.ID()
|
||||
|
||||
// Create move lines: one debit line per expense, one credit (payable) line for total
|
||||
for _, l := range lines {
|
||||
debitVals := orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": l.name,
|
||||
"debit": l.amount,
|
||||
"credit": float64(0),
|
||||
}
|
||||
if l.accountID != nil {
|
||||
debitVals["account_id"] = *l.accountID
|
||||
}
|
||||
if partnerID != nil {
|
||||
debitVals["partner_id"] = *partnerID
|
||||
}
|
||||
lineRS := env.Model("account.move.line")
|
||||
if _, err := lineRS.Create(debitVals); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create debit line: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Credit line (payable) — find payable account
|
||||
var payableAccID int64
|
||||
cid := int64(0)
|
||||
if companyID != nil {
|
||||
cid = *companyID
|
||||
}
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_account
|
||||
WHERE account_type = 'liability_payable' AND company_id = $1
|
||||
ORDER BY code LIMIT 1`, cid).Scan(&payableAccID)
|
||||
|
||||
creditVals := orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": "Employee Expense Payable",
|
||||
"debit": float64(0),
|
||||
"credit": total,
|
||||
}
|
||||
if payableAccID > 0 {
|
||||
creditVals["account_id"] = payableAccID
|
||||
}
|
||||
if partnerID != nil {
|
||||
creditVals["partner_id"] = *partnerID
|
||||
}
|
||||
lineRS := env.Model("account.move.line")
|
||||
if _, err := lineRS.Create(creditVals); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create credit line: %w", err)
|
||||
}
|
||||
|
||||
// Update expense sheet state and link to move
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'post', account_move_id = $1 WHERE id = $2`,
|
||||
moveID, sheetID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update state to post %d: %w", sheetID, err)
|
||||
}
|
||||
|
||||
// Update expense line states
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'done' WHERE sheet_id = $1`, sheetID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update expense states %d: %w", sheetID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_reset", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'draft' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: reset %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'draft' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for reset %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
575
addons/hr/models/hr_leave.go
Normal file
575
addons/hr/models/hr_leave.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrLeaveType registers the hr.leave.type model.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
|
||||
func initHrLeaveType() {
|
||||
orm.NewModel("hr.leave.type", orm.ModelOpts{
|
||||
Description: "Time Off Type",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 100}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Selection("leave_validation_type", []orm.SelectionItem{
|
||||
{Value: "no_validation", Label: "No Validation"},
|
||||
{Value: "hr", Label: "By Time Off Officer"},
|
||||
{Value: "manager", Label: "By Employee's Approver"},
|
||||
{Value: "both", Label: "By Employee's Approver and Time Off Officer"},
|
||||
}, orm.FieldOpts{String: "Approval", Default: "hr"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Integer("color", orm.FieldOpts{String: "Color"}),
|
||||
orm.Boolean("requires_allocation", orm.FieldOpts{String: "Requires Allocation", Default: true}),
|
||||
orm.Float("max_allowed", orm.FieldOpts{String: "Max Days Allowed"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initHrLeave registers the hr.leave model.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py
|
||||
func initHrLeave() {
|
||||
m := orm.NewModel("hr.leave", orm.ModelOpts{
|
||||
Description: "Time Off",
|
||||
Order: "date_from desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description"}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
|
||||
orm.Datetime("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Datetime("date_to", orm.FieldOpts{String: "End Date", Required: true}),
|
||||
orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "To Submit"},
|
||||
{Value: "confirm", Label: "To Approve"},
|
||||
{Value: "validate1", Label: "Second Approval"},
|
||||
{Value: "validate", Label: "Approved"},
|
||||
{Value: "refuse", Label: "Refused"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Text("notes", orm.FieldOpts{String: "Reasons"}),
|
||||
)
|
||||
|
||||
// action_approve: Manager approves leave request (first approval).
|
||||
// For leave types with 'both' validation, moves to validate1 (second approval needed).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_approve()
|
||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "validate1" {
|
||||
return nil, fmt.Errorf("hr.leave: can only approve leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
// Check if second approval is needed
|
||||
var validationType string
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(lt.leave_validation_type, 'hr')
|
||||
FROM hr_leave l
|
||||
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||
WHERE l.id = $1`, id,
|
||||
).Scan(&validationType); err != nil {
|
||||
validationType = "hr" // safe default
|
||||
}
|
||||
|
||||
newState := "validate"
|
||||
if validationType == "both" && state == "confirm" {
|
||||
newState = "validate1"
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = $1 WHERE id = $2`, newState, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: approve leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_validate: Final validation (second approval if needed).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_validate()
|
||||
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "validate1" {
|
||||
return nil, fmt.Errorf("hr.leave: can only validate leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'validate' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: validate leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_refuse: Manager refuses leave request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_refuse()
|
||||
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state == "draft" {
|
||||
return nil, fmt.Errorf("hr.leave: cannot refuse a draft leave (leave %d). Submit it first", id)
|
||||
}
|
||||
if state == "validate" {
|
||||
return nil, fmt.Errorf("hr.leave: cannot refuse an already approved leave (leave %d). Reset to draft first", id)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: refuse leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: Reset leave to draft state.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_draft()
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "refuse" {
|
||||
return nil, fmt.Errorf("hr.leave: can only reset to draft from 'To Approve' or 'Refused' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: reset to draft leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_confirm: Submit leave for approval (draft -> confirm).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_confirm()
|
||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "draft" {
|
||||
return nil, fmt.Errorf("hr.leave: can only confirm draft leaves (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'confirm' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: confirm leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveExtensions adds additional leave methods.
|
||||
func initHrLeaveExtensions() {
|
||||
leave := orm.ExtendModel("hr.leave")
|
||||
|
||||
// action_approve_batch: Approve multiple leave requests at once (manager workflow).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py action_approve (multi)
|
||||
leave.RegisterMethod("action_approve_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Leaves with 'both' validation: confirm → validate1 (not directly to validate)
|
||||
r1, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave l SET state = 'validate1'
|
||||
WHERE l.id = ANY($1) AND l.state = 'confirm'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM hr_leave_type lt
|
||||
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||
)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: batch approve (first step): %w", err)
|
||||
}
|
||||
|
||||
// Non-'both' confirm → validate, and existing validate1 → validate
|
||||
// Exclude IDs that were just set to validate1 above
|
||||
r2, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave l SET state = 'validate'
|
||||
WHERE l.id = ANY($1)
|
||||
AND (l.state = 'validate1'
|
||||
OR (l.state = 'confirm' AND NOT EXISTS (
|
||||
SELECT 1 FROM hr_leave_type lt
|
||||
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||
)))`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: batch approve: %w", err)
|
||||
}
|
||||
count := r1.RowsAffected() + r2.RowsAffected()
|
||||
return map[string]interface{}{"approved": count}, nil
|
||||
})
|
||||
|
||||
// _compute_number_of_days: Auto-compute duration from date_from/date_to.
|
||||
leave.RegisterCompute("number_of_days", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var days float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (date_to - date_from)) / 86400.0, 0)
|
||||
FROM hr_leave WHERE id = $1`, id).Scan(&days); err != nil {
|
||||
return orm.Values{"number_of_days": float64(0)}, nil
|
||||
}
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return orm.Values{"number_of_days": days}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveReport registers the hr.leave.report transient model.
|
||||
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||
func initHrLeaveReport() {
|
||||
m := orm.NewModel("hr.leave.report", orm.ModelOpts{
|
||||
Description: "Time Off Summary Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "From"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "To"}),
|
||||
)
|
||||
|
||||
// get_leave_summary: Returns leave days grouped by leave type for an employee in a date range.
|
||||
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||
m.RegisterMethod("get_leave_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Parse kwargs
|
||||
var employeeID int64
|
||||
dateFrom := "2000-01-01"
|
||||
dateTo := "2099-12-31"
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["employee_id"]; ok {
|
||||
switch vid := v.(type) {
|
||||
case int64:
|
||||
employeeID = vid
|
||||
case float64:
|
||||
employeeID = int64(vid)
|
||||
case int:
|
||||
employeeID = int64(vid)
|
||||
}
|
||||
}
|
||||
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||
dateFrom = v
|
||||
}
|
||||
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||
dateTo = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if employeeID == 0 {
|
||||
return nil, fmt.Errorf("hr.leave.report: employee_id is required")
|
||||
}
|
||||
|
||||
// Approved leaves grouped by type
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT lt.id, lt.name, COALESCE(SUM(l.number_of_days), 0) AS total_days,
|
||||
COUNT(l.id) AS leave_count
|
||||
FROM hr_leave l
|
||||
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||
WHERE l.employee_id = $1
|
||||
AND l.state = 'validate'
|
||||
AND l.date_from::date >= $2
|
||||
AND l.date_to::date <= $3
|
||||
GROUP BY lt.id, lt.name
|
||||
ORDER BY lt.name`, employeeID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.report: query leaves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var summary []map[string]interface{}
|
||||
var totalDays float64
|
||||
for rows.Next() {
|
||||
var typeID int64
|
||||
var typeName string
|
||||
var days float64
|
||||
var count int
|
||||
if err := rows.Scan(&typeID, &typeName, &days, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
summary = append(summary, map[string]interface{}{
|
||||
"leave_type_id": typeID,
|
||||
"leave_type_name": typeName,
|
||||
"total_days": days,
|
||||
"leave_count": count,
|
||||
})
|
||||
totalDays += days
|
||||
}
|
||||
|
||||
// Remaining allocation per type
|
||||
allocRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT lt.id, lt.name,
|
||||
COALESCE(SUM(a.number_of_days), 0) AS allocated
|
||||
FROM hr_leave_allocation a
|
||||
JOIN hr_leave_type lt ON lt.id = a.holiday_status_id
|
||||
WHERE a.employee_id = $1 AND a.state = 'validate'
|
||||
GROUP BY lt.id, lt.name
|
||||
ORDER BY lt.name`, employeeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.report: query allocations: %w", err)
|
||||
}
|
||||
defer allocRows.Close()
|
||||
|
||||
var allocations []map[string]interface{}
|
||||
for allocRows.Next() {
|
||||
var typeID int64
|
||||
var typeName string
|
||||
var allocated float64
|
||||
if err := allocRows.Scan(&typeID, &typeName, &allocated); err != nil {
|
||||
continue
|
||||
}
|
||||
allocations = append(allocations, map[string]interface{}{
|
||||
"leave_type_id": typeID,
|
||||
"leave_type_name": typeName,
|
||||
"allocated_days": allocated,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"employee_id": employeeID,
|
||||
"date_from": dateFrom,
|
||||
"date_to": dateTo,
|
||||
"leaves": summary,
|
||||
"allocations": allocations,
|
||||
"total_days": totalDays,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveTypeExtensions adds remaining quota computation to leave types.
|
||||
func initHrLeaveTypeExtensions() {
|
||||
lt := orm.ExtendModel("hr.leave.type")
|
||||
|
||||
lt.AddFields(
|
||||
orm.Float("remaining_leaves", orm.FieldOpts{
|
||||
String: "Remaining Leaves", Compute: "_compute_remaining_leaves",
|
||||
Help: "Remaining leaves for current employee (allocated - taken)",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_remaining_quota: Calculate remaining leaves per type for current user's employee.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py _compute_leaves()
|
||||
lt.RegisterCompute("remaining_leaves", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
typeID := rs.IDs()[0]
|
||||
|
||||
// Get current user's employee
|
||||
var employeeID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID(),
|
||||
).Scan(&employeeID)
|
||||
if employeeID == 0 {
|
||||
return orm.Values{"remaining_leaves": float64(0)}, nil
|
||||
}
|
||||
|
||||
// Allocated days for this type (approved allocations)
|
||||
var allocated float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave_allocation
|
||||
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'`,
|
||||
employeeID, typeID).Scan(&allocated)
|
||||
|
||||
// Used days for this type (approved leaves, current fiscal year)
|
||||
var used float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave
|
||||
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'
|
||||
AND date_from >= date_trunc('year', CURRENT_DATE)`,
|
||||
employeeID, typeID).Scan(&used)
|
||||
|
||||
return orm.Values{"remaining_leaves": allocated - used}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveAllocation registers the hr.leave.allocation model.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
|
||||
func initHrLeaveAllocation() {
|
||||
m := orm.NewModel("hr.leave.allocation", orm.ModelOpts{
|
||||
Description: "Time Off Allocation",
|
||||
Order: "create_date desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description"}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}),
|
||||
orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)", Required: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "To Submit"},
|
||||
{Value: "confirm", Label: "To Approve"},
|
||||
{Value: "validate", Label: "Approved"},
|
||||
{Value: "refuse", Label: "Refused"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Selection("allocation_type", []orm.SelectionItem{
|
||||
{Value: "regular", Label: "Regular Allocation"},
|
||||
{Value: "accrual", Label: "Accrual Allocation"},
|
||||
}, orm.FieldOpts{String: "Allocation Type", Default: "regular"}),
|
||||
orm.Float("accrual_increment", orm.FieldOpts{
|
||||
String: "Monthly Accrual Increment",
|
||||
Help: "Number of days added each month for accrual allocations",
|
||||
}),
|
||||
orm.Date("last_accrual_date", orm.FieldOpts{
|
||||
String: "Last Accrual Date",
|
||||
Help: "Date when the last accrual increment was applied",
|
||||
}),
|
||||
)
|
||||
|
||||
// action_approve: Approve allocation request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_approve()
|
||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "draft" {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: can only approve allocations in 'To Submit' or 'To Approve' state (allocation %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: approve allocation %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_refuse: Refuse allocation request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_refuse()
|
||||
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||
}
|
||||
if state == "validate" {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: cannot refuse an already approved allocation (allocation %d)", id)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation SET state = 'refuse' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: refuse allocation %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveAccrualCron registers the accrual allocation cron method.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py _cron_accrual_allocation()
|
||||
func initHrLeaveAccrualCron() {
|
||||
alloc := orm.ExtendModel("hr.leave.allocation")
|
||||
|
||||
// _cron_accrual_allocation: Auto-increment approved accrual-type allocations monthly.
|
||||
// For each approved accrual allocation whose last_accrual_date is more than a month ago
|
||||
// (or NULL), add accrual_increment days to number_of_days and update last_accrual_date.
|
||||
alloc.RegisterMethod("_cron_accrual_allocation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// Find all approved accrual allocations due for increment
|
||||
// Due = last_accrual_date is NULL or more than 30 days ago
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, COALESCE(number_of_days, 0), COALESCE(accrual_increment, 0)
|
||||
FROM hr_leave_allocation
|
||||
WHERE state = 'validate'
|
||||
AND allocation_type = 'accrual'
|
||||
AND COALESCE(accrual_increment, 0) > 0
|
||||
AND (last_accrual_date IS NULL
|
||||
OR last_accrual_date <= CURRENT_DATE - INTERVAL '30 days')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: accrual cron query: %w", err)
|
||||
}
|
||||
|
||||
type accrualRow struct {
|
||||
id int64
|
||||
days float64
|
||||
increment float64
|
||||
}
|
||||
var pending []accrualRow
|
||||
for rows.Next() {
|
||||
var r accrualRow
|
||||
if err := rows.Scan(&r.id, &r.days, &r.increment); err != nil {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
var updated int64
|
||||
for _, r := range pending {
|
||||
newDays := r.days + r.increment
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation
|
||||
SET number_of_days = $1, last_accrual_date = $2
|
||||
WHERE id = $3`, newDays, today, r.id); err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: accrual update %d: %w", r.id, err)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
fmt.Printf("hr.leave.allocation accrual cron: incremented %d allocations\n", updated)
|
||||
}
|
||||
return map[string]interface{}{"updated": updated}, nil
|
||||
})
|
||||
}
|
||||
303
addons/hr/models/hr_payroll.go
Normal file
303
addons/hr/models/hr_payroll.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrPayroll registers hr.salary.structure, hr.salary.rule, and hr.payslip models.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py, hr_salary_rule.py, hr_payroll_structure.py
|
||||
func initHrPayroll() {
|
||||
// -- hr.salary.rule --
|
||||
orm.NewModel("hr.salary.rule", orm.ModelOpts{
|
||||
Description: "Salary Rule",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "basic", Label: "Basic"},
|
||||
{Value: "allowance", Label: "Allowance"},
|
||||
{Value: "deduction", Label: "Deduction"},
|
||||
{Value: "gross", Label: "Gross"},
|
||||
{Value: "net", Label: "Net"},
|
||||
}, orm.FieldOpts{String: "Category", Required: true, Default: "basic"}),
|
||||
orm.Selection("amount_select", []orm.SelectionItem{
|
||||
{Value: "fixed", Label: "Fixed Amount"},
|
||||
{Value: "percentage", Label: "Percentage (%)"},
|
||||
{Value: "code", Label: "Python/Go Code"},
|
||||
}, orm.FieldOpts{String: "Amount Type", Required: true, Default: "fixed"}),
|
||||
orm.Float("amount_fix", orm.FieldOpts{String: "Fixed Amount"}),
|
||||
orm.Float("amount_percentage", orm.FieldOpts{String: "Percentage (%)"}),
|
||||
orm.Char("amount_percentage_base", orm.FieldOpts{
|
||||
String: "Percentage Based On",
|
||||
Help: "Code of the rule whose result is used as the base for percentage calculation",
|
||||
}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
|
||||
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{String: "Salary Structure"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||
)
|
||||
|
||||
// -- hr.salary.structure --
|
||||
orm.NewModel("hr.salary.structure", orm.ModelOpts{
|
||||
Description: "Salary Structure",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.One2many("rule_ids", "hr.salary.rule", "struct_id", orm.FieldOpts{String: "Salary Rules"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||
)
|
||||
|
||||
// -- hr.payslip --
|
||||
m := orm.NewModel("hr.payslip", orm.ModelOpts{
|
||||
Description: "Pay Slip",
|
||||
Order: "number desc, id desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description"}),
|
||||
orm.Char("number", orm.FieldOpts{String: "Reference", Readonly: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{
|
||||
String: "Salary Structure", Required: true,
|
||||
}),
|
||||
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Contract"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Date From", Required: true}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "Date To", Required: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "verify", Label: "Waiting"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Rejected"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Monetary("net_wage", orm.FieldOpts{
|
||||
String: "Net Wage", Compute: "_compute_net_wage", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Notes"}),
|
||||
)
|
||||
|
||||
// _compute_net_wage: Sum salary rule results stored in hr_payslip_line.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py _compute_basic_net()
|
||||
m.RegisterCompute("net_wage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var net float64
|
||||
// Net = sum of all line amounts (allowances positive, deductions negative)
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(
|
||||
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||
), 0)
|
||||
FROM hr_payslip_line WHERE slip_id = $1`, id,
|
||||
).Scan(&net); err != nil {
|
||||
return orm.Values{"net_wage": float64(0)}, nil
|
||||
}
|
||||
return orm.Values{"net_wage": net}, nil
|
||||
})
|
||||
|
||||
// compute_sheet: Apply salary rules from the structure to compute payslip lines.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py compute_sheet()
|
||||
m.RegisterMethod("compute_sheet", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
for _, slipID := range rs.IDs() {
|
||||
// Read payslip data
|
||||
var structID, contractID, employeeID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT struct_id, COALESCE(contract_id, 0), employee_id
|
||||
FROM hr_payslip WHERE id = $1`, slipID,
|
||||
).Scan(&structID, &contractID, &employeeID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: read %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Fetch contract wage as the base
|
||||
var wage float64
|
||||
if contractID > 0 {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0) FROM hr_contract WHERE id = $1`, contractID,
|
||||
).Scan(&wage)
|
||||
} else {
|
||||
// Try to find open contract for the employee
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0) FROM hr_contract
|
||||
WHERE employee_id = $1 AND state = 'open'
|
||||
ORDER BY date_start DESC LIMIT 1`, employeeID,
|
||||
).Scan(&wage)
|
||||
}
|
||||
|
||||
// Fetch salary rules for this structure, ordered by sequence
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, name, code, COALESCE(category, 'basic'),
|
||||
COALESCE(amount_select, 'fixed'),
|
||||
COALESCE(amount_fix, 0), COALESCE(amount_percentage, 0),
|
||||
COALESCE(amount_percentage_base, ''), sequence
|
||||
FROM hr_salary_rule
|
||||
WHERE struct_id = $1 AND COALESCE(active, true) = true
|
||||
ORDER BY sequence, id`, structID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: fetch rules for struct %d: %w", structID, err)
|
||||
}
|
||||
|
||||
type rule struct {
|
||||
id int64
|
||||
name, code string
|
||||
category string
|
||||
amountSelect string
|
||||
amountFix float64
|
||||
amountPct float64
|
||||
amountPctBase string
|
||||
sequence int
|
||||
}
|
||||
var rules []rule
|
||||
for rows.Next() {
|
||||
var r rule
|
||||
if err := rows.Scan(&r.id, &r.name, &r.code, &r.category,
|
||||
&r.amountSelect, &r.amountFix, &r.amountPct, &r.amountPctBase, &r.sequence); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("hr.payslip: scan rule: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
if rules[i].sequence != rules[j].sequence {
|
||||
return rules[i].sequence < rules[j].sequence
|
||||
}
|
||||
return rules[i].id < rules[j].id
|
||||
})
|
||||
|
||||
// Delete existing lines for re-computation
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`DELETE FROM hr_payslip_line WHERE slip_id = $1`, slipID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: clear lines for %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Compute each rule; track results by code for percentage-base lookups
|
||||
codeResults := map[string]float64{
|
||||
"BASIC": wage, // default base
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
var amount float64
|
||||
switch r.amountSelect {
|
||||
case "fixed":
|
||||
amount = r.amountFix
|
||||
case "percentage":
|
||||
base := wage // default base is wage
|
||||
if r.amountPctBase != "" {
|
||||
if v, ok := codeResults[r.amountPctBase]; ok {
|
||||
base = v
|
||||
}
|
||||
}
|
||||
amount = base * r.amountPct / 100.0
|
||||
default:
|
||||
// "code" type — use fixed amount as fallback
|
||||
amount = r.amountFix
|
||||
}
|
||||
|
||||
codeResults[r.code] = amount
|
||||
|
||||
// Insert payslip line
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO hr_payslip_line
|
||||
(slip_id, name, code, category, amount, sequence, salary_rule_id,
|
||||
create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NOW(), NOW())`,
|
||||
slipID, r.name, r.code, r.category, amount, r.sequence, r.id,
|
||||
env.UID(),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: insert line for rule %s: %w", r.code, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update payslip state to verify and compute net_wage inline
|
||||
var net float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(
|
||||
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||
), 0) FROM hr_payslip_line WHERE slip_id = $1`, slipID,
|
||||
).Scan(&net)
|
||||
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'verify', net_wage = $1 WHERE id = $2`,
|
||||
net, slipID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: update state to verify %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Generate payslip number if empty
|
||||
now := time.Now()
|
||||
number := fmt.Sprintf("SLIP/%04d/%02d/%05d", now.Year(), now.Month(), slipID)
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET number = $1 WHERE id = $2 AND (number IS NULL OR number = '')`,
|
||||
number, slipID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_done: verify → done (confirm payslip)
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'done' WHERE id = $1 AND state = 'verify'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_done %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: → cancel
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: cancel → draft
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_draft %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// -- hr.payslip.line — detail lines computed from salary rules --
|
||||
orm.NewModel("hr.payslip.line", orm.ModelOpts{
|
||||
Description: "Payslip Line",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Many2one("slip_id", "hr.payslip", orm.FieldOpts{
|
||||
String: "Pay Slip", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("salary_rule_id", "hr.salary.rule", orm.FieldOpts{String: "Rule"}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "basic", Label: "Basic"},
|
||||
{Value: "allowance", Label: "Allowance"},
|
||||
{Value: "deduction", Label: "Deduction"},
|
||||
{Value: "gross", Label: "Gross"},
|
||||
{Value: "net", Label: "Net"},
|
||||
}, orm.FieldOpts{String: "Category"}),
|
||||
orm.Float("amount", orm.FieldOpts{String: "Amount"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
|
||||
)
|
||||
}
|
||||
53
addons/hr/models/hr_skills.go
Normal file
53
addons/hr/models/hr_skills.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initHrSkill registers hr.skill.type, hr.skill, hr.employee.skill and hr.resume.line.
|
||||
// Mirrors: odoo/addons/hr_skills/models/hr_skill.py
|
||||
func initHrSkill() {
|
||||
orm.NewModel("hr.skill.type", orm.ModelOpts{
|
||||
Description: "Skill Type",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.One2many("skill_ids", "hr.skill", "skill_type_id", orm.FieldOpts{String: "Skills"}),
|
||||
)
|
||||
|
||||
orm.NewModel("hr.skill", orm.ModelOpts{
|
||||
Description: "Skill",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Many2one("skill_type_id", "hr.skill.type", orm.FieldOpts{String: "Skill Type", Required: true}),
|
||||
)
|
||||
|
||||
orm.NewModel("hr.employee.skill", orm.ModelOpts{
|
||||
Description: "Employee Skill",
|
||||
}).AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true, OnDelete: orm.OnDeleteCascade}),
|
||||
orm.Many2one("skill_id", "hr.skill", orm.FieldOpts{String: "Skill", Required: true}),
|
||||
orm.Many2one("skill_type_id", "hr.skill.type", orm.FieldOpts{String: "Skill Type"}),
|
||||
orm.Selection("skill_level", []orm.SelectionItem{
|
||||
{Value: "beginner", Label: "Beginner"},
|
||||
{Value: "intermediate", Label: "Intermediate"},
|
||||
{Value: "advanced", Label: "Advanced"},
|
||||
{Value: "expert", Label: "Expert"},
|
||||
}, orm.FieldOpts{String: "Level", Default: "beginner"}),
|
||||
)
|
||||
|
||||
orm.NewModel("hr.resume.line", orm.ModelOpts{
|
||||
Description: "Resume Line",
|
||||
Order: "date_start desc",
|
||||
}).AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true, OnDelete: orm.OnDeleteCascade}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Description"}),
|
||||
orm.Selection("line_type_id", []orm.SelectionItem{
|
||||
{Value: "experience", Label: "Experience"},
|
||||
{Value: "education", Label: "Education"},
|
||||
{Value: "certification", Label: "Certification"},
|
||||
}, orm.FieldOpts{String: "Type", Default: "experience"}),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,49 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
// Core HR models
|
||||
initResourceCalendar()
|
||||
initHREmployee()
|
||||
initHRDepartment()
|
||||
initHRJob()
|
||||
initHrContract()
|
||||
|
||||
// Employee categories (tags)
|
||||
initHrEmployeeCategory()
|
||||
|
||||
// Leave management
|
||||
initHrLeaveType()
|
||||
initHrLeave()
|
||||
initHrLeaveAllocation()
|
||||
|
||||
// Attendance
|
||||
initHrAttendance()
|
||||
|
||||
// Expenses
|
||||
initHrExpense()
|
||||
|
||||
// Skills & Resume
|
||||
initHrSkill()
|
||||
|
||||
// Payroll (salary rules, structures, payslips)
|
||||
initHrPayroll()
|
||||
|
||||
// Employee public view (read-only subset)
|
||||
initHrEmployeePublic()
|
||||
|
||||
// Extend hr.employee with links to new models (must come last)
|
||||
initHrEmployeeExtensions()
|
||||
|
||||
// Leave extensions (batch approve, remaining quota)
|
||||
initHrLeaveExtensions()
|
||||
initHrLeaveTypeExtensions()
|
||||
|
||||
// Leave report (transient model)
|
||||
initHrLeaveReport()
|
||||
|
||||
// Contract cron methods (after contract model is registered)
|
||||
initHrContractCron()
|
||||
|
||||
// Accrual allocation cron (after allocation model is registered)
|
||||
initHrLeaveAccrualCron()
|
||||
}
|
||||
|
||||
16
addons/mail/models/init.go
Normal file
16
addons/mail/models/init.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package models registers all mail module models.
|
||||
package models
|
||||
|
||||
// Init registers all models for the mail module.
|
||||
// Called by the module loader in dependency order.
|
||||
func Init() {
|
||||
initMailMessage() // mail.message
|
||||
initMailFollowers() // mail.followers
|
||||
initMailActivityType() // mail.activity.type
|
||||
initMailActivity() // mail.activity
|
||||
initMailChannel() // mail.channel + mail.channel.member
|
||||
// Extensions (must come after base models are registered)
|
||||
initMailThread()
|
||||
initMailChannelExtensions()
|
||||
initDiscussBus()
|
||||
}
|
||||
62
addons/mail/models/mail_activity.go
Normal file
62
addons/mail/models/mail_activity.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailActivity registers the mail.activity model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivity
|
||||
func initMailActivity() {
|
||||
m := orm.NewModel("mail.activity", orm.ModelOpts{
|
||||
Description: "Activity",
|
||||
Order: "date_deadline ASC",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Related Document Model",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("activity_type_id", "mail.activity.type", orm.FieldOpts{
|
||||
String: "Activity Type",
|
||||
OnDelete: orm.OnDeleteRestrict,
|
||||
}),
|
||||
orm.Char("summary", orm.FieldOpts{String: "Summary"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Note"}),
|
||||
orm.Date("date_deadline", orm.FieldOpts{
|
||||
String: "Due Date",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{
|
||||
String: "Assigned to",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "overdue", Label: "Overdue"},
|
||||
{Value: "today", Label: "Today"},
|
||||
{Value: "planned", Label: "Planned"},
|
||||
}, orm.FieldOpts{String: "State", Default: "planned"}),
|
||||
orm.Boolean("done", orm.FieldOpts{String: "Done", Default: false}),
|
||||
// Odoo 19: deadline_range for flexible deadline display
|
||||
orm.Integer("deadline_range", orm.FieldOpts{
|
||||
String: "Deadline Range (Days)", Help: "Number of days before/after deadline for grouping",
|
||||
}),
|
||||
)
|
||||
|
||||
// action_done: mark activity as done
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py action_done
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE mail_activity SET done = true WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
39
addons/mail/models/mail_activity_type.go
Normal file
39
addons/mail/models/mail_activity_type.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailActivityType registers the mail.activity.type model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivityType
|
||||
func initMailActivityType() {
|
||||
m := orm.NewModel("mail.activity.type", orm.ModelOpts{
|
||||
Description: "Activity Type",
|
||||
Order: "sequence, id",
|
||||
RecName: "name",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Char("summary", orm.FieldOpts{String: "Default Summary"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Document Model",
|
||||
Help: "Specify a model if this activity type is specific to a model, otherwise it is available for all models.",
|
||||
}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "default", Label: "Other"},
|
||||
{Value: "upload_file", Label: "Upload Document"},
|
||||
}, orm.FieldOpts{String: "Action", Default: "default"}),
|
||||
orm.Integer("delay_count", orm.FieldOpts{
|
||||
String: "Schedule",
|
||||
Default: 0,
|
||||
Help: "Number of days/weeks/months before executing the action.",
|
||||
}),
|
||||
orm.Selection("delay_unit", []orm.SelectionItem{
|
||||
{Value: "days", Label: "days"},
|
||||
{Value: "weeks", Label: "weeks"},
|
||||
{Value: "months", Label: "months"},
|
||||
}, orm.FieldOpts{String: "Delay units", Default: "days"}),
|
||||
orm.Char("icon", orm.FieldOpts{String: "Icon", Help: "Font awesome icon e.g. fa-tasks"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
}
|
||||
424
addons/mail/models/mail_channel.go
Normal file
424
addons/mail/models/mail_channel.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initMailChannel registers mail.channel and mail.channel.member models.
|
||||
// Mirrors: odoo/addons/mail/models/discuss_channel.py
|
||||
func initMailChannel() {
|
||||
m := orm.NewModel("mail.channel", orm.ModelOpts{
|
||||
Description: "Discussion Channel",
|
||||
Order: "name",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Selection("channel_type", []orm.SelectionItem{
|
||||
{Value: "channel", Label: "Channel"},
|
||||
{Value: "chat", Label: "Direct Message"},
|
||||
{Value: "group", Label: "Group"},
|
||||
}, orm.FieldOpts{String: "Type", Default: "channel", Required: true}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Description"}),
|
||||
orm.Many2one("create_uid", "res.users", orm.FieldOpts{String: "Created By", Readonly: true}),
|
||||
orm.Boolean("public", orm.FieldOpts{String: "Public", Default: true,
|
||||
Help: "If true, any internal user can join. If false, invitation only."}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Many2one("group_id", "res.groups", orm.FieldOpts{String: "Authorized Group"}),
|
||||
orm.One2many("member_ids", "mail.channel.member", "channel_id", orm.FieldOpts{String: "Members"}),
|
||||
orm.Integer("member_count", orm.FieldOpts{
|
||||
String: "Member Count", Compute: "_compute_member_count",
|
||||
}),
|
||||
orm.Many2one("last_message_id", "mail.message", orm.FieldOpts{String: "Last Message"}),
|
||||
orm.Datetime("last_message_date", orm.FieldOpts{String: "Last Message Date"}),
|
||||
)
|
||||
|
||||
m.RegisterCompute("member_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var count int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = $1`, id,
|
||||
).Scan(&count); err != nil {
|
||||
return orm.Values{"member_count": int64(0)}, nil
|
||||
}
|
||||
return orm.Values{"member_count": count}, nil
|
||||
})
|
||||
|
||||
// action_join: Current user joins the channel.
|
||||
m.RegisterMethod("action_join", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
channelID := rs.IDs()[0]
|
||||
|
||||
// Get current user's partner
|
||||
var partnerID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&partnerID); err != nil || partnerID == 0 {
|
||||
return nil, fmt.Errorf("mail.channel: cannot find partner for user %d", env.UID())
|
||||
}
|
||||
|
||||
// Check not already member
|
||||
var exists bool
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT EXISTS(SELECT 1 FROM mail_channel_member
|
||||
WHERE channel_id = $1 AND partner_id = $2)`, channelID, partnerID,
|
||||
).Scan(&exists)
|
||||
if exists {
|
||||
return true, nil // Already a member
|
||||
}
|
||||
|
||||
memberRS := env.Model("mail.channel.member")
|
||||
if _, err := memberRS.Create(orm.Values{
|
||||
"channel_id": channelID,
|
||||
"partner_id": partnerID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: join %d: %w", channelID, err)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_leave: Current user leaves the channel.
|
||||
m.RegisterMethod("action_leave", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
channelID := rs.IDs()[0]
|
||||
|
||||
var partnerID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&partnerID)
|
||||
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`DELETE FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`,
|
||||
channelID, partnerID); err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: leave %d: %w", channelID, err)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// message_post: Post a message to the channel.
|
||||
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
channelID := rs.IDs()[0]
|
||||
|
||||
body := ""
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["body"].(string); ok {
|
||||
body = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&authorID)
|
||||
|
||||
var msgID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('mail.channel', $1, $2, 'comment', $3, NOW(), $4, $4, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
channelID, body, authorID, env.UID(),
|
||||
).Scan(&msgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: post message: %w", err)
|
||||
}
|
||||
|
||||
// Update channel last message
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE mail_channel SET last_message_id = $1, last_message_date = NOW() WHERE id = $2`,
|
||||
msgID, channelID)
|
||||
|
||||
return msgID, nil
|
||||
})
|
||||
|
||||
// get_messages: Get messages for a channel.
|
||||
m.RegisterMethod("get_messages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
channelID := rs.IDs()[0]
|
||||
|
||||
limit := 50
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = 'mail.channel' AND m.res_id = $1
|
||||
ORDER BY m.id DESC LIMIT $2`, channelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: get_messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, authorID int64
|
||||
var body, authorName string
|
||||
var date interface{}
|
||||
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id,
|
||||
"body": body,
|
||||
"date": date,
|
||||
}
|
||||
if authorID > 0 {
|
||||
msg["author_id"] = []interface{}{authorID, authorName}
|
||||
} else {
|
||||
msg["author_id"] = false
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
return messages, nil
|
||||
})
|
||||
|
||||
// channel_get: Get or create a direct message channel between current user and partner.
|
||||
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_get()
|
||||
m.RegisterMethod("channel_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("mail.channel: channel_get requires partner_ids")
|
||||
}
|
||||
|
||||
var partnerIDs []int64
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if pids, ok := kw["partner_ids"].([]interface{}); ok {
|
||||
for _, pid := range pids {
|
||||
switch v := pid.(type) {
|
||||
case float64:
|
||||
partnerIDs = append(partnerIDs, int64(v))
|
||||
case int64:
|
||||
partnerIDs = append(partnerIDs, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add current user's partner
|
||||
var myPartnerID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&myPartnerID)
|
||||
if myPartnerID > 0 {
|
||||
partnerIDs = append(partnerIDs, myPartnerID)
|
||||
}
|
||||
|
||||
if len(partnerIDs) < 2 {
|
||||
return nil, fmt.Errorf("mail.channel: need at least 2 partners for DM")
|
||||
}
|
||||
|
||||
// Check if DM channel already exists between these partners
|
||||
var existingID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT c.id FROM mail_channel c
|
||||
WHERE c.channel_type = 'chat'
|
||||
AND (SELECT COUNT(*) FROM mail_channel_member m WHERE m.channel_id = c.id) = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM mail_channel_member m
|
||||
WHERE m.channel_id = c.id AND m.partner_id != ALL($2)
|
||||
)
|
||||
LIMIT 1`, len(partnerIDs), partnerIDs,
|
||||
).Scan(&existingID)
|
||||
|
||||
if existingID > 0 {
|
||||
return map[string]interface{}{"id": existingID}, nil
|
||||
}
|
||||
|
||||
// Create new DM channel
|
||||
var partnerName string
|
||||
for _, pid := range partnerIDs {
|
||||
if pid != myPartnerID {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, '') FROM res_partner WHERE id = $1`, pid,
|
||||
).Scan(&partnerName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
channelRS := env.Model("mail.channel")
|
||||
channel, err := channelRS.Create(orm.Values{
|
||||
"name": partnerName,
|
||||
"channel_type": "chat",
|
||||
"public": false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: create DM: %w", err)
|
||||
}
|
||||
channelID := channel.ID()
|
||||
|
||||
// Add members
|
||||
memberRS := env.Model("mail.channel.member")
|
||||
for _, pid := range partnerIDs {
|
||||
memberRS.Create(orm.Values{
|
||||
"channel_id": channelID,
|
||||
"partner_id": pid,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"id": channelID}, nil
|
||||
})
|
||||
|
||||
// -- mail.channel.member --
|
||||
initMailChannelMember()
|
||||
}
|
||||
|
||||
func initMailChannelMember() {
|
||||
m := orm.NewModel("mail.channel.member", orm.ModelOpts{
|
||||
Description: "Channel Member",
|
||||
Order: "id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("channel_id", "mail.channel", orm.FieldOpts{
|
||||
String: "Channel", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||
String: "Partner", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Datetime("last_seen_dt", orm.FieldOpts{String: "Last Seen"}),
|
||||
orm.Many2one("last_seen_message_id", "mail.message", orm.FieldOpts{String: "Last Seen Message"}),
|
||||
orm.Boolean("is_pinned", orm.FieldOpts{String: "Pinned", Default: true}),
|
||||
orm.Boolean("is_muted", orm.FieldOpts{String: "Muted", Default: false}),
|
||||
)
|
||||
|
||||
m.AddSQLConstraint(
|
||||
"unique_channel_partner",
|
||||
"UNIQUE(channel_id, partner_id)",
|
||||
"A partner can only be a member of a channel once.",
|
||||
)
|
||||
|
||||
// mark_as_read: Update last seen timestamp and message.
|
||||
m.RegisterMethod("mark_as_read", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE mail_channel_member SET last_seen_dt = NOW(),
|
||||
last_seen_message_id = (
|
||||
SELECT MAX(m.id) FROM mail_message m
|
||||
WHERE m.model = 'mail.channel'
|
||||
AND m.res_id = (SELECT channel_id FROM mail_channel_member WHERE id = $1)
|
||||
) WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("mail.channel.member: mark_as_read %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initMailChannelExtensions adds unread count compute after message model is registered.
|
||||
func initMailChannelExtensions() {
|
||||
ch := orm.ExtendModel("mail.channel")
|
||||
|
||||
ch.AddFields(
|
||||
orm.Integer("message_unread_count", orm.FieldOpts{
|
||||
String: "Unread Messages", Compute: "_compute_message_unread_count",
|
||||
}),
|
||||
)
|
||||
|
||||
ch.RegisterCompute("message_unread_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
channelID := rs.IDs()[0]
|
||||
|
||||
var partnerID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&partnerID)
|
||||
|
||||
if partnerID == 0 {
|
||||
return orm.Values{"message_unread_count": int64(0)}, nil
|
||||
}
|
||||
|
||||
var lastSeenID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(last_seen_message_id, 0) FROM mail_channel_member
|
||||
WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID,
|
||||
).Scan(&lastSeenID)
|
||||
|
||||
var count int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM mail_message
|
||||
WHERE model = 'mail.channel' AND res_id = $1 AND id > $2`,
|
||||
channelID, lastSeenID,
|
||||
).Scan(&count)
|
||||
|
||||
return orm.Values{"message_unread_count": count}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initDiscussBus registers the message bus polling endpoint logic.
|
||||
func initDiscussBus() {
|
||||
ch := orm.ExtendModel("mail.channel")
|
||||
|
||||
// channel_fetch_preview: Get channel list with last message for discuss sidebar.
|
||||
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_fetch_preview()
|
||||
ch.RegisterMethod("channel_fetch_preview", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
var partnerID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&partnerID)
|
||||
|
||||
if partnerID == 0 {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT c.id, c.name, c.channel_type, c.last_message_date,
|
||||
COALESCE(m.body, ''), COALESCE(p.name, ''),
|
||||
(SELECT COUNT(*) FROM mail_message msg
|
||||
WHERE msg.model = 'mail.channel' AND msg.res_id = c.id
|
||||
AND msg.id > COALESCE(cm.last_seen_message_id, 0)) AS unread
|
||||
FROM mail_channel c
|
||||
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
|
||||
LEFT JOIN mail_message m ON m.id = c.last_message_id
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE c.active = true AND cm.is_pinned = true
|
||||
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail.channel: fetch_preview: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var channels []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, channelType, lastBody, lastAuthor string
|
||||
var lastDate *time.Time
|
||||
var unread int64
|
||||
if err := rows.Scan(&id, &name, &channelType, &lastDate, &lastBody, &lastAuthor, &unread); err != nil {
|
||||
continue
|
||||
}
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"channel_type": channelType,
|
||||
"last_message": lastBody,
|
||||
"last_author": lastAuthor,
|
||||
"last_date": lastDate,
|
||||
"unread_count": unread,
|
||||
})
|
||||
}
|
||||
if channels == nil {
|
||||
channels = []map[string]interface{}{}
|
||||
}
|
||||
return channels, nil
|
||||
})
|
||||
}
|
||||
31
addons/mail/models/mail_followers.go
Normal file
31
addons/mail/models/mail_followers.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailFollowers registers the mail.followers model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_followers.py
|
||||
func initMailFollowers() {
|
||||
m := orm.NewModel("mail.followers", orm.ModelOpts{
|
||||
Description: "Document Followers",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Related Document Model Name",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Required: true,
|
||||
Index: true,
|
||||
Help: "Id of the followed resource",
|
||||
}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||
String: "Related Partner",
|
||||
Required: true,
|
||||
Index: true,
|
||||
OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
)
|
||||
}
|
||||
53
addons/mail/models/mail_message.go
Normal file
53
addons/mail/models/mail_message.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailMessage registers the mail.message model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_message.py
|
||||
func initMailMessage() {
|
||||
m := orm.NewModel("mail.message", orm.ModelOpts{
|
||||
Description: "Message",
|
||||
Order: "id desc",
|
||||
RecName: "subject",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("subject", orm.FieldOpts{String: "Subject"}),
|
||||
orm.Datetime("date", orm.FieldOpts{String: "Date"}),
|
||||
orm.Text("body", orm.FieldOpts{String: "Contents"}),
|
||||
orm.Selection("message_type", []orm.SelectionItem{
|
||||
{Value: "comment", Label: "Comment"},
|
||||
{Value: "notification", Label: "System notification"},
|
||||
{Value: "email", Label: "Email"},
|
||||
{Value: "user_notification", Label: "User Notification"},
|
||||
}, orm.FieldOpts{String: "Type", Required: true, Default: "comment"}),
|
||||
orm.Many2one("author_id", "res.partner", orm.FieldOpts{
|
||||
String: "Author",
|
||||
Index: true,
|
||||
Help: "Author of the message.",
|
||||
}),
|
||||
orm.Char("model", orm.FieldOpts{
|
||||
String: "Related Document Model",
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("parent_id", "mail.message", orm.FieldOpts{
|
||||
String: "Parent Message",
|
||||
OnDelete: orm.OnDeleteSetNull,
|
||||
}),
|
||||
orm.Boolean("starred", orm.FieldOpts{String: "Starred"}),
|
||||
orm.Char("email_from", orm.FieldOpts{String: "From", Help: "Email address of the sender."}),
|
||||
orm.Char("reply_to", orm.FieldOpts{String: "Reply To", Help: "Reply-To address."}),
|
||||
orm.Char("record_name", orm.FieldOpts{
|
||||
String: "Message Record Name",
|
||||
Help: "Name of the document the message is attached to.",
|
||||
}),
|
||||
orm.Many2many("attachment_ids", "ir.attachment", orm.FieldOpts{
|
||||
String: "Attachments",
|
||||
Help: "Attachments linked to this message.",
|
||||
}),
|
||||
)
|
||||
}
|
||||
208
addons/mail/models/mail_thread.go
Normal file
208
addons/mail/models/mail_thread.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
// initMailThread extends existing models with mail.thread functionality.
|
||||
// In Python Odoo, models inherit from mail.thread to get chatter support.
|
||||
// Here we use ExtendModel to add the message fields and methods.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||
func initMailThread() {
|
||||
// Models that support mail.thread chatter
|
||||
threadModels := []string{
|
||||
"res.partner",
|
||||
"sale.order",
|
||||
"purchase.order",
|
||||
"account.move",
|
||||
"stock.picking",
|
||||
"crm.lead",
|
||||
"project.task",
|
||||
}
|
||||
|
||||
for _, modelName := range threadModels {
|
||||
// Check if the model is registered (module may not be loaded)
|
||||
if orm.Registry.Get(modelName) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
m := orm.ExtendModel(modelName)
|
||||
|
||||
m.AddFields(
|
||||
orm.Integer("message_partner_ids_count", orm.FieldOpts{
|
||||
String: "Followers Count",
|
||||
Help: "Number of partners following this document.",
|
||||
}),
|
||||
)
|
||||
|
||||
// message_post: post a new message on the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Parse kwargs from args
|
||||
body := ""
|
||||
messageType := "comment"
|
||||
subject := ""
|
||||
var attachmentIDs []int64
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["body"].(string); ok {
|
||||
body = v
|
||||
}
|
||||
if v, ok := kw["message_type"].(string); ok {
|
||||
messageType = v
|
||||
}
|
||||
if v, ok := kw["subject"].(string); ok {
|
||||
subject = v
|
||||
}
|
||||
if v, ok := kw["attachment_ids"].([]interface{}); ok {
|
||||
for _, aid := range v {
|
||||
switch id := aid.(type) {
|
||||
case float64:
|
||||
attachmentIDs = append(attachmentIDs, int64(id))
|
||||
case int64:
|
||||
attachmentIDs = append(attachmentIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get author from current user's partner_id
|
||||
var authorID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&authorID); err != nil {
|
||||
log.Printf("warning: mail_thread message_post author lookup failed: %v", err)
|
||||
}
|
||||
|
||||
// Create mail.message
|
||||
var msgID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, subject, date, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $7, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
rs.ModelDef().Name(), ids[0], body, messageType, authorID, subject, env.UID(),
|
||||
).Scan(&msgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Link attachments to the message via M2M
|
||||
for _, aid := range attachmentIDs {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO mail_message_ir_attachment_rel (mail_message_id, ir_attachment_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`, msgID, aid)
|
||||
}
|
||||
|
||||
// Notify followers via email
|
||||
notifyFollowers(env, rs.ModelDef().Name(), ids[0], authorID, subject, body)
|
||||
|
||||
return msgID, nil
|
||||
})
|
||||
|
||||
// _message_get_thread: get messages for the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||
m.RegisterMethod("_message_get_thread", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.body, m.message_type, m.date,
|
||||
m.author_id, COALESCE(p.name, ''),
|
||||
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = $1 AND m.res_id = $2
|
||||
ORDER BY m.id DESC`,
|
||||
rs.ModelDef().Name(), ids[0],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var body, msgType, subject, emailFrom string
|
||||
var date interface{}
|
||||
var authorID int64
|
||||
var authorName string
|
||||
|
||||
if err := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id,
|
||||
"body": body,
|
||||
"message_type": msgType,
|
||||
"date": date,
|
||||
"subject": subject,
|
||||
"email_from": emailFrom,
|
||||
}
|
||||
if authorID > 0 {
|
||||
msg["author_id"] = []interface{}{authorID, authorName}
|
||||
} else {
|
||||
msg["author_id"] = false
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
return messages, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notifyFollowers sends email notifications to followers of a document.
|
||||
// Skips the message author to avoid self-notifications.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||
func notifyFollowers(env *orm.Environment, modelName string, resID, authorID int64, subject, body string) {
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT DISTINCT p.email, p.name
|
||||
FROM mail_followers f
|
||||
JOIN res_partner p ON p.id = f.partner_id
|
||||
WHERE f.res_model = $1 AND f.res_id = $2
|
||||
AND f.partner_id != $3
|
||||
AND p.email IS NOT NULL AND p.email != ''`,
|
||||
modelName, resID, authorID)
|
||||
if err != nil {
|
||||
log.Printf("mail: follower lookup failed for %s/%d: %v", modelName, resID, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cfg := tools.LoadSMTPConfig()
|
||||
if cfg.Host == "" {
|
||||
return // SMTP not configured — skip silently
|
||||
}
|
||||
|
||||
emailSubject := subject
|
||||
if emailSubject == "" {
|
||||
emailSubject = fmt.Sprintf("New message on %s", modelName)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var email, name string
|
||||
if err := rows.Scan(&email, &name); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := tools.SendEmail(cfg, email, emailSubject, body); err != nil {
|
||||
log.Printf("mail: failed to notify %s (%s): %v", name, email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
addons/mail/module.go
Normal file
22
addons/mail/module.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Package mail implements Odoo's Mail/Chatter module.
|
||||
// Mirrors: odoo/addons/mail/__manifest__.py
|
||||
package mail
|
||||
|
||||
import (
|
||||
"odoo-go/addons/mail/models"
|
||||
"odoo-go/pkg/modules"
|
||||
)
|
||||
|
||||
func init() {
|
||||
modules.Register(&modules.Module{
|
||||
Name: "mail",
|
||||
Description: "Discuss",
|
||||
Version: "19.0.1.0.0",
|
||||
Category: "Productivity/Discuss",
|
||||
Depends: []string{"base"},
|
||||
Application: true,
|
||||
Installable: true,
|
||||
Sequence: 5,
|
||||
Init: models.Init,
|
||||
})
|
||||
}
|
||||
@@ -198,19 +198,63 @@ func initProductPricelist() {
|
||||
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: "applied_on, min_quantity desc, categ_id desc, id desc",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{
|
||||
String: "Pricelist", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product"}),
|
||||
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"},
|
||||
|
||||
@@ -6,4 +6,15 @@ func Init() {
|
||||
initProjectMilestone()
|
||||
initProjectProject()
|
||||
initProjectTask()
|
||||
initProjectTaskChecklist()
|
||||
initProjectSharing()
|
||||
initProjectUpdate()
|
||||
initProjectTimesheetExtension()
|
||||
initTimesheetReport()
|
||||
initProjectProjectExtension()
|
||||
initProjectTaskExtension()
|
||||
initProjectMilestoneExtension()
|
||||
initProjectTaskRecurrence()
|
||||
initProjectTaskRecurrenceExtension()
|
||||
initProjectSharingWizard()
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ func initProjectTask() {
|
||||
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
|
||||
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
|
||||
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
|
||||
orm.Datetime("planned_date_start", orm.FieldOpts{String: "Planned Start Date"}),
|
||||
orm.Datetime("planned_date_end", orm.FieldOpts{String: "Planned End Date"}),
|
||||
orm.Selection("display_type", []orm.SelectionItem{
|
||||
{Value: "", Label: ""},
|
||||
{Value: "line_section", Label: "Section"},
|
||||
@@ -100,38 +102,54 @@ func initProjectTask() {
|
||||
task.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'done' WHERE id = $1`, id)
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'done' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task: done %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: mark task as cancelled
|
||||
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task: cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_reopen: reopen a cancelled/done task
|
||||
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'open' WHERE id = $1`, id)
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET state = 'open' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task: reopen %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_blocked: set kanban state to blocked
|
||||
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task: blocked %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
task.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task SET active = NOT active WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task: toggle_active %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
@@ -185,3 +203,62 @@ func initProjectTags() {
|
||||
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initProjectTaskChecklist registers the project.task.checklist model.
|
||||
// Mirrors: odoo/addons/project/models/project_task_checklist.py
|
||||
func initProjectTaskChecklist() {
|
||||
m := orm.NewModel("project.task.checklist", orm.ModelOpts{
|
||||
Description: "Task Checklist Item",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("task_id", "project.task", orm.FieldOpts{
|
||||
String: "Task", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Boolean("is_done", orm.FieldOpts{String: "Done", Default: false}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
)
|
||||
|
||||
// action_toggle_done: Toggle the checklist item done status.
|
||||
m.RegisterMethod("action_toggle_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE project_task_checklist SET is_done = NOT is_done WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("project.task.checklist: toggle_done %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initProjectSharing registers the project.sharing model.
|
||||
// Mirrors: odoo/addons/project/models/project_sharing.py
|
||||
func initProjectSharing() {
|
||||
m := orm.NewModel("project.sharing", orm.ModelOpts{
|
||||
Description: "Project Sharing",
|
||||
Order: "id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||
String: "Partner", Required: true, Index: true,
|
||||
}),
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{
|
||||
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Selection("access_level", []orm.SelectionItem{
|
||||
{Value: "read", Label: "Read"},
|
||||
{Value: "edit", Label: "Edit"},
|
||||
{Value: "admin", Label: "Admin"},
|
||||
}, orm.FieldOpts{String: "Access Level", Required: true, Default: "read"}),
|
||||
)
|
||||
|
||||
m.AddSQLConstraint(
|
||||
"unique_partner_project",
|
||||
"UNIQUE(partner_id, project_id)",
|
||||
"A partner can only have one sharing entry per project.",
|
||||
)
|
||||
}
|
||||
|
||||
1208
addons/project/models/project_extend.go
Normal file
1208
addons/project/models/project_extend.go
Normal file
File diff suppressed because it is too large
Load Diff
325
addons/project/models/project_timesheet.go
Normal file
325
addons/project/models/project_timesheet.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initProjectTimesheetExtension extends account.analytic.line with project/timesheet fields.
|
||||
// In Odoo, timesheets are account.analytic.line records with project_id set.
|
||||
// Mirrors: odoo/addons/hr_timesheet/models/hr_timesheet.py
|
||||
//
|
||||
// class AccountAnalyticLine(models.Model):
|
||||
// _inherit = 'account.analytic.line'
|
||||
func initProjectTimesheetExtension() {
|
||||
al := orm.ExtendModel("account.analytic.line")
|
||||
|
||||
al.AddFields(
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{
|
||||
String: "Project", Index: true,
|
||||
}),
|
||||
orm.Many2one("task_id", "project.task", orm.FieldOpts{
|
||||
String: "Task", Index: true,
|
||||
}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Index: true,
|
||||
}),
|
||||
orm.Float("unit_amount", orm.FieldOpts{String: "Duration (Hours)"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "User"}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Selection("encoding_uom_id", []orm.SelectionItem{
|
||||
{Value: "hours", Label: "Hours"},
|
||||
{Value: "days", Label: "Days"},
|
||||
}, orm.FieldOpts{String: "Encoding UoM", Default: "hours"}),
|
||||
)
|
||||
|
||||
// DefaultGet: set date to today, employee from current user
|
||||
al.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
vals["date"] = time.Now().Format("2006-01-02")
|
||||
if env.UID() > 0 {
|
||||
vals["user_id"] = env.UID()
|
||||
// Try to find the employee linked to this user
|
||||
var empID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID()).Scan(&empID)
|
||||
if err == nil && empID > 0 {
|
||||
vals["employee_id"] = empID
|
||||
// Also set department
|
||||
var deptID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT department_id FROM hr_employee WHERE id = $1`, empID).Scan(&deptID)
|
||||
if deptID > 0 {
|
||||
vals["department_id"] = deptID
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
// initTimesheetReport registers a transient model for timesheet reporting.
|
||||
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py
|
||||
func initTimesheetReport() {
|
||||
m := orm.NewModel("hr.timesheet.report", orm.ModelOpts{
|
||||
Description: "Timesheet Analysis Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{String: "Project"}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
|
||||
orm.Many2one("task_id", "project.task", orm.FieldOpts{String: "Task"}),
|
||||
)
|
||||
|
||||
// get_timesheet_data: Aggregated timesheet data for reporting.
|
||||
// Returns: { by_project, by_employee, by_task, summary }
|
||||
m.RegisterMethod("get_timesheet_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// ── Hours by project ──
|
||||
projRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pp.name, 'No Project') AS project,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN project_project pp ON pp.id = aal.project_id
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY pp.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by project query: %w", err)
|
||||
}
|
||||
defer projRows.Close()
|
||||
|
||||
var byProject []map[string]interface{}
|
||||
for projRows.Next() {
|
||||
var name string
|
||||
var hours float64
|
||||
var entries int64
|
||||
if err := projRows.Scan(&name, &hours, &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
byProject = append(byProject, map[string]interface{}{
|
||||
"project": name,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hours by employee ──
|
||||
empRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(he.name, 'Unknown') AS employee,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries,
|
||||
COUNT(DISTINCT aal.project_id) AS projects
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN hr_employee he ON he.id = aal.employee_id
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY he.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by employee query: %w", err)
|
||||
}
|
||||
defer empRows.Close()
|
||||
|
||||
var byEmployee []map[string]interface{}
|
||||
for empRows.Next() {
|
||||
var name string
|
||||
var hours float64
|
||||
var entries, projects int64
|
||||
if err := empRows.Scan(&name, &hours, &entries, &projects); err != nil {
|
||||
continue
|
||||
}
|
||||
byEmployee = append(byEmployee, map[string]interface{}{
|
||||
"employee": name,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
"projects": projects,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hours by task ──
|
||||
taskRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pt.name, 'No Task') AS task,
|
||||
COALESCE(pp.name, 'No Project') AS project,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN project_task pt ON pt.id = aal.task_id
|
||||
LEFT JOIN project_project pp ON pp.id = aal.project_id
|
||||
WHERE aal.project_id IS NOT NULL AND aal.task_id IS NOT NULL
|
||||
GROUP BY pt.name, pp.name
|
||||
ORDER BY hours DESC
|
||||
LIMIT 20`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: by task query: %w", err)
|
||||
}
|
||||
defer taskRows.Close()
|
||||
|
||||
var byTask []map[string]interface{}
|
||||
for taskRows.Next() {
|
||||
var taskName, projName string
|
||||
var hours float64
|
||||
var entries int64
|
||||
if err := taskRows.Scan(&taskName, &projName, &hours, &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
byTask = append(byTask, map[string]interface{}{
|
||||
"task": taskName,
|
||||
"project": projName,
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Summary ──
|
||||
var totalHours float64
|
||||
var totalEntries int64
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(SUM(unit_amount), 0), COUNT(*)
|
||||
FROM account_analytic_line WHERE project_id IS NOT NULL
|
||||
`).Scan(&totalHours, &totalEntries)
|
||||
|
||||
return map[string]interface{}{
|
||||
"by_project": byProject,
|
||||
"by_employee": byEmployee,
|
||||
"by_task": byTask,
|
||||
"summary": map[string]interface{}{
|
||||
"total_hours": totalHours,
|
||||
"total_entries": totalEntries,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_timesheet_pivot_data: Pivot data by employee x task with date breakdown.
|
||||
// Returns rows grouped by employee, columns grouped by task, cells contain hours per date period.
|
||||
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py pivot view
|
||||
m.RegisterMethod("get_timesheet_pivot_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Query: employee x task x week, with hours
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(he.name, 'Unknown') AS employee,
|
||||
COALESCE(pt.name, 'No Task') AS task,
|
||||
date_trunc('week', aal.date) AS week,
|
||||
SUM(aal.unit_amount) AS hours
|
||||
FROM account_analytic_line aal
|
||||
LEFT JOIN hr_employee he ON he.id = aal.employee_id
|
||||
LEFT JOIN project_task pt ON pt.id = aal.task_id
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY he.name, pt.name, date_trunc('week', aal.date)
|
||||
ORDER BY he.name, pt.name, week
|
||||
LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: pivot query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build pivot structure: { rows: [{employee, task, dates: [{week, hours}]}] }
|
||||
type pivotCell struct {
|
||||
Week string `json:"week"`
|
||||
Hours float64 `json:"hours"`
|
||||
}
|
||||
type pivotRow struct {
|
||||
Employee string `json:"employee"`
|
||||
Task string `json:"task"`
|
||||
Dates []pivotCell `json:"dates"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
rowMap := make(map[string]*pivotRow) // key = "employee|task"
|
||||
var allWeeks []string
|
||||
weekSet := make(map[string]bool)
|
||||
|
||||
for rows.Next() {
|
||||
var employee, task string
|
||||
var week time.Time
|
||||
var hours float64
|
||||
if err := rows.Scan(&employee, &task, &week, &hours); err != nil {
|
||||
continue
|
||||
}
|
||||
weekStr := week.Format("2006-01-02")
|
||||
key := employee + "|" + task
|
||||
r, ok := rowMap[key]
|
||||
if !ok {
|
||||
r = &pivotRow{Employee: employee, Task: task}
|
||||
rowMap[key] = r
|
||||
}
|
||||
r.Dates = append(r.Dates, pivotCell{Week: weekStr, Hours: hours})
|
||||
r.Total += hours
|
||||
|
||||
if !weekSet[weekStr] {
|
||||
weekSet[weekStr] = true
|
||||
allWeeks = append(allWeeks, weekStr)
|
||||
}
|
||||
}
|
||||
|
||||
var pivotRows []pivotRow
|
||||
for _, r := range rowMap {
|
||||
pivotRows = append(pivotRows, *r)
|
||||
}
|
||||
|
||||
// Compute totals per employee
|
||||
empTotals := make(map[string]float64)
|
||||
for _, r := range pivotRows {
|
||||
empTotals[r.Employee] += r.Total
|
||||
}
|
||||
var empSummary []map[string]interface{}
|
||||
for emp, total := range empTotals {
|
||||
empSummary = append(empSummary, map[string]interface{}{
|
||||
"employee": emp,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"pivot_rows": pivotRows,
|
||||
"weeks": allWeeks,
|
||||
"employee_totals": empSummary,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
|
||||
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT date_trunc('week', aal.date) AS week,
|
||||
SUM(aal.unit_amount) AS hours,
|
||||
COUNT(*) AS entries,
|
||||
COUNT(DISTINCT aal.employee_id) AS employees
|
||||
FROM account_analytic_line aal
|
||||
WHERE aal.project_id IS NOT NULL
|
||||
GROUP BY week
|
||||
ORDER BY week DESC
|
||||
LIMIT 12`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timesheet_report: weekly query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var week time.Time
|
||||
var hours float64
|
||||
var entries, employees int64
|
||||
if err := rows.Scan(&week, &hours, &entries, &employees); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"week": week.Format("2006-01-02"),
|
||||
"hours": hours,
|
||||
"entries": entries,
|
||||
"employees": employees,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
}
|
||||
69
addons/project/models/project_update.go
Normal file
69
addons/project/models/project_update.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initProjectUpdate registers the project.update model.
|
||||
// Mirrors: odoo/addons/project/models/project_update.py
|
||||
//
|
||||
// class ProjectUpdate(models.Model):
|
||||
// _name = 'project.update'
|
||||
// _description = 'Project Update'
|
||||
// _order = 'date desc'
|
||||
func initProjectUpdate() {
|
||||
m := orm.NewModel("project.update", orm.ModelOpts{
|
||||
Description: "Project Update",
|
||||
Order: "date desc, id desc",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("project_id", "project.project", orm.FieldOpts{
|
||||
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Title", Required: true}),
|
||||
orm.Selection("status", []orm.SelectionItem{
|
||||
{Value: "on_track", Label: "On Track"},
|
||||
{Value: "at_risk", Label: "At Risk"},
|
||||
{Value: "off_track", Label: "Off Track"},
|
||||
{Value: "on_hold", Label: "On Hold"},
|
||||
}, orm.FieldOpts{String: "Status", Required: true, Default: "on_track"}),
|
||||
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
||||
orm.HTML("description", orm.FieldOpts{String: "Description"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Author"}),
|
||||
orm.Float("progress", orm.FieldOpts{String: "Progress (%)"}),
|
||||
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||
)
|
||||
|
||||
// DefaultGet: set date to today, user to current user
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
vals["date"] = time.Now().Format("2006-01-02")
|
||||
if env.UID() > 0 {
|
||||
vals["user_id"] = env.UID()
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// action_open_project: Return an action to open the project.
|
||||
m.RegisterMethod("action_open_project", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
updateID := rs.IDs()[0]
|
||||
var projectID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT project_id FROM project_update WHERE id = $1`, updateID).Scan(&projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("project_update: read update %d: %w", updateID, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "project.project",
|
||||
"res_id": projectID,
|
||||
"view_mode": "form",
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initPurchaseOrder()
|
||||
initPurchaseOrderLine()
|
||||
initPurchaseOrder() // also calls initPurchaseOrderLine()
|
||||
initPurchaseAgreement()
|
||||
initPurchaseReport()
|
||||
initProductSupplierInfo()
|
||||
initAccountMoveLinePurchaseExtension()
|
||||
initPurchaseOrderExtension()
|
||||
initPurchaseOrderWorkflow()
|
||||
initPurchaseOrderLineExtension()
|
||||
initResPartnerPurchaseExtension()
|
||||
initPurchaseOrderAmount()
|
||||
initVendorLeadTime()
|
||||
}
|
||||
|
||||
89
addons/purchase/models/purchase_agreement.go
Normal file
89
addons/purchase/models/purchase_agreement.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line.
|
||||
// Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py
|
||||
|
||||
func initPurchaseAgreement() {
|
||||
m := orm.NewModel("purchase.requisition", orm.ModelOpts{
|
||||
Description: "Purchase Agreement",
|
||||
Order: "name desc",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Reference", Readonly: true, Default: "New"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}),
|
||||
orm.Selection("type_id", []orm.SelectionItem{
|
||||
{Value: "blanket", Label: "Blanket Order"},
|
||||
{Value: "purchase", Label: "Purchase Tender"},
|
||||
}, orm.FieldOpts{String: "Agreement Type", Default: "blanket"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "ongoing", Label: "Confirmed"},
|
||||
{Value: "in_progress", Label: "Bid Selection"},
|
||||
{Value: "open", Label: "Bid Selection"},
|
||||
{Value: "done", Label: "Closed"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Date("date_end", orm.FieldOpts{String: "Agreement Deadline"}),
|
||||
orm.One2many("line_ids", "purchase.requisition.line", "requisition_id", orm.FieldOpts{String: "Lines"}),
|
||||
)
|
||||
|
||||
// action_confirm: draft → ongoing
|
||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("purchase.requisition: confirm %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("purchase.requisition: done %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("purchase.requisition: cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
initPurchaseRequisitionLine()
|
||||
}
|
||||
|
||||
func initPurchaseRequisitionLine() {
|
||||
orm.NewModel("purchase.requisition.line", orm.ModelOpts{
|
||||
Description: "Purchase Agreement Line",
|
||||
Order: "id",
|
||||
}).AddFields(
|
||||
orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{
|
||||
String: "Agreement", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{
|
||||
String: "Product", Required: true,
|
||||
}),
|
||||
orm.Float("product_qty", orm.FieldOpts{String: "Quantity"}),
|
||||
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
||||
)
|
||||
}
|
||||
1442
addons/purchase/models/purchase_extend.go
Normal file
1442
addons/purchase/models/purchase_extend.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,13 @@ func initPurchaseOrder() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Agreement Link --
|
||||
m.AddFields(
|
||||
orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{
|
||||
String: "Purchase Agreement",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Company & Currency --
|
||||
m.AddFields(
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||
@@ -102,6 +109,12 @@ func initPurchaseOrder() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Vendor Reference & Lock --
|
||||
m.AddFields(
|
||||
orm.Char("partner_ref", orm.FieldOpts{String: "Vendor Reference"}),
|
||||
orm.Boolean("locked", orm.FieldOpts{String: "Locked", Default: false}),
|
||||
)
|
||||
|
||||
// -- Notes --
|
||||
m.AddFields(
|
||||
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
|
||||
@@ -134,15 +147,84 @@ func initPurchaseOrder() {
|
||||
return vals
|
||||
}
|
||||
|
||||
// button_confirm: draft → purchase
|
||||
// button_confirm: Validate and confirm PO. Mirrors Python PurchaseOrder.button_confirm().
|
||||
// Skips orders not in draft/sent, checks order lines have products, then either
|
||||
// directly approves (single-step) or sets to "to approve" (double validation).
|
||||
m.RegisterMethod("button_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, poID := range rs.IDs() {
|
||||
var state, name string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(state, 'draft'), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&state, &name)
|
||||
if state != "draft" && state != "sent" {
|
||||
continue // skip already confirmed orders (Python does same)
|
||||
}
|
||||
|
||||
// Validate: all non-section lines must have a product
|
||||
var badLines int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM purchase_order_line
|
||||
WHERE order_id = $1 AND product_id IS NULL
|
||||
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
|
||||
poID).Scan(&badLines)
|
||||
if badLines > 0 {
|
||||
return nil, fmt.Errorf("purchase: some order lines are missing a product on PO %s", name)
|
||||
}
|
||||
|
||||
// Generate sequence if still default
|
||||
if name == "" || name == "/" || name == "New" {
|
||||
seq, err := orm.NextByCode(env, "purchase.order")
|
||||
if err != nil {
|
||||
name = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000)
|
||||
} else {
|
||||
name = seq
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET name = $1 WHERE id = $2`, name, poID)
|
||||
}
|
||||
|
||||
// Double validation: check company setting
|
||||
var poDoubleVal string
|
||||
var companyID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(company_id, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&companyID)
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(po_double_validation, 'one_step') FROM res_company WHERE id = $1`,
|
||||
companyID).Scan(&poDoubleVal)
|
||||
|
||||
if poDoubleVal == "two_step" {
|
||||
// Check if amount exceeds threshold
|
||||
var amountTotal, threshold float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(amount_total::float8, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&amountTotal)
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(po_double_validation_amount::float8, 0) FROM res_company WHERE id = $1`,
|
||||
companyID).Scan(&threshold)
|
||||
|
||||
if amountTotal >= threshold {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET state = 'to approve' WHERE id = $1`, poID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Approve directly
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// button_approve: Approve a PO that is in "to approve" state → purchase.
|
||||
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve()
|
||||
m.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state)
|
||||
if state != "draft" && state != "sent" {
|
||||
return nil, fmt.Errorf("purchase: can only confirm draft orders")
|
||||
if state != "to approve" {
|
||||
return nil, fmt.Errorf("purchase: can only approve orders in 'to approve' state (current: %s)", state)
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id)
|
||||
@@ -150,12 +232,31 @@ func initPurchaseOrder() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// button_cancel
|
||||
// button_cancel: Cancel a PO. Mirrors Python PurchaseOrder.button_cancel().
|
||||
// Checks: locked orders cannot be cancelled; orders with posted bills cannot be cancelled.
|
||||
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
for _, poID := range rs.IDs() {
|
||||
var locked bool
|
||||
var poName string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(locked, false), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&locked, &poName)
|
||||
if locked {
|
||||
return nil, fmt.Errorf("purchase: cannot cancel locked order %s, unlock it first", poName)
|
||||
}
|
||||
|
||||
// Check for non-draft/non-cancelled vendor bills
|
||||
var billCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move
|
||||
WHERE invoice_origin = $1 AND move_type = 'in_invoice'
|
||||
AND state NOT IN ('draft', 'cancel')`, poName).Scan(&billCount)
|
||||
if billCount > 0 {
|
||||
return nil, fmt.Errorf("purchase: cannot cancel order %s, cancel related vendor bills first", poName)
|
||||
}
|
||||
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, id)
|
||||
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
@@ -170,36 +271,51 @@ func initPurchaseOrder() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_create_bill: Generate a vendor bill (account.move in_invoice) from a confirmed PO.
|
||||
// action_create_bill / action_create_invoice: Generate a vendor bill from a confirmed PO.
|
||||
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice()
|
||||
m.RegisterMethod("action_create_bill", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
// Creates account.move (in_invoice) with linked invoice lines, updates qty_invoiced,
|
||||
// and writes purchase_line_id on invoice lines for proper tracking.
|
||||
createBillFn := func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
var billIDs []int64
|
||||
|
||||
for _, poID := range rs.IDs() {
|
||||
var partnerID, companyID, currencyID int64
|
||||
var poName string
|
||||
var fiscalPosID, paymentTermID *int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id, company_id, currency_id FROM purchase_order WHERE id = $1`,
|
||||
poID).Scan(&partnerID, &companyID, ¤cyID)
|
||||
`SELECT partner_id, company_id, currency_id, COALESCE(name, ''),
|
||||
fiscal_position_id, payment_term_id
|
||||
FROM purchase_order WHERE id = $1`,
|
||||
poID).Scan(&partnerID, &companyID, ¤cyID, &poName, &fiscalPosID, &paymentTermID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err)
|
||||
}
|
||||
|
||||
// Check PO state: must be in 'purchase' state
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state)
|
||||
if state != "purchase" {
|
||||
return nil, fmt.Errorf("purchase: can only create bills for confirmed purchase orders (PO %s is %s)", poName, state)
|
||||
}
|
||||
|
||||
// Find purchase journal
|
||||
var journalID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`,
|
||||
companyID).Scan(&journalID)
|
||||
if journalID == 0 {
|
||||
// Fallback: first available journal
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`,
|
||||
companyID).Scan(&journalID)
|
||||
}
|
||||
|
||||
// Read PO lines to generate invoice lines
|
||||
// Read PO lines (skip section/note display types)
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0)
|
||||
`SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0),
|
||||
COALESCE(discount,0), COALESCE(qty_invoiced,0), product_id,
|
||||
COALESCE(display_type, '')
|
||||
FROM purchase_order_line
|
||||
WHERE order_id = $1 ORDER BY sequence, id`, poID)
|
||||
if err != nil {
|
||||
@@ -207,15 +323,20 @@ func initPurchaseOrder() {
|
||||
}
|
||||
|
||||
type poLine struct {
|
||||
name string
|
||||
qty float64
|
||||
price float64
|
||||
discount float64
|
||||
id int64
|
||||
name string
|
||||
qty float64
|
||||
price float64
|
||||
discount float64
|
||||
qtyInvoiced float64
|
||||
productID *int64
|
||||
displayType string
|
||||
}
|
||||
var lines []poLine
|
||||
for rows.Next() {
|
||||
var l poLine
|
||||
if err := rows.Scan(&l.name, &l.qty, &l.price, &l.discount); err != nil {
|
||||
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount,
|
||||
&l.qtyInvoiced, &l.productID, &l.displayType); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,19 +344,43 @@ func initPurchaseOrder() {
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Filter to only lines that need invoicing
|
||||
var invoiceableLines []poLine
|
||||
for _, l := range lines {
|
||||
if l.displayType == "line_section" || l.displayType == "line_note" {
|
||||
continue
|
||||
}
|
||||
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||
if qtyToInvoice > 0 {
|
||||
invoiceableLines = append(invoiceableLines, l)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invoiceableLines) == 0 {
|
||||
continue // nothing to invoice on this PO
|
||||
}
|
||||
|
||||
// Determine invoice_origin
|
||||
invoiceOrigin := poName
|
||||
if invoiceOrigin == "" {
|
||||
invoiceOrigin = fmt.Sprintf("PO%d", poID)
|
||||
}
|
||||
|
||||
// Create the vendor bill
|
||||
var billID int64
|
||||
err = env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO account_move
|
||||
(name, move_type, state, date, partner_id, journal_id, company_id, currency_id, invoice_origin)
|
||||
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5) RETURNING id`,
|
||||
partnerID, journalID, companyID, currencyID,
|
||||
fmt.Sprintf("PO%d", poID)).Scan(&billID)
|
||||
(name, move_type, state, date, partner_id, journal_id, company_id,
|
||||
currency_id, invoice_origin, fiscal_position_id, invoice_payment_term_id)
|
||||
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
partnerID, journalID, companyID, currencyID, invoiceOrigin,
|
||||
fiscalPosID, paymentTermID).Scan(&billID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err)
|
||||
}
|
||||
|
||||
// Try to generate a proper sequence name
|
||||
// Generate sequence name
|
||||
seq, seqErr := orm.NextByCode(env, "account.move.in_invoice")
|
||||
if seqErr != nil {
|
||||
seq, seqErr = orm.NextByCode(env, "account.move")
|
||||
@@ -245,30 +390,58 @@ func initPurchaseOrder() {
|
||||
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
|
||||
}
|
||||
|
||||
// Create invoice lines for each PO line
|
||||
for _, l := range lines {
|
||||
subtotal := l.qty * l.price * (1 - l.discount/100)
|
||||
// Create invoice lines for each invoiceable PO line
|
||||
seq2 := 10
|
||||
for _, l := range invoiceableLines {
|
||||
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||
subtotal := qtyToInvoice * l.price * (1 - l.discount/100)
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO account_move_line
|
||||
(move_id, name, quantity, price_unit, discount, debit, credit, balance,
|
||||
display_type, company_id, journal_id, account_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8,
|
||||
COALESCE((SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
|
||||
billID, l.name, l.qty, l.price, l.discount, subtotal,
|
||||
companyID, journalID)
|
||||
display_type, company_id, journal_id, sequence, purchase_line_id, product_id,
|
||||
account_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8, $9, $10, $11,
|
||||
COALESCE((SELECT id FROM account_account
|
||||
WHERE company_id = $7 AND account_type = 'expense' LIMIT 1),
|
||||
(SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
|
||||
billID, l.name, qtyToInvoice, l.price, l.discount, subtotal,
|
||||
companyID, journalID, seq2, l.id, l.productID)
|
||||
seq2 += 10
|
||||
}
|
||||
|
||||
// Update qty_invoiced on PO lines
|
||||
for _, l := range invoiceableLines {
|
||||
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
|
||||
qtyToInvoice, l.id)
|
||||
}
|
||||
|
||||
billIDs = append(billIDs, billID)
|
||||
|
||||
// Update PO invoice_status
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET invoice_status = 'invoiced' WHERE id = $1`, poID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase: update invoice status for PO %d: %w", poID, err)
|
||||
// Recompute PO invoice_status based on lines
|
||||
var totalQty, totalInvoiced float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0)
|
||||
FROM purchase_order_line WHERE order_id = $1
|
||||
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
|
||||
poID).Scan(&totalQty, &totalInvoiced)
|
||||
invStatus := "no"
|
||||
if totalQty > 0 {
|
||||
if totalInvoiced >= totalQty {
|
||||
invStatus = "invoiced"
|
||||
} else {
|
||||
invStatus = "to invoice"
|
||||
}
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET invoice_status = $1 WHERE id = $2`, invStatus, poID)
|
||||
}
|
||||
return billIDs, nil
|
||||
})
|
||||
}
|
||||
m.RegisterMethod("action_create_bill", createBillFn)
|
||||
// action_create_invoice: Python-standard name for the same operation.
|
||||
m.RegisterMethod("action_create_invoice", createBillFn)
|
||||
|
||||
// BeforeCreate: auto-assign sequence number
|
||||
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
||||
@@ -285,6 +458,11 @@ func initPurchaseOrder() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
|
||||
m.BeforeWrite = orm.StateGuard("purchase_order", "state IN ('done', 'cancel')",
|
||||
[]string{"write_uid", "write_date", "message_partner_ids_count", "locked"},
|
||||
"cannot modify locked/cancelled orders")
|
||||
|
||||
// purchase.order.line — individual line items on a PO
|
||||
initPurchaseOrderLine()
|
||||
}
|
||||
@@ -325,6 +503,9 @@ func initPurchaseOrderLine() {
|
||||
orm.Monetary("price_subtotal", orm.FieldOpts{
|
||||
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Float("price_tax", orm.FieldOpts{
|
||||
String: "Tax", Compute: "_compute_amount", Store: true,
|
||||
}),
|
||||
orm.Monetary("price_total", orm.FieldOpts{
|
||||
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
@@ -333,6 +514,17 @@ func initPurchaseOrderLine() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Invoice Lines & Display --
|
||||
m.AddFields(
|
||||
orm.One2many("invoice_lines", "account.move.line", "purchase_line_id", orm.FieldOpts{
|
||||
String: "Bill Lines", Readonly: true,
|
||||
}),
|
||||
orm.Selection("display_type", []orm.SelectionItem{
|
||||
{Value: "line_section", Label: "Section"},
|
||||
{Value: "line_note", Label: "Note"},
|
||||
}, orm.FieldOpts{String: "Display Type", Default: ""}),
|
||||
)
|
||||
|
||||
// -- Dates --
|
||||
m.AddFields(
|
||||
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),
|
||||
|
||||
292
addons/purchase/models/purchase_report.go
Normal file
292
addons/purchase/models/purchase_report.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initPurchaseReport registers purchase.report — a transient model for purchase analysis.
|
||||
// Mirrors: odoo/addons/purchase/report/purchase_report.py
|
||||
//
|
||||
// class PurchaseReport(models.Model):
|
||||
// _name = 'purchase.report'
|
||||
// _description = 'Purchase Report'
|
||||
// _auto = False
|
||||
func initPurchaseReport() {
|
||||
m := orm.NewModel("purchase.report", orm.ModelOpts{
|
||||
Description: "Purchase Analysis Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Vendor"}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Representative"}),
|
||||
orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "RFQ"},
|
||||
{Value: "purchase", Label: "Purchase Order"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status"}),
|
||||
)
|
||||
|
||||
// get_purchase_data: Aggregated purchase data for dashboards and reports.
|
||||
// Mirrors: odoo/addons/purchase/report/purchase_report.py (SQL view query)
|
||||
// Returns: { months, top_vendors, top_products, summary }
|
||||
m.RegisterMethod("get_purchase_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// ── Spending by month (last 12 months) ──
|
||||
monthRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT date_trunc('month', po.date_order) AS month,
|
||||
COUNT(DISTINCT po.id) AS order_count,
|
||||
COALESCE(SUM(po.amount_total::float8), 0) AS spending,
|
||||
COALESCE(AVG(po.amount_total::float8), 0) AS avg_order
|
||||
FROM purchase_order po
|
||||
WHERE po.state IN ('purchase', 'done')
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 12`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: monthly spending query: %w", err)
|
||||
}
|
||||
defer monthRows.Close()
|
||||
|
||||
var months []map[string]interface{}
|
||||
for monthRows.Next() {
|
||||
var month time.Time
|
||||
var cnt int64
|
||||
var spending, avg float64
|
||||
if err := monthRows.Scan(&month, &cnt, &spending, &avg); err != nil {
|
||||
continue
|
||||
}
|
||||
months = append(months, map[string]interface{}{
|
||||
"month": month.Format("2006-01"),
|
||||
"orders": cnt,
|
||||
"spending": spending,
|
||||
"avg_order": avg,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Top 10 vendors by spending ──
|
||||
vendorRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT p.name,
|
||||
COUNT(DISTINCT po.id) AS orders,
|
||||
COALESCE(SUM(po.amount_total::float8), 0) AS spending,
|
||||
COALESCE(AVG(po.amount_total::float8), 0) AS avg_order
|
||||
FROM purchase_order po
|
||||
JOIN res_partner p ON p.id = po.partner_id
|
||||
WHERE po.state IN ('purchase', 'done')
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY spending DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: top vendors query: %w", err)
|
||||
}
|
||||
defer vendorRows.Close()
|
||||
|
||||
var vendors []map[string]interface{}
|
||||
for vendorRows.Next() {
|
||||
var name string
|
||||
var cnt int64
|
||||
var spending, avg float64
|
||||
if err := vendorRows.Scan(&name, &cnt, &spending, &avg); err != nil {
|
||||
continue
|
||||
}
|
||||
vendors = append(vendors, map[string]interface{}{
|
||||
"vendor": name,
|
||||
"orders": cnt,
|
||||
"spending": spending,
|
||||
"avg_order": avg,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Top 10 products by purchase volume ──
|
||||
prodRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT pt.name,
|
||||
SUM(pol.product_qty) AS qty,
|
||||
COALESCE(SUM(pol.price_subtotal::float8), 0) AS spending,
|
||||
COUNT(DISTINCT pol.order_id) AS order_count
|
||||
FROM purchase_order_line pol
|
||||
JOIN purchase_order po ON po.id = pol.order_id AND po.state IN ('purchase', 'done')
|
||||
JOIN product_product pp ON pp.id = pol.product_id
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pol.product_id IS NOT NULL
|
||||
GROUP BY pt.name
|
||||
ORDER BY spending DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: top products query: %w", err)
|
||||
}
|
||||
defer prodRows.Close()
|
||||
|
||||
var products []map[string]interface{}
|
||||
for prodRows.Next() {
|
||||
var name string
|
||||
var qty, spending float64
|
||||
var orderCnt int64
|
||||
if err := prodRows.Scan(&name, &qty, &spending, &orderCnt); err != nil {
|
||||
continue
|
||||
}
|
||||
products = append(products, map[string]interface{}{
|
||||
"product": name,
|
||||
"qty": qty,
|
||||
"spending": spending,
|
||||
"orders": orderCnt,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Summary totals ──
|
||||
var totalOrders int64
|
||||
var totalSpending, avgOrderValue float64
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COUNT(*), COALESCE(SUM(amount_total::float8), 0), COALESCE(AVG(amount_total::float8), 0)
|
||||
FROM purchase_order WHERE state IN ('purchase', 'done')
|
||||
`).Scan(&totalOrders, &totalSpending, &avgOrderValue)
|
||||
|
||||
return map[string]interface{}{
|
||||
"months": months,
|
||||
"top_vendors": vendors,
|
||||
"top_products": products,
|
||||
"summary": map[string]interface{}{
|
||||
"total_orders": totalOrders,
|
||||
"total_spending": totalSpending,
|
||||
"avg_order_value": avgOrderValue,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_purchases_by_category: Breakdown by product category.
|
||||
// Mirrors: odoo/addons/purchase/report/purchase_report.py (grouped by categ_id)
|
||||
m.RegisterMethod("get_purchases_by_category", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pc.name, 'Uncategorized') AS category,
|
||||
COUNT(DISTINCT pol.order_id) AS orders,
|
||||
SUM(pol.product_qty) AS qty,
|
||||
COALESCE(SUM(pol.price_subtotal::float8), 0) AS spending
|
||||
FROM purchase_order_line pol
|
||||
JOIN purchase_order po ON po.id = pol.order_id AND po.state IN ('purchase', 'done')
|
||||
LEFT JOIN product_product pp ON pp.id = pol.product_id
|
||||
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
LEFT JOIN product_category pc ON pc.id = pt.categ_id
|
||||
WHERE pol.product_id IS NOT NULL
|
||||
GROUP BY pc.name
|
||||
ORDER BY spending DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: category query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var orders int64
|
||||
var qty, spending float64
|
||||
if err := rows.Scan(&name, &orders, &qty, &spending); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"category": name,
|
||||
"orders": orders,
|
||||
"qty": qty,
|
||||
"spending": spending,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
|
||||
// get_bill_status_analysis: Invoice/bill status analysis.
|
||||
// Mirrors: odoo/addons/purchase/report/purchase_report.py (invoice_status grouping)
|
||||
m.RegisterMethod("get_bill_status_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(invoice_status, 'no') AS status,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(amount_total::float8), 0) AS spending
|
||||
FROM purchase_order
|
||||
WHERE state IN ('purchase', 'done')
|
||||
GROUP BY invoice_status
|
||||
ORDER BY spending DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: bill status query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int64
|
||||
var spending float64
|
||||
if err := rows.Scan(&status, &count, &spending); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"status": status,
|
||||
"count": count,
|
||||
"spending": spending,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
|
||||
// get_receipt_analysis: Receipt/delivery status analysis.
|
||||
// Mirrors: odoo/addons/purchase/report/purchase_report.py (receipt_status grouping)
|
||||
m.RegisterMethod("get_receipt_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN COALESCE(SUM(pol.qty_received), 0) = 0 THEN 'pending'
|
||||
WHEN SUM(pol.qty_received) < SUM(pol.product_qty) THEN 'partial'
|
||||
ELSE 'full'
|
||||
END AS status,
|
||||
COUNT(DISTINCT po.id) AS count,
|
||||
COALESCE(SUM(po.amount_total::float8), 0) AS spending
|
||||
FROM purchase_order po
|
||||
LEFT JOIN purchase_order_line pol ON pol.order_id = po.id
|
||||
WHERE po.state IN ('purchase', 'done')
|
||||
GROUP BY po.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase_report: receipt analysis query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Aggregate the per-PO results
|
||||
statusMap := map[string]map[string]interface{}{
|
||||
"pending": {"status": "pending", "count": int64(0), "spending": float64(0)},
|
||||
"partial": {"status": "partial", "count": int64(0), "spending": float64(0)},
|
||||
"full": {"status": "full", "count": int64(0), "spending": float64(0)},
|
||||
}
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int64
|
||||
var spending float64
|
||||
if err := rows.Scan(&status, &count, &spending); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry, ok := statusMap[status]; ok {
|
||||
entry["count"] = entry["count"].(int64) + count
|
||||
entry["spending"] = entry["spending"].(float64) + spending
|
||||
}
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for _, v := range statusMap {
|
||||
if v["count"].(int64) > 0 {
|
||||
results = append(results, v)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,18 @@ func Init() {
|
||||
initSaleOrder()
|
||||
initSaleOrderLine()
|
||||
initResPartnerSaleExtension()
|
||||
initSaleMargin()
|
||||
initSaleOrderTemplate()
|
||||
initSaleOrderTemplateLine()
|
||||
initSaleOrderTemplateOption()
|
||||
initSaleOrderOption()
|
||||
initSaleReport()
|
||||
initSaleOrderWarnMsg()
|
||||
initSaleAdvancePaymentWizard()
|
||||
initSaleOrderExtension()
|
||||
initSaleOrderLineExtension()
|
||||
initSaleOrderDiscount()
|
||||
initResPartnerSaleExtension2()
|
||||
}
|
||||
|
||||
// initResPartnerSaleExtension extends res.partner with sale-specific fields.
|
||||
|
||||
68
addons/sale/models/sale_margin.go
Normal file
68
addons/sale/models/sale_margin.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initSaleMargin adds margin fields to sale.order.line and sale.order.
|
||||
// Mirrors: odoo/addons/sale_margin/models/sale_order_line.py + sale_order.py
|
||||
|
||||
func initSaleMargin() {
|
||||
// -- Extend sale.order.line with margin fields --
|
||||
line := orm.ExtendModel("sale.order.line")
|
||||
line.AddFields(
|
||||
orm.Float("purchase_price", orm.FieldOpts{String: "Cost Price"}),
|
||||
orm.Monetary("margin", orm.FieldOpts{
|
||||
String: "Margin", Compute: "_compute_margin", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Float("margin_percent", orm.FieldOpts{
|
||||
String: "Margin (%)", Compute: "_compute_margin", Store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
computeLineMargin := func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
var subtotal, cost float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(price_subtotal, 0), COALESCE(purchase_price, 0) * COALESCE(product_uom_qty, 0)
|
||||
FROM sale_order_line WHERE id = $1`, lineID,
|
||||
).Scan(&subtotal, &cost)
|
||||
margin := subtotal - cost
|
||||
pct := float64(0)
|
||||
if subtotal > 0 {
|
||||
pct = margin / subtotal * 100
|
||||
}
|
||||
return orm.Values{"margin": margin, "margin_percent": pct}, nil
|
||||
}
|
||||
line.RegisterCompute("margin", computeLineMargin)
|
||||
line.RegisterCompute("margin_percent", computeLineMargin)
|
||||
|
||||
// -- Extend sale.order with total margin --
|
||||
so := orm.ExtendModel("sale.order")
|
||||
so.AddFields(
|
||||
orm.Monetary("margin", orm.FieldOpts{
|
||||
String: "Margin", Compute: "_compute_margin", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Float("margin_percent", orm.FieldOpts{
|
||||
String: "Margin (%)", Compute: "_compute_margin", Store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
computeSOMargin := func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
soID := rs.IDs()[0]
|
||||
var margin, untaxed float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(margin), 0), COALESCE(SUM(price_subtotal), 0)
|
||||
FROM sale_order_line WHERE order_id = $1
|
||||
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||
soID,
|
||||
).Scan(&margin, &untaxed)
|
||||
pct := float64(0)
|
||||
if untaxed > 0 {
|
||||
pct = margin / untaxed * 100
|
||||
}
|
||||
return orm.Values{"margin": margin, "margin_percent": pct}, nil
|
||||
}
|
||||
so.RegisterCompute("margin", computeSOMargin)
|
||||
so.RegisterCompute("margin_percent", computeSOMargin)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func initSaleOrder() {
|
||||
{Value: "draft", Label: "Quotation"},
|
||||
{Value: "sent", Label: "Quotation Sent"},
|
||||
{Value: "sale", Label: "Sales Order"},
|
||||
{Value: "done", Label: "Locked"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
|
||||
)
|
||||
@@ -253,26 +254,82 @@ func initSaleOrder() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
|
||||
m.BeforeWrite = orm.StateGuard("sale_order", "state IN ('done', 'cancel')",
|
||||
[]string{"write_uid", "write_date", "message_partner_ids_count"},
|
||||
"cannot modify locked/cancelled orders")
|
||||
|
||||
// -- Business Methods --
|
||||
|
||||
// action_confirm: draft → sale
|
||||
// Validates required fields, generates sequence number, sets date_order,
|
||||
// creates stock picking for physical products if stock module is loaded.
|
||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_confirm()
|
||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
var state, name string
|
||||
var partnerID int64
|
||||
var dateOrder *time.Time
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM sale_order WHERE id = $1`, id).Scan(&state)
|
||||
`SELECT state, COALESCE(name, '/'), COALESCE(partner_id, 0), date_order
|
||||
FROM sale_order WHERE id = $1`, id,
|
||||
).Scan(&state, &name, &partnerID, &dateOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state != "draft" && state != "sent" {
|
||||
return nil, fmt.Errorf("sale: can only confirm draft/sent orders (current: %s)", state)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if partnerID == 0 {
|
||||
return nil, fmt.Errorf("sale: cannot confirm order %s without a customer", name)
|
||||
}
|
||||
var lineCount int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM sale_order_line WHERE order_id = $1
|
||||
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||
id).Scan(&lineCount)
|
||||
if lineCount == 0 {
|
||||
return nil, fmt.Errorf("sale: cannot confirm order %s without order lines", name)
|
||||
}
|
||||
|
||||
// Generate sequence number if still default
|
||||
if name == "/" || name == "" {
|
||||
seq, seqErr := orm.NextByCode(env, "sale.order")
|
||||
if seqErr != nil {
|
||||
name = fmt.Sprintf("SO/%d", time.Now().UnixNano()%100000)
|
||||
} else {
|
||||
name = seq
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET name = $1 WHERE id = $2`, name, id)
|
||||
}
|
||||
|
||||
// Set date_order if not set
|
||||
if dateOrder == nil {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET date_order = NOW() WHERE id = $1`, id)
|
||||
}
|
||||
|
||||
// Confirm the order
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create stock picking for physical products if stock module is loaded
|
||||
if stockModel := orm.Registry.Get("stock.picking"); stockModel != nil {
|
||||
soRS := env.Model("sale.order").Browse(id)
|
||||
soModel := orm.Registry.Get("sale.order")
|
||||
if fn, ok := soModel.Methods["action_create_delivery"]; ok {
|
||||
if _, err := fn(soRS); err != nil {
|
||||
// Log but don't fail confirmation if delivery creation fails
|
||||
fmt.Printf("sale: warning: could not create delivery for SO %d: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
@@ -305,7 +362,7 @@ func initSaleOrder() {
|
||||
).Scan(&journalID)
|
||||
}
|
||||
if journalID == 0 {
|
||||
journalID = 1 // ultimate fallback
|
||||
return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
|
||||
}
|
||||
|
||||
// Read SO lines
|
||||
@@ -431,11 +488,17 @@ func initSaleOrder() {
|
||||
"credit": baseAmount,
|
||||
"balance": -baseAmount,
|
||||
}
|
||||
if _, err := lineRS.Create(productLineVals); err != nil {
|
||||
invLine, err := lineRS.Create(productLineVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
|
||||
}
|
||||
totalCredit += baseAmount
|
||||
|
||||
// Link SO line to invoice line via M2M
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO sale_order_line_invoice_rel (order_line_id, invoice_line_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`, line.id, invLine.ID())
|
||||
|
||||
// Look up taxes from SO line's tax_id M2M and compute tax lines
|
||||
taxRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT t.id, t.name, t.amount, t.amount_type, COALESCE(t.price_include, false)
|
||||
@@ -549,9 +612,19 @@ func initSaleOrder() {
|
||||
line.qty, line.id)
|
||||
}
|
||||
|
||||
// Update SO invoice_status
|
||||
// Recompute invoice_status based on actual qty_invoiced vs qty
|
||||
var totalQty, totalInvoiced float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_invoiced), 0)
|
||||
FROM sale_order_line WHERE order_id = $1
|
||||
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||
soID).Scan(&totalQty, &totalInvoiced)
|
||||
invStatus := "to invoice"
|
||||
if totalQty > 0 && totalInvoiced >= totalQty {
|
||||
invStatus = "invoiced"
|
||||
}
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET invoice_status = 'invoiced' WHERE id = $1`, soID)
|
||||
`UPDATE sale_order SET invoice_status = $1 WHERE id = $2`, invStatus, soID)
|
||||
}
|
||||
|
||||
if len(invoiceIDs) == 0 {
|
||||
@@ -613,6 +686,16 @@ func initSaleOrder() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_done: Lock a confirmed sale order (state → done).
|
||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_done()
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, soID := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'done' WHERE id = $1 AND state = 'sale'`, soID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_view_invoice: Open invoices linked to this sale order.
|
||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_view_invoice()
|
||||
m.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
@@ -748,6 +831,134 @@ func initSaleOrder() {
|
||||
}
|
||||
return pickingIDs, nil
|
||||
})
|
||||
|
||||
// action_create_down_payment: Create a deposit invoice for a percentage of the SO total.
|
||||
// Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py
|
||||
m.RegisterMethod("action_create_down_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
soID := rs.IDs()[0]
|
||||
percentage := float64(10) // Default 10%
|
||||
if len(args) > 0 {
|
||||
if p, ok := args[0].(float64); ok {
|
||||
percentage = p
|
||||
}
|
||||
}
|
||||
|
||||
var total float64
|
||||
var partnerID, companyID, currencyID, journalID int64
|
||||
var soName string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(amount_total, 0), partner_id, company_id, currency_id,
|
||||
COALESCE(journal_id, 0), COALESCE(name, '')
|
||||
FROM sale_order WHERE id = $1`, soID,
|
||||
).Scan(&total, &partnerID, &companyID, ¤cyID, &journalID, &soName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale: read SO %d for down payment: %w", soID, err)
|
||||
}
|
||||
|
||||
downAmount := total * percentage / 100
|
||||
|
||||
// Find sales journal if not set on SO
|
||||
if journalID == 0 {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_journal
|
||||
WHERE type = 'sale' AND active = true AND company_id = $1
|
||||
ORDER BY sequence, id LIMIT 1`, companyID,
|
||||
).Scan(&journalID)
|
||||
}
|
||||
if journalID == 0 {
|
||||
journalID = 1
|
||||
}
|
||||
|
||||
// Create deposit invoice
|
||||
invoiceRS := env.Model("account.move")
|
||||
inv, err := invoiceRS.Create(orm.Values{
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partnerID,
|
||||
"company_id": companyID,
|
||||
"currency_id": currencyID,
|
||||
"journal_id": journalID,
|
||||
"invoice_origin": soName,
|
||||
"date": time.Now().Format("2006-01-02"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale: create down payment invoice for SO %d: %w", soID, err)
|
||||
}
|
||||
moveID := inv.ID()
|
||||
|
||||
// Find revenue account
|
||||
var revenueAccountID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_account WHERE code = '8300' AND company_id = $1 LIMIT 1`,
|
||||
companyID).Scan(&revenueAccountID)
|
||||
if revenueAccountID == 0 {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_account
|
||||
WHERE account_type LIKE 'income%%' AND company_id = $1
|
||||
ORDER BY code LIMIT 1`, companyID).Scan(&revenueAccountID)
|
||||
}
|
||||
|
||||
// Find receivable account
|
||||
var receivableAccountID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_account
|
||||
WHERE account_type = 'asset_receivable' AND company_id = $1
|
||||
ORDER BY code LIMIT 1`, companyID).Scan(&receivableAccountID)
|
||||
if receivableAccountID == 0 {
|
||||
receivableAccountID = revenueAccountID
|
||||
}
|
||||
|
||||
lineRS := env.Model("account.move.line")
|
||||
|
||||
// Down payment product line (credit)
|
||||
_, err = lineRS.Create(orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": fmt.Sprintf("Down payment of %.0f%%", percentage),
|
||||
"quantity": 1.0,
|
||||
"price_unit": downAmount,
|
||||
"account_id": revenueAccountID,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": currencyID,
|
||||
"partner_id": partnerID,
|
||||
"display_type": "product",
|
||||
"debit": 0.0,
|
||||
"credit": downAmount,
|
||||
"balance": -downAmount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale: create down payment line: %w", err)
|
||||
}
|
||||
|
||||
// Receivable line (debit)
|
||||
_, err = lineRS.Create(orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": "/",
|
||||
"quantity": 1.0,
|
||||
"account_id": receivableAccountID,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
"currency_id": currencyID,
|
||||
"partner_id": partnerID,
|
||||
"display_type": "payment_term",
|
||||
"debit": downAmount,
|
||||
"credit": 0.0,
|
||||
"balance": downAmount,
|
||||
"amount_residual": downAmount,
|
||||
"amount_residual_currency": downAmount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale: create down payment receivable line: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"res_id": moveID,
|
||||
"view_mode": "form",
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleOrderLine registers sale.order.line — individual line items on a sales order.
|
||||
@@ -831,11 +1042,24 @@ func initSaleOrderLine() {
|
||||
orm.Monetary("price_subtotal", orm.FieldOpts{
|
||||
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Float("price_tax", orm.FieldOpts{
|
||||
String: "Total Tax", Compute: "_compute_amount", Store: true,
|
||||
}),
|
||||
orm.Monetary("price_total", orm.FieldOpts{
|
||||
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Invoice link --
|
||||
m.AddFields(
|
||||
orm.Many2many("invoice_line_ids", "account.move.line", orm.FieldOpts{
|
||||
String: "Invoice Lines",
|
||||
Relation: "sale_order_line_invoice_rel",
|
||||
Column1: "order_line_id",
|
||||
Column2: "invoice_line_id",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Display --
|
||||
m.AddFields(
|
||||
orm.Selection("display_type", []orm.SelectionItem{
|
||||
@@ -895,10 +1119,12 @@ func initSaleOrderLine() {
|
||||
|
||||
return orm.Values{
|
||||
"price_subtotal": subtotal,
|
||||
"price_tax": taxTotal,
|
||||
"price_total": subtotal + taxTotal,
|
||||
}, nil
|
||||
}
|
||||
m.RegisterCompute("price_subtotal", computeLineAmount)
|
||||
m.RegisterCompute("price_tax", computeLineAmount)
|
||||
m.RegisterCompute("price_total", computeLineAmount)
|
||||
|
||||
// -- Delivery & Invoicing Quantities --
|
||||
|
||||
1205
addons/sale/models/sale_order_extend.go
Normal file
1205
addons/sale/models/sale_order_extend.go
Normal file
File diff suppressed because it is too large
Load Diff
421
addons/sale/models/sale_report.go
Normal file
421
addons/sale/models/sale_report.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initSaleReport registers sale.report — a transient model for sales analysis.
|
||||
// Mirrors: odoo/addons/sale/report/sale_report.py
|
||||
//
|
||||
// class SaleReport(models.Model):
|
||||
// _name = 'sale.report'
|
||||
// _description = 'Sales Analysis Report'
|
||||
// _auto = False
|
||||
func initSaleReport() {
|
||||
m := orm.NewModel("sale.report", orm.ModelOpts{
|
||||
Description: "Sales Analysis Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Start Date"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{String: "Product Template"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson"}),
|
||||
orm.Many2one("team_id", "crm.team", orm.FieldOpts{String: "Sales Team"}),
|
||||
orm.Many2one("categ_id", "product.category", orm.FieldOpts{String: "Product Category"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Quotation"},
|
||||
{Value: "sale", Label: "Sales Order"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status"}),
|
||||
)
|
||||
|
||||
// get_sales_data: Retrieve aggregated sales data for dashboards and reports.
|
||||
// Mirrors: odoo/addons/sale/report/sale_report.py (the SQL view query logic)
|
||||
// Returns: { months, top_products, top_customers, summary }
|
||||
m.RegisterMethod("get_sales_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// ── Revenue by month (last 12 months) ──
|
||||
monthRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT date_trunc('month', so.date_order) AS month,
|
||||
COUNT(DISTINCT so.id) AS order_count,
|
||||
COALESCE(SUM(so.amount_total::float8), 0) AS revenue,
|
||||
COALESCE(AVG(so.amount_total::float8), 0) AS avg_order
|
||||
FROM sale_order so
|
||||
WHERE so.state IN ('sale', 'done')
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 12`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: monthly revenue query: %w", err)
|
||||
}
|
||||
defer monthRows.Close()
|
||||
|
||||
var months []map[string]interface{}
|
||||
for monthRows.Next() {
|
||||
var month time.Time
|
||||
var cnt int64
|
||||
var rev, avg float64
|
||||
if err := monthRows.Scan(&month, &cnt, &rev, &avg); err != nil {
|
||||
continue
|
||||
}
|
||||
months = append(months, map[string]interface{}{
|
||||
"month": month.Format("2006-01"),
|
||||
"orders": cnt,
|
||||
"revenue": rev,
|
||||
"avg_order": avg,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Top 10 products by revenue ──
|
||||
prodRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT pt.name,
|
||||
SUM(sol.product_uom_qty) AS qty,
|
||||
COALESCE(SUM(sol.price_subtotal::float8), 0) AS revenue,
|
||||
COUNT(DISTINCT sol.order_id) AS order_count
|
||||
FROM sale_order_line sol
|
||||
JOIN sale_order so ON so.id = sol.order_id AND so.state IN ('sale', 'done')
|
||||
JOIN product_product pp ON pp.id = sol.product_id
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE sol.product_id IS NOT NULL
|
||||
GROUP BY pt.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: top products query: %w", err)
|
||||
}
|
||||
defer prodRows.Close()
|
||||
|
||||
var products []map[string]interface{}
|
||||
for prodRows.Next() {
|
||||
var name string
|
||||
var qty, rev float64
|
||||
var orderCnt int64
|
||||
if err := prodRows.Scan(&name, &qty, &rev, &orderCnt); err != nil {
|
||||
continue
|
||||
}
|
||||
products = append(products, map[string]interface{}{
|
||||
"product": name,
|
||||
"qty": qty,
|
||||
"revenue": rev,
|
||||
"orders": orderCnt,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Top 10 customers by revenue ──
|
||||
custRows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT p.name,
|
||||
COUNT(DISTINCT so.id) AS orders,
|
||||
COALESCE(SUM(so.amount_total::float8), 0) AS revenue,
|
||||
COALESCE(AVG(so.amount_total::float8), 0) AS avg_order
|
||||
FROM sale_order so
|
||||
JOIN res_partner p ON p.id = so.partner_id
|
||||
WHERE so.state IN ('sale', 'done')
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: top customers query: %w", err)
|
||||
}
|
||||
defer custRows.Close()
|
||||
|
||||
var customers []map[string]interface{}
|
||||
for custRows.Next() {
|
||||
var name string
|
||||
var cnt int64
|
||||
var rev, avg float64
|
||||
if err := custRows.Scan(&name, &cnt, &rev, &avg); err != nil {
|
||||
continue
|
||||
}
|
||||
customers = append(customers, map[string]interface{}{
|
||||
"customer": name,
|
||||
"orders": cnt,
|
||||
"revenue": rev,
|
||||
"avg_order": avg,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Summary totals ──
|
||||
var totalOrders int64
|
||||
var totalRevenue, avgOrderValue float64
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COUNT(*), COALESCE(SUM(amount_total::float8), 0), COALESCE(AVG(amount_total::float8), 0)
|
||||
FROM sale_order WHERE state IN ('sale', 'done')
|
||||
`).Scan(&totalOrders, &totalRevenue, &avgOrderValue)
|
||||
|
||||
return map[string]interface{}{
|
||||
"months": months,
|
||||
"top_products": products,
|
||||
"top_customers": customers,
|
||||
"summary": map[string]interface{}{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"avg_order_value": avgOrderValue,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_sales_by_salesperson: Breakdown by salesperson.
|
||||
// Mirrors: odoo/addons/sale/report/sale_report.py (grouped by user_id)
|
||||
m.RegisterMethod("get_sales_by_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(rp.name, 'Unassigned') AS salesperson,
|
||||
COUNT(DISTINCT so.id) AS orders,
|
||||
COALESCE(SUM(so.amount_total::float8), 0) AS revenue,
|
||||
COALESCE(AVG(so.amount_total::float8), 0) AS avg_order
|
||||
FROM sale_order so
|
||||
LEFT JOIN res_users ru ON ru.id = so.user_id
|
||||
LEFT JOIN res_partner rp ON rp.id = ru.partner_id
|
||||
WHERE so.state IN ('sale', 'done')
|
||||
GROUP BY rp.name
|
||||
ORDER BY revenue DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: salesperson query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var orders int64
|
||||
var rev, avg float64
|
||||
if err := rows.Scan(&name, &orders, &rev, &avg); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"salesperson": name,
|
||||
"orders": orders,
|
||||
"revenue": rev,
|
||||
"avg_order": avg,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
|
||||
// get_sales_by_category: Breakdown by product category.
|
||||
// Mirrors: odoo/addons/sale/report/sale_report.py (grouped by categ_id)
|
||||
m.RegisterMethod("get_sales_by_category", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(pc.name, 'Uncategorized') AS category,
|
||||
COUNT(DISTINCT sol.order_id) AS orders,
|
||||
SUM(sol.product_uom_qty) AS qty,
|
||||
COALESCE(SUM(sol.price_subtotal::float8), 0) AS revenue
|
||||
FROM sale_order_line sol
|
||||
JOIN sale_order so ON so.id = sol.order_id AND so.state IN ('sale', 'done')
|
||||
LEFT JOIN product_product pp ON pp.id = sol.product_id
|
||||
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
LEFT JOIN product_category pc ON pc.id = pt.categ_id
|
||||
WHERE sol.product_id IS NOT NULL
|
||||
GROUP BY pc.name
|
||||
ORDER BY revenue DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: category query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var orders int64
|
||||
var qty, rev float64
|
||||
if err := rows.Scan(&name, &orders, &qty, &rev); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"category": name,
|
||||
"orders": orders,
|
||||
"qty": qty,
|
||||
"revenue": rev,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
|
||||
// get_invoice_analysis: Invoice status analysis.
|
||||
// Mirrors: odoo/addons/sale/report/sale_report.py (invoice_status grouping)
|
||||
m.RegisterMethod("get_invoice_analysis", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(invoice_status, 'no') AS status,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(amount_total::float8), 0) AS revenue
|
||||
FROM sale_order
|
||||
WHERE state IN ('sale', 'done')
|
||||
GROUP BY invoice_status
|
||||
ORDER BY revenue DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_report: invoice analysis query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int64
|
||||
var rev float64
|
||||
if err := rows.Scan(&status, &count, &rev); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"status": status,
|
||||
"count": count,
|
||||
"revenue": rev,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleOrderWarnMsg registers the sale.order.onchange.warning transient model.
|
||||
// Mirrors: odoo/addons/sale/wizard/sale_order_cancel.py (cancel warning dialog)
|
||||
func initSaleOrderWarnMsg() {
|
||||
m := orm.NewModel("sale.order.cancel", orm.ModelOpts{
|
||||
Description: "Sale Order Cancel",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}),
|
||||
orm.Text("cancel_reason", orm.FieldOpts{String: "Cancellation Reason"}),
|
||||
)
|
||||
|
||||
// action_cancel: Confirm the cancellation of the sale order.
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
wizID := rs.IDs()[0]
|
||||
var orderID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT order_id FROM sale_order_cancel WHERE id = $1`, wizID).Scan(&orderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_cancel: read wizard %d: %w", wizID, err)
|
||||
}
|
||||
soRS := env.Model("sale.order").Browse(orderID)
|
||||
soModel := orm.Registry.Get("sale.order")
|
||||
if fn, ok := soModel.Methods["action_cancel"]; ok {
|
||||
return fn(soRS)
|
||||
}
|
||||
return nil, fmt.Errorf("sale_cancel: action_cancel method not found")
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleAdvancePaymentWizard registers the sale.advance.payment.inv wizard.
|
||||
// Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py
|
||||
//
|
||||
// class SaleAdvancePaymentInv(models.TransientModel):
|
||||
// _name = 'sale.advance.payment.inv'
|
||||
// _description = 'Sales Advance Payment Invoice'
|
||||
func initSaleAdvancePaymentWizard() {
|
||||
m := orm.NewModel("sale.advance.payment.inv", orm.ModelOpts{
|
||||
Description: "Sales Advance Payment Invoice",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Selection("advance_payment_method", []orm.SelectionItem{
|
||||
{Value: "delivered", Label: "Regular invoice"},
|
||||
{Value: "percentage", Label: "Down payment (percentage)"},
|
||||
{Value: "fixed", Label: "Down payment (fixed amount)"},
|
||||
}, orm.FieldOpts{String: "Create Invoice", Default: "delivered", Required: true}),
|
||||
orm.Float("amount", orm.FieldOpts{String: "Down Payment Amount", Default: 0}),
|
||||
orm.Boolean("has_down_payments", orm.FieldOpts{String: "Has down payments"}),
|
||||
orm.Boolean("deduct_down_payments", orm.FieldOpts{String: "Deduct down payments", Default: true}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Down Payment Product"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Float("fixed_amount", orm.FieldOpts{String: "Fixed Amount"}),
|
||||
orm.Integer("count", orm.FieldOpts{String: "Order Count"}),
|
||||
orm.Many2many("sale_order_ids", "sale.order", orm.FieldOpts{String: "Sale Orders"}),
|
||||
)
|
||||
|
||||
// create_invoices: Generate invoices based on the wizard settings.
|
||||
// Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py create_invoices()
|
||||
m.RegisterMethod("create_invoices", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
wizID := rs.IDs()[0]
|
||||
|
||||
// Read wizard settings
|
||||
var method string
|
||||
var amount, fixedAmount float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(advance_payment_method, 'delivered'),
|
||||
COALESCE(amount, 0), COALESCE(fixed_amount, 0)
|
||||
FROM sale_advance_payment_inv WHERE id = $1`, wizID,
|
||||
).Scan(&method, &amount, &fixedAmount)
|
||||
|
||||
// Get linked sale order IDs from the M2M or from context
|
||||
soRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sale_order_id FROM sale_order_sale_advance_payment_inv_rel
|
||||
WHERE sale_advance_payment_inv_id = $1`, wizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_advance_wiz: read SO IDs: %w", err)
|
||||
}
|
||||
defer soRows.Close()
|
||||
|
||||
var soIDs []int64
|
||||
for soRows.Next() {
|
||||
var id int64
|
||||
soRows.Scan(&id)
|
||||
soIDs = append(soIDs, id)
|
||||
}
|
||||
|
||||
if len(soIDs) == 0 {
|
||||
return nil, fmt.Errorf("sale_advance_wiz: no sale orders linked")
|
||||
}
|
||||
|
||||
soModel := orm.Registry.Get("sale.order")
|
||||
|
||||
switch method {
|
||||
case "delivered":
|
||||
// Create regular invoices for all linked SOs
|
||||
soRS := env.Model("sale.order").Browse(soIDs...)
|
||||
if fn, ok := soModel.Methods["create_invoices"]; ok {
|
||||
return fn(soRS)
|
||||
}
|
||||
return nil, fmt.Errorf("sale_advance_wiz: create_invoices method not found")
|
||||
case "percentage":
|
||||
// Create down payment invoices
|
||||
for _, soID := range soIDs {
|
||||
soRS := env.Model("sale.order").Browse(soID)
|
||||
if fn, ok := soModel.Methods["action_create_down_payment"]; ok {
|
||||
if _, err := fn(soRS, amount); err != nil {
|
||||
return nil, fmt.Errorf("sale_advance_wiz: down payment for SO %d: %w", soID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
case "fixed":
|
||||
// Create fixed-amount down payment (treat as percentage by computing %)
|
||||
for _, soID := range soIDs {
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(amount_total::float8, 0) FROM sale_order WHERE id = $1`, soID).Scan(&total)
|
||||
pct := float64(0)
|
||||
if total > 0 {
|
||||
pct = fixedAmount / total * 100
|
||||
}
|
||||
soRS := env.Model("sale.order").Browse(soID)
|
||||
if fn, ok := soModel.Methods["action_create_down_payment"]; ok {
|
||||
if _, err := fn(soRS, pct); err != nil {
|
||||
return nil, fmt.Errorf("sale_advance_wiz: fixed down payment for SO %d: %w", soID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("sale_advance_wiz: unknown method %q", method)
|
||||
})
|
||||
}
|
||||
419
addons/sale/models/sale_template.go
Normal file
419
addons/sale/models/sale_template.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initSaleOrderTemplate registers sale.order.template and sale.order.template.line.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_template.py
|
||||
//
|
||||
// class SaleOrderTemplate(models.Model):
|
||||
// _name = 'sale.order.template'
|
||||
// _description = 'Quotation Template'
|
||||
func initSaleOrderTemplate() {
|
||||
m := orm.NewModel("sale.order.template", orm.ModelOpts{
|
||||
Description: "Quotation Template",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
// -- Identity --
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Integer("number_of_days", orm.FieldOpts{String: "Validity (Days)", Default: 30}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Terms and Conditions", Translate: true}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Many2one("mail_template_id", "mail.template", orm.FieldOpts{String: "Confirmation Mail"}),
|
||||
)
|
||||
|
||||
// -- Lines --
|
||||
m.AddFields(
|
||||
orm.One2many("sale_order_template_line_ids", "sale.order.template.line", "sale_order_template_id", orm.FieldOpts{
|
||||
String: "Lines",
|
||||
}),
|
||||
orm.One2many("sale_order_template_option_ids", "sale.order.template.option", "sale_order_template_id", orm.FieldOpts{
|
||||
String: "Optional Products",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Computed: line_count --
|
||||
m.AddFields(
|
||||
orm.Integer("line_count", orm.FieldOpts{
|
||||
String: "Line Count", Compute: "_compute_line_count",
|
||||
}),
|
||||
)
|
||||
m.RegisterCompute("line_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
templateID := rs.IDs()[0]
|
||||
var count int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM sale_order_template_line WHERE sale_order_template_id = $1`,
|
||||
templateID).Scan(&count)
|
||||
if err != nil {
|
||||
count = 0
|
||||
}
|
||||
return orm.Values{"line_count": count}, nil
|
||||
})
|
||||
|
||||
// action_apply_to_order: Apply this template to a sale order.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order.py SaleOrder._onchange_sale_order_template_id()
|
||||
// Copies template lines into the SO as order lines, and copies the template note.
|
||||
m.RegisterMethod("action_apply_to_order", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("order_id required")
|
||||
}
|
||||
env := rs.Env()
|
||||
templateID := rs.IDs()[0]
|
||||
orderID, _ := args[0].(float64)
|
||||
|
||||
// Read template lines
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT name, product_id, product_uom_qty, price_unit, discount, sequence
|
||||
FROM sale_order_template_line
|
||||
WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sale_template: read lines: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lineRS := env.Model("sale.order.line")
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var prodID *int64
|
||||
var qty, price, disc float64
|
||||
var seq int
|
||||
if err := rows.Scan(&name, &prodID, &qty, &price, &disc, &seq); err != nil {
|
||||
return nil, fmt.Errorf("sale_template: scan line: %w", err)
|
||||
}
|
||||
vals := orm.Values{
|
||||
"order_id": int64(orderID),
|
||||
"name": name,
|
||||
"product_uom_qty": qty,
|
||||
"price_unit": price,
|
||||
"discount": disc,
|
||||
"sequence": seq,
|
||||
}
|
||||
if prodID != nil {
|
||||
vals["product_id"] = *prodID
|
||||
}
|
||||
if _, err := lineRS.Create(vals); err != nil {
|
||||
return nil, fmt.Errorf("sale_template: create SO line: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy template note to the SO
|
||||
var note *string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT note FROM sale_order_template WHERE id = $1`, templateID).Scan(¬e)
|
||||
if note != nil {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET note = $1 WHERE id = $2`, *note, int64(orderID))
|
||||
}
|
||||
|
||||
// Copy validity_date from number_of_days
|
||||
var numDays int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(number_of_days, 0) FROM sale_order_template WHERE id = $1`,
|
||||
templateID).Scan(&numDays)
|
||||
if numDays > 0 {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE sale_order SET validity_date = CURRENT_DATE + $1 WHERE id = $2`,
|
||||
numDays, int64(orderID))
|
||||
}
|
||||
|
||||
// Copy template options as sale.order.option records on the SO
|
||||
optRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), product_id, COALESCE(quantity, 1),
|
||||
COALESCE(price_unit, 0), COALESCE(discount, 0), COALESCE(sequence, 10)
|
||||
FROM sale_order_template_option
|
||||
WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID)
|
||||
if err == nil {
|
||||
optionModel := orm.Registry.Get("sale.order.option")
|
||||
if optionModel != nil {
|
||||
optionRS := env.Model("sale.order.option")
|
||||
for optRows.Next() {
|
||||
var oName string
|
||||
var oProdID *int64
|
||||
var oQty, oPrice, oDisc float64
|
||||
var oSeq int
|
||||
if err := optRows.Scan(&oName, &oProdID, &oQty, &oPrice, &oDisc, &oSeq); err != nil {
|
||||
continue
|
||||
}
|
||||
optVals := orm.Values{
|
||||
"order_id": int64(orderID),
|
||||
"name": oName,
|
||||
"quantity": oQty,
|
||||
"price_unit": oPrice,
|
||||
"discount": oDisc,
|
||||
"sequence": oSeq,
|
||||
"is_present": false,
|
||||
}
|
||||
if oProdID != nil {
|
||||
optVals["product_id"] = *oProdID
|
||||
}
|
||||
optionRS.Create(optVals)
|
||||
}
|
||||
}
|
||||
optRows.Close()
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_open_template: Return an action to open this template's form view.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_template.py
|
||||
m.RegisterMethod("action_open_template", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
templateID := rs.IDs()[0]
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "sale.order.template",
|
||||
"res_id": templateID,
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleOrderTemplateLine registers sale.order.template.line.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateLine
|
||||
func initSaleOrderTemplateLine() {
|
||||
m := orm.NewModel("sale.order.template.line", orm.ModelOpts{
|
||||
Description: "Quotation Template Line",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
|
||||
String: "Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true, Translate: true}),
|
||||
orm.Float("product_uom_qty", orm.FieldOpts{String: "Quantity", Default: 1}),
|
||||
orm.Many2one("product_uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
|
||||
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
||||
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Selection("display_type", []orm.SelectionItem{
|
||||
{Value: "line_section", Label: "Section"},
|
||||
{Value: "line_note", Label: "Note"},
|
||||
}, orm.FieldOpts{String: "Display Type"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
)
|
||||
|
||||
// Onchange: product_id → name + price_unit
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateLine._compute_name()
|
||||
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||
result := make(orm.Values)
|
||||
|
||||
var productID int64
|
||||
switch v := vals["product_id"].(type) {
|
||||
case int64:
|
||||
productID = v
|
||||
case float64:
|
||||
productID = int64(v)
|
||||
case map[string]interface{}:
|
||||
if id, ok := v["id"]; ok {
|
||||
switch n := id.(type) {
|
||||
case float64:
|
||||
productID = int64(n)
|
||||
case int64:
|
||||
productID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if productID <= 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var name string
|
||||
var listPrice float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0)
|
||||
FROM product_product pp
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pp.id = $1`, productID,
|
||||
).Scan(&name, &listPrice)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result["name"] = name
|
||||
result["price_unit"] = listPrice
|
||||
return result
|
||||
})
|
||||
|
||||
// _compute_price_subtotal: qty * price * (1 - discount/100)
|
||||
m.AddFields(
|
||||
orm.Monetary("price_subtotal", orm.FieldOpts{
|
||||
String: "Subtotal", Compute: "_compute_price_subtotal", CurrencyField: "currency_id",
|
||||
}),
|
||||
)
|
||||
m.RegisterCompute("price_subtotal", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
var qty, price, discount float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(product_uom_qty, 0), COALESCE(price_unit, 0), COALESCE(discount, 0)
|
||||
FROM sale_order_template_line WHERE id = $1`, lineID,
|
||||
).Scan(&qty, &price, &discount)
|
||||
return orm.Values{"price_subtotal": qty * price * (1 - discount/100)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleOrderTemplateOption registers sale.order.template.option.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_template.py SaleOrderTemplateOption
|
||||
func initSaleOrderTemplateOption() {
|
||||
m := orm.NewModel("sale.order.template.option", orm.ModelOpts{
|
||||
Description: "Quotation Template Option",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
|
||||
String: "Template", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true, Translate: true}),
|
||||
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
|
||||
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
|
||||
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
||||
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
)
|
||||
|
||||
// Onchange: product_id → name + price_unit
|
||||
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||
result := make(orm.Values)
|
||||
|
||||
var productID int64
|
||||
switch v := vals["product_id"].(type) {
|
||||
case int64:
|
||||
productID = v
|
||||
case float64:
|
||||
productID = int64(v)
|
||||
case map[string]interface{}:
|
||||
if id, ok := v["id"]; ok {
|
||||
switch n := id.(type) {
|
||||
case float64:
|
||||
productID = int64(n)
|
||||
case int64:
|
||||
productID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if productID <= 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var name string
|
||||
var listPrice float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0)
|
||||
FROM product_product pp
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pp.id = $1`, productID,
|
||||
).Scan(&name, &listPrice)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result["name"] = name
|
||||
result["price_unit"] = listPrice
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// initSaleOrderOption registers sale.order.option — optional products on a specific sale order.
|
||||
// When a template with options is applied to an SO, options are copied here.
|
||||
// The customer or salesperson can then choose to add them as order lines.
|
||||
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption
|
||||
func initSaleOrderOption() {
|
||||
m := orm.NewModel("sale.order.option", orm.ModelOpts{
|
||||
Description: "Sale Order Option",
|
||||
Order: "sequence, id",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
|
||||
String: "Sale Order", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||
}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{
|
||||
String: "Product", Required: true,
|
||||
}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
|
||||
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
|
||||
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
|
||||
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
||||
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Boolean("is_present", orm.FieldOpts{
|
||||
String: "Present on Order", Default: false,
|
||||
}),
|
||||
)
|
||||
|
||||
// Onchange: product_id → name + price_unit
|
||||
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||
result := make(orm.Values)
|
||||
|
||||
var productID int64
|
||||
switch v := vals["product_id"].(type) {
|
||||
case int64:
|
||||
productID = v
|
||||
case float64:
|
||||
productID = int64(v)
|
||||
case map[string]interface{}:
|
||||
if id, ok := v["id"]; ok {
|
||||
switch n := id.(type) {
|
||||
case float64:
|
||||
productID = int64(n)
|
||||
case int64:
|
||||
productID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if productID <= 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var name string
|
||||
var listPrice float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0)
|
||||
FROM product_product pp
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pp.id = $1`, productID,
|
||||
).Scan(&name, &listPrice)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result["name"] = name
|
||||
result["price_unit"] = listPrice
|
||||
return result
|
||||
})
|
||||
|
||||
// button_add: Add this option as an order line. Delegates to sale.order action_add_option.
|
||||
m.RegisterMethod("button_add", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
optionID := rs.IDs()[0]
|
||||
|
||||
var orderID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(order_id, 0) FROM sale_order_option WHERE id = $1`, optionID,
|
||||
).Scan(&orderID)
|
||||
if err != nil || orderID == 0 {
|
||||
return nil, fmt.Errorf("sale_option: no order linked to option %d", optionID)
|
||||
}
|
||||
|
||||
soRS := env.Model("sale.order").Browse(orderID)
|
||||
soModel := orm.Registry.Get("sale.order")
|
||||
if fn, ok := soModel.Methods["action_add_option"]; ok {
|
||||
return fn(soRS, float64(optionID))
|
||||
}
|
||||
return nil, fmt.Errorf("sale_option: action_add_option not found on sale.order")
|
||||
})
|
||||
}
|
||||
@@ -2,4 +2,5 @@ package models
|
||||
|
||||
func Init() {
|
||||
initStock()
|
||||
initStockIntrastat()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
377
addons/stock/models/stock_barcode.go
Normal file
377
addons/stock/models/stock_barcode.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initStockBarcode registers stock.barcode.picking — transient model for barcode scanning interface.
|
||||
// Mirrors: odoo/addons/stock_barcode/models/stock_picking.py barcode processing
|
||||
func initStockBarcode() {
|
||||
m := orm.NewModel("stock.barcode.picking", orm.ModelOpts{
|
||||
Description: "Barcode Picking Interface",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{String: "Transfer"}),
|
||||
orm.Char("barcode", orm.FieldOpts{String: "Barcode"}),
|
||||
)
|
||||
|
||||
// process_barcode: Look up a product or lot/serial by barcode.
|
||||
// Mirrors: stock_barcode barcode scanning logic
|
||||
m.RegisterMethod("process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("barcode required")
|
||||
}
|
||||
barcode, _ := args[0].(string)
|
||||
|
||||
// Try to find product by barcode
|
||||
var productID int64
|
||||
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 OR pp.barcode = $1 LIMIT 1`, barcode,
|
||||
).Scan(&productID)
|
||||
|
||||
if productID > 0 {
|
||||
return map[string]interface{}{"product_id": productID, "found": true}, nil
|
||||
}
|
||||
|
||||
// Try lot/serial
|
||||
var lotID, lotProductID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id, product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
|
||||
).Scan(&lotID, &lotProductID)
|
||||
|
||||
if lotID > 0 {
|
||||
return map[string]interface{}{"lot_id": lotID, "product_id": lotProductID, "found": true}, nil
|
||||
}
|
||||
|
||||
// Try package
|
||||
var packageID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM stock_quant_package WHERE name = $1 LIMIT 1`, barcode,
|
||||
).Scan(&packageID)
|
||||
|
||||
if packageID > 0 {
|
||||
return map[string]interface{}{"package_id": packageID, "found": true}, nil
|
||||
}
|
||||
|
||||
// Try location barcode
|
||||
var locationID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM stock_location WHERE barcode = $1 LIMIT 1`, barcode,
|
||||
).Scan(&locationID)
|
||||
|
||||
if locationID > 0 {
|
||||
return map[string]interface{}{"location_id": locationID, "found": true}, nil
|
||||
}
|
||||
|
||||
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
|
||||
m.RegisterMethod("process_barcode_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("stock.barcode.picking.process_barcode_picking 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()
|
||||
|
||||
// Find product by barcode
|
||||
var productID int64
|
||||
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 OR pp.barcode = $1 LIMIT 1`, barcode,
|
||||
).Scan(&productID)
|
||||
|
||||
if productID == 0 {
|
||||
// Try lot/serial
|
||||
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", barcode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Find matching move line
|
||||
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 != 'done'
|
||||
ORDER BY sml.id LIMIT 1`,
|
||||
pickingID, productID,
|
||||
).Scan(&moveLineID)
|
||||
|
||||
if err != nil || moveLineID == 0 {
|
||||
// No existing move line — check if there is a move for this product
|
||||
var moveID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state != 'done' 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
|
||||
})
|
||||
|
||||
// get_picking_data: Return the full picking data for the barcode interface.
|
||||
// Mirrors: stock_barcode.picking get_barcode_data()
|
||||
m.RegisterMethod("get_picking_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.barcode.picking.get_picking_data requires picking_id")
|
||||
}
|
||||
pickingID, _ := args[0].(int64)
|
||||
if pickingID == 0 {
|
||||
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
// Get picking header
|
||||
var pickingName, state string
|
||||
var srcLocID, dstLocID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT name, state, location_id, location_dest_id
|
||||
FROM stock_picking WHERE id = $1`, pickingID,
|
||||
).Scan(&pickingName, &state, &srcLocID, &dstLocID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.barcode.picking: read picking %d: %w", pickingID, err)
|
||||
}
|
||||
|
||||
// Get source/dest location names
|
||||
var srcLocName, dstLocName string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, srcLocID,
|
||||
).Scan(&srcLocName)
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, dstLocID,
|
||||
).Scan(&dstLocName)
|
||||
|
||||
// Get move lines
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sm.id, sm.product_id, pt.name as product_name,
|
||||
sm.product_uom_qty as demand,
|
||||
COALESCE(SUM(sml.quantity), 0) as done_qty
|
||||
FROM stock_move sm
|
||||
JOIN product_product pp ON pp.id = sm.product_id
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
LEFT JOIN stock_move_line sml ON sml.move_id = sm.id
|
||||
WHERE sm.picking_id = $1 AND sm.state != 'cancel'
|
||||
GROUP BY sm.id, sm.product_id, pt.name, sm.product_uom_qty
|
||||
ORDER BY pt.name`,
|
||||
pickingID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.barcode.picking: query moves for %d: %w", pickingID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var moveLines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var moveID, prodID int64
|
||||
var prodName string
|
||||
var demand, doneQty float64
|
||||
if err := rows.Scan(&moveID, &prodID, &prodName, &demand, &doneQty); err != nil {
|
||||
return nil, fmt.Errorf("stock.barcode.picking: scan move: %w", err)
|
||||
}
|
||||
moveLines = append(moveLines, map[string]interface{}{
|
||||
"move_id": moveID, "product_id": prodID, "product": prodName,
|
||||
"demand": demand, "done": doneQty,
|
||||
"remaining": demand - doneQty,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"picking_id": pickingID,
|
||||
"name": pickingName,
|
||||
"state": state,
|
||||
"source_location": srcLocName,
|
||||
"dest_location": dstLocName,
|
||||
"lines": moveLines,
|
||||
}, 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
|
||||
}
|
||||
379
addons/stock/models/stock_landed_cost.go
Normal file
379
addons/stock/models/stock_landed_cost.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initStockLandedCost registers stock.landed.cost, stock.landed.cost.lines,
|
||||
// and stock.valuation.adjustment.lines — landed cost allocation on transfers.
|
||||
// Mirrors: odoo/addons/stock_landed_costs/models/stock_landed_cost.py
|
||||
func initStockLandedCost() {
|
||||
m := orm.NewModel("stock.landed.cost", orm.ModelOpts{
|
||||
Description: "Landed Costs",
|
||||
Order: "date desc, id desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Default: "New"}),
|
||||
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
||||
orm.Many2many("picking_ids", "stock.picking", orm.FieldOpts{String: "Transfers"}),
|
||||
orm.One2many("cost_lines", "stock.landed.cost.lines", "cost_id", orm.FieldOpts{String: "Cost Lines"}),
|
||||
orm.One2many("valuation_adjustment_lines", "stock.valuation.adjustment.lines", "cost_id", orm.FieldOpts{String: "Valuation Adjustments"}),
|
||||
orm.Many2one("account_journal_id", "account.journal", orm.FieldOpts{String: "Journal"}),
|
||||
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "done", Label: "Posted"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount_total", CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
)
|
||||
|
||||
// _compute_amount_total: Sum of all cost lines.
|
||||
// Mirrors: stock.landed.cost._compute_amount_total()
|
||||
m.RegisterCompute("amount_total", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
costID := rs.IDs()[0]
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(price_unit), 0) FROM stock_landed_cost_lines WHERE cost_id = $1`,
|
||||
costID,
|
||||
).Scan(&total)
|
||||
return orm.Values{"amount_total": total}, nil
|
||||
})
|
||||
|
||||
// compute_landed_cost: Compute and create valuation adjustment lines based on split method.
|
||||
// Mirrors: stock.landed.cost.compute_landed_cost()
|
||||
m.RegisterMethod("compute_landed_cost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, costID := range rs.IDs() {
|
||||
// Delete existing adjustment lines
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`DELETE FROM stock_valuation_adjustment_lines WHERE cost_id = $1`, costID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: clear adjustments for %d: %w", costID, err)
|
||||
}
|
||||
|
||||
// Get all moves from associated pickings
|
||||
moveRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit
|
||||
FROM stock_move sm
|
||||
JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||
JOIN stock_landed_cost_stock_picking_rel rel ON rel.stock_picking_id = sp.id
|
||||
WHERE rel.stock_landed_cost_id = $1 AND sm.state = 'done'`,
|
||||
costID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: query moves for %d: %w", costID, err)
|
||||
}
|
||||
|
||||
type moveInfo struct {
|
||||
ID int64
|
||||
ProductID int64
|
||||
Qty float64
|
||||
UnitCost float64
|
||||
}
|
||||
var moves []moveInfo
|
||||
var totalQty, totalWeight, totalVolume, totalCost float64
|
||||
|
||||
for moveRows.Next() {
|
||||
var mi moveInfo
|
||||
if err := moveRows.Scan(&mi.ID, &mi.ProductID, &mi.Qty, &mi.UnitCost); err != nil {
|
||||
moveRows.Close()
|
||||
return nil, fmt.Errorf("stock.landed.cost: scan move: %w", err)
|
||||
}
|
||||
moves = append(moves, mi)
|
||||
totalQty += mi.Qty
|
||||
totalCost += mi.Qty * mi.UnitCost
|
||||
}
|
||||
moveRows.Close()
|
||||
|
||||
if len(moves) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get cost lines
|
||||
costLineRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, price_unit, split_method FROM stock_landed_cost_lines WHERE cost_id = $1`,
|
||||
costID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: query cost lines for %d: %w", costID, err)
|
||||
}
|
||||
|
||||
type costLineInfo struct {
|
||||
ID int64
|
||||
PriceUnit float64
|
||||
SplitMethod string
|
||||
}
|
||||
var costLines []costLineInfo
|
||||
for costLineRows.Next() {
|
||||
var cl costLineInfo
|
||||
if err := costLineRows.Scan(&cl.ID, &cl.PriceUnit, &cl.SplitMethod); err != nil {
|
||||
costLineRows.Close()
|
||||
return nil, fmt.Errorf("stock.landed.cost: scan cost line: %w", err)
|
||||
}
|
||||
costLines = append(costLines, cl)
|
||||
}
|
||||
costLineRows.Close()
|
||||
|
||||
// For each cost line, distribute costs across moves
|
||||
for _, cl := range costLines {
|
||||
for _, mv := range moves {
|
||||
var share float64
|
||||
switch cl.SplitMethod {
|
||||
case "equal":
|
||||
share = cl.PriceUnit / float64(len(moves))
|
||||
case "by_quantity":
|
||||
if totalQty > 0 {
|
||||
share = cl.PriceUnit * mv.Qty / totalQty
|
||||
}
|
||||
case "by_current_cost_price":
|
||||
moveCost := mv.Qty * mv.UnitCost
|
||||
if totalCost > 0 {
|
||||
share = cl.PriceUnit * moveCost / totalCost
|
||||
}
|
||||
case "by_weight":
|
||||
// Simplified: use quantity as proxy for weight
|
||||
if totalWeight > 0 {
|
||||
share = cl.PriceUnit * mv.Qty / totalWeight
|
||||
} else if totalQty > 0 {
|
||||
share = cl.PriceUnit * mv.Qty / totalQty
|
||||
}
|
||||
case "by_volume":
|
||||
// Simplified: use quantity as proxy for volume
|
||||
if totalVolume > 0 {
|
||||
share = cl.PriceUnit * mv.Qty / totalVolume
|
||||
} else if totalQty > 0 {
|
||||
share = cl.PriceUnit * mv.Qty / totalQty
|
||||
}
|
||||
default:
|
||||
share = cl.PriceUnit / float64(len(moves))
|
||||
}
|
||||
|
||||
formerCost := mv.Qty * mv.UnitCost
|
||||
finalCost := formerCost + share
|
||||
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO stock_valuation_adjustment_lines
|
||||
(cost_id, cost_line_id, move_id, product_id, quantity, former_cost, additional_landed_cost, final_cost)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
costID, cl.ID, mv.ID, mv.ProductID, mv.Qty, formerCost, share, finalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: create adjustment line: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// button_validate: Post the landed cost, apply valuation adjustments, and set state to done.
|
||||
// Mirrors: stock.landed.cost.button_validate()
|
||||
m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, costID := range rs.IDs() {
|
||||
// First compute the cost distribution
|
||||
lcModel := orm.Registry.Get("stock.landed.cost")
|
||||
if lcModel != nil {
|
||||
if computeMethod, ok := lcModel.Methods["compute_landed_cost"]; ok {
|
||||
lcRS := env.Model("stock.landed.cost").Browse(costID)
|
||||
if _, err := computeMethod(lcRS); err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: compute for validate %d: %w", costID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply adjustments to valuation layers
|
||||
adjRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT move_id, product_id, additional_landed_cost
|
||||
FROM stock_valuation_adjustment_lines
|
||||
WHERE cost_id = $1 AND additional_landed_cost != 0`,
|
||||
costID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: query adjustments for %d: %w", costID, err)
|
||||
}
|
||||
|
||||
type adjInfo struct {
|
||||
MoveID int64
|
||||
ProductID int64
|
||||
AdditionalCost float64
|
||||
}
|
||||
var adjustments []adjInfo
|
||||
for adjRows.Next() {
|
||||
var adj adjInfo
|
||||
if err := adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost); err != nil {
|
||||
adjRows.Close()
|
||||
return nil, fmt.Errorf("stock.landed.cost: scan adjustment: %w", err)
|
||||
}
|
||||
adjustments = append(adjustments, adj)
|
||||
}
|
||||
adjRows.Close()
|
||||
|
||||
for _, adj := range adjustments {
|
||||
// Update the corresponding valuation layer remaining_value
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_valuation_layer
|
||||
SET remaining_value = remaining_value + $1, value = value + $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 {
|
||||
// Non-fatal: layer might not exist yet
|
||||
fmt.Printf("stock.landed.cost: update valuation layer for move %d: %v\n", adj.MoveID, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_landed_cost SET state = 'done' WHERE id = $1`, costID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: validate %d: %w", costID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: Reverse the landed cost and set state to cancelled.
|
||||
// Mirrors: stock.landed.cost.action_cancel()
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, costID := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM stock_landed_cost WHERE id = $1`, costID,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: read state for %d: %w", costID, err)
|
||||
}
|
||||
|
||||
if state == "done" {
|
||||
// Reverse valuation adjustments
|
||||
adjRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT move_id, product_id, additional_landed_cost
|
||||
FROM stock_valuation_adjustment_lines
|
||||
WHERE cost_id = $1 AND additional_landed_cost != 0`,
|
||||
costID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: query adjustments for cancel %d: %w", costID, err)
|
||||
}
|
||||
|
||||
type adjInfo struct {
|
||||
MoveID int64
|
||||
ProductID int64
|
||||
AdditionalCost float64
|
||||
}
|
||||
var adjustments []adjInfo
|
||||
for adjRows.Next() {
|
||||
var adj adjInfo
|
||||
adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost)
|
||||
adjustments = append(adjustments, adj)
|
||||
}
|
||||
adjRows.Close()
|
||||
|
||||
for _, adj := range adjustments {
|
||||
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`,
|
||||
adj.AdditionalCost, adj.MoveID, adj.ProductID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_landed_cost SET state = 'cancel' WHERE id = $1`, costID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: cancel %d: %w", costID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// get_cost_summary: Return a summary of landed cost distribution.
|
||||
// Mirrors: stock.landed.cost views / reporting
|
||||
m.RegisterMethod("get_cost_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
costID := rs.IDs()[0]
|
||||
|
||||
// Get totals by product
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT val.product_id,
|
||||
COALESCE(SUM(val.former_cost), 0) as total_former,
|
||||
COALESCE(SUM(val.additional_landed_cost), 0) as total_additional,
|
||||
COALESCE(SUM(val.final_cost), 0) as total_final
|
||||
FROM stock_valuation_adjustment_lines val
|
||||
WHERE val.cost_id = $1
|
||||
GROUP BY val.product_id
|
||||
ORDER BY val.product_id`,
|
||||
costID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: query summary for %d: %w", costID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var productID int64
|
||||
var former, additional, final float64
|
||||
if err := rows.Scan(&productID, &former, &additional, &final); err != nil {
|
||||
return nil, fmt.Errorf("stock.landed.cost: scan summary row: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"former_cost": former,
|
||||
"additional_landed_cost": additional,
|
||||
"final_cost": final,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"summary": lines}, nil
|
||||
})
|
||||
|
||||
// --- Sub-models ---
|
||||
|
||||
// stock.landed.cost.lines — individual cost items on a landed cost
|
||||
orm.NewModel("stock.landed.cost.lines", orm.ModelOpts{
|
||||
Description: "Landed Cost Lines",
|
||||
}).AddFields(
|
||||
orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Float("price_unit", orm.FieldOpts{String: "Cost"}),
|
||||
orm.Selection("split_method", []orm.SelectionItem{
|
||||
{Value: "equal", Label: "Equal"},
|
||||
{Value: "by_quantity", Label: "By Quantity"},
|
||||
{Value: "by_current_cost_price", Label: "By Current Cost"},
|
||||
{Value: "by_weight", Label: "By Weight"},
|
||||
{Value: "by_volume", Label: "By Volume"},
|
||||
}, orm.FieldOpts{String: "Split Method", Default: "equal", Required: true}),
|
||||
orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}),
|
||||
)
|
||||
|
||||
// stock.valuation.adjustment.lines — per-move cost adjustments
|
||||
orm.NewModel("stock.valuation.adjustment.lines", orm.ModelOpts{
|
||||
Description: "Valuation Adjustment Lines",
|
||||
}).AddFields(
|
||||
orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}),
|
||||
orm.Many2one("cost_line_id", "stock.landed.cost.lines", orm.FieldOpts{String: "Cost Line"}),
|
||||
orm.Many2one("move_id", "stock.move", orm.FieldOpts{String: "Stock Move"}),
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Float("quantity", orm.FieldOpts{String: "Quantity"}),
|
||||
orm.Float("weight", orm.FieldOpts{String: "Weight"}),
|
||||
orm.Float("volume", orm.FieldOpts{String: "Volume"}),
|
||||
orm.Monetary("former_cost", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}),
|
||||
orm.Monetary("additional_landed_cost", orm.FieldOpts{String: "Additional Cost", CurrencyField: "currency_id"}),
|
||||
orm.Monetary("final_cost", orm.FieldOpts{String: "Final Cost", CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
)
|
||||
}
|
||||
343
addons/stock/models/stock_picking_batch.go
Normal file
343
addons/stock/models/stock_picking_batch.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initStockPickingBatch registers stock.picking.batch — batch processing of transfers.
|
||||
// Mirrors: odoo/addons/stock_picking_batch/models/stock_picking_batch.py
|
||||
func initStockPickingBatch() {
|
||||
m := orm.NewModel("stock.picking.batch", orm.ModelOpts{
|
||||
Description: "Batch Transfer",
|
||||
Order: "name desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Default: "New"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.One2many("picking_ids", "stock.picking", "batch_id", orm.FieldOpts{String: "Transfers"}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "in_progress", Label: "In Progress"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Selection("picking_type_code", []orm.SelectionItem{
|
||||
{Value: "incoming", Label: "Receipt"},
|
||||
{Value: "outgoing", Label: "Delivery"},
|
||||
{Value: "internal", Label: "Internal Transfer"},
|
||||
}, orm.FieldOpts{String: "Operation Type"}),
|
||||
orm.Integer("picking_count", orm.FieldOpts{String: "Transfers Count", Compute: "_compute_picking_count"}),
|
||||
orm.Integer("move_line_count", orm.FieldOpts{String: "Move Lines Count", Compute: "_compute_move_line_count"}),
|
||||
)
|
||||
|
||||
// _compute_picking_count: Count the number of pickings in this batch.
|
||||
// Mirrors: stock.picking.batch._compute_picking_count()
|
||||
m.RegisterCompute("picking_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
batchID := rs.IDs()[0]
|
||||
var count int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM stock_picking WHERE batch_id = $1`, batchID,
|
||||
).Scan(&count)
|
||||
return orm.Values{"picking_count": count}, nil
|
||||
})
|
||||
|
||||
// _compute_move_line_count: Count the total move lines across all pickings in batch.
|
||||
// Mirrors: stock.picking.batch._compute_move_line_count()
|
||||
m.RegisterCompute("move_line_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
batchID := rs.IDs()[0]
|
||||
var count int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*)
|
||||
FROM stock_move_line sml
|
||||
JOIN stock_move sm ON sm.id = sml.move_id
|
||||
JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||
WHERE sp.batch_id = $1`, batchID,
|
||||
).Scan(&count)
|
||||
return orm.Values{"move_line_count": count}, nil
|
||||
})
|
||||
|
||||
// action_confirm: Move batch from draft to in_progress.
|
||||
// Mirrors: stock.picking.batch.action_confirm()
|
||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM stock_picking_batch WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "draft" {
|
||||
return nil, fmt.Errorf("stock.picking.batch: can only confirm draft batches (batch %d is %q)", id, state)
|
||||
}
|
||||
|
||||
// Confirm all draft pickings in batch
|
||||
pickRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM stock_picking WHERE batch_id = $1 AND state = 'draft'`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: query draft pickings for %d: %w", id, err)
|
||||
}
|
||||
var pickIDs []int64
|
||||
for pickRows.Next() {
|
||||
var pid int64
|
||||
pickRows.Scan(&pid)
|
||||
pickIDs = append(pickIDs, pid)
|
||||
}
|
||||
pickRows.Close()
|
||||
|
||||
if len(pickIDs) > 0 {
|
||||
pickModel := orm.Registry.Get("stock.picking")
|
||||
if pickModel != nil {
|
||||
if confirmMethod, ok := pickModel.Methods["action_confirm"]; ok {
|
||||
pickRS := env.Model("stock.picking").Browse(pickIDs...)
|
||||
if _, err := confirmMethod(pickRS); err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: confirm pickings for batch %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking_batch SET state = 'in_progress' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: confirm batch %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_assign: Reserve stock for all pickings in the batch.
|
||||
// Mirrors: stock.picking.batch.action_assign()
|
||||
m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, batchID := range rs.IDs() {
|
||||
pickRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM stock_picking WHERE batch_id = $1 AND state IN ('confirmed', 'assigned')`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: query pickings for assign %d: %w", batchID, err)
|
||||
}
|
||||
var pickIDs []int64
|
||||
for pickRows.Next() {
|
||||
var pid int64
|
||||
pickRows.Scan(&pid)
|
||||
pickIDs = append(pickIDs, pid)
|
||||
}
|
||||
pickRows.Close()
|
||||
|
||||
if len(pickIDs) > 0 {
|
||||
pickModel := orm.Registry.Get("stock.picking")
|
||||
if pickModel != nil {
|
||||
if assignMethod, ok := pickModel.Methods["action_assign"]; ok {
|
||||
pickRS := env.Model("stock.picking").Browse(pickIDs...)
|
||||
if _, err := assignMethod(pickRS); err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: assign pickings for batch %d: %w", batchID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_done: Validate all pickings in the batch, then set batch to done.
|
||||
// Mirrors: stock.picking.batch.action_done()
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, batchID := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM stock_picking_batch WHERE id = $1`, batchID,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: read state for %d: %w", batchID, err)
|
||||
}
|
||||
if state != "in_progress" {
|
||||
return nil, fmt.Errorf("stock.picking.batch: can only validate in-progress batches (batch %d is %q)", batchID, state)
|
||||
}
|
||||
|
||||
// Validate all non-done pickings
|
||||
pickRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM stock_picking WHERE batch_id = $1 AND state != 'done'`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: query pickings for done %d: %w", batchID, err)
|
||||
}
|
||||
var pickIDs []int64
|
||||
for pickRows.Next() {
|
||||
var pid int64
|
||||
pickRows.Scan(&pid)
|
||||
pickIDs = append(pickIDs, pid)
|
||||
}
|
||||
pickRows.Close()
|
||||
|
||||
if len(pickIDs) > 0 {
|
||||
pickModel := orm.Registry.Get("stock.picking")
|
||||
if pickModel != nil {
|
||||
if validateMethod, ok := pickModel.Methods["button_validate"]; ok {
|
||||
pickRS := env.Model("stock.picking").Browse(pickIDs...)
|
||||
if _, err := validateMethod(pickRS); err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: validate pickings for batch %d: %w", batchID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking_batch SET state = 'done' WHERE id = $1`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: set done for %d: %w", batchID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: Cancel the batch and all non-done pickings.
|
||||
// Mirrors: stock.picking.batch.action_cancel()
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, batchID := range rs.IDs() {
|
||||
// Cancel non-done pickings
|
||||
pickRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM stock_picking WHERE batch_id = $1 AND state NOT IN ('done', 'cancel')`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: query pickings for cancel %d: %w", batchID, err)
|
||||
}
|
||||
var pickIDs []int64
|
||||
for pickRows.Next() {
|
||||
var pid int64
|
||||
pickRows.Scan(&pid)
|
||||
pickIDs = append(pickIDs, pid)
|
||||
}
|
||||
pickRows.Close()
|
||||
|
||||
if len(pickIDs) > 0 {
|
||||
pickModel := orm.Registry.Get("stock.picking")
|
||||
if pickModel != nil {
|
||||
if cancelMethod, ok := pickModel.Methods["action_cancel"]; ok {
|
||||
pickRS := env.Model("stock.picking").Browse(pickIDs...)
|
||||
if _, err := cancelMethod(pickRS); err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: cancel pickings for batch %d: %w", batchID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking_batch SET state = 'cancel' WHERE id = $1`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: cancel batch %d: %w", batchID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_print: Generate a summary of the batch for printing.
|
||||
// Mirrors: stock.picking.batch print report
|
||||
m.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
batchID := rs.IDs()[0]
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sp.name as picking_name, sp.state as picking_state,
|
||||
sm.product_id, pt.name as product_name, sm.product_uom_qty,
|
||||
sl_src.name as source_location, sl_dst.name as dest_location
|
||||
FROM stock_picking sp
|
||||
JOIN stock_move sm ON sm.picking_id = sp.id
|
||||
JOIN product_product pp ON pp.id = sm.product_id
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
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
|
||||
WHERE sp.batch_id = $1
|
||||
ORDER BY sp.name, pt.name`,
|
||||
batchID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: query print data for %d: %w", batchID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var pickingName, pickingState, prodName, srcLoc, dstLoc string
|
||||
var prodID int64
|
||||
var qty float64
|
||||
if err := rows.Scan(&pickingName, &pickingState, &prodID, &prodName, &qty, &srcLoc, &dstLoc); err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: scan print row: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"picking": pickingName, "state": pickingState,
|
||||
"product_id": prodID, "product": prodName, "quantity": qty,
|
||||
"source": srcLoc, "destination": dstLoc,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"batch_id": batchID,
|
||||
"lines": lines,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// add_pickings: Add pickings to an existing batch.
|
||||
// Mirrors: stock.picking.batch add picking wizard
|
||||
m.RegisterMethod("add_pickings", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.picking.batch.add_pickings requires picking_ids")
|
||||
}
|
||||
pickingIDs, ok := args[0].([]int64)
|
||||
if !ok || len(pickingIDs) == 0 {
|
||||
return nil, fmt.Errorf("stock.picking.batch: invalid picking_ids")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
batchID := rs.IDs()[0]
|
||||
|
||||
for _, pid := range pickingIDs {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking SET batch_id = $1 WHERE id = $2`, batchID, pid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: add picking %d to batch %d: %w", pid, batchID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// remove_pickings: Remove pickings from the batch.
|
||||
// Mirrors: stock.picking.batch remove picking
|
||||
m.RegisterMethod("remove_pickings", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.picking.batch.remove_pickings requires picking_ids")
|
||||
}
|
||||
pickingIDs, ok := args[0].([]int64)
|
||||
if !ok || len(pickingIDs) == 0 {
|
||||
return nil, fmt.Errorf("stock.picking.batch: invalid picking_ids")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
batchID := rs.IDs()[0]
|
||||
|
||||
for _, pid := range pickingIDs {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE stock_picking SET batch_id = NULL WHERE id = $1 AND batch_id = $2`, pid, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.picking.batch: remove picking %d from batch %d: %w", pid, batchID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initStockPickingBatchExtension extends stock.picking with batch_id field.
|
||||
// Mirrors: stock_picking_batch module's extension of stock.picking
|
||||
func initStockPickingBatchExtension() {
|
||||
p := orm.ExtendModel("stock.picking")
|
||||
p.AddFields(
|
||||
orm.Many2one("batch_id", "stock.picking.batch", orm.FieldOpts{String: "Batch Transfer"}),
|
||||
)
|
||||
}
|
||||
746
addons/stock/models/stock_report.go
Normal file
746
addons/stock/models/stock_report.go
Normal file
@@ -0,0 +1,746 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initStockReport registers stock.report — transient model for stock quantity reporting.
|
||||
// Mirrors: odoo/addons/stock/report/stock_report_views.py
|
||||
func initStockReport() {
|
||||
m := orm.NewModel("stock.report", orm.ModelOpts{
|
||||
Description: "Stock Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "From"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "To"}),
|
||||
)
|
||||
|
||||
// get_stock_data: Aggregate on-hand / reserved / available per product+location.
|
||||
// Mirrors: stock.report logic from Odoo stock views.
|
||||
m.RegisterMethod("get_stock_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
query := `
|
||||
SELECT p.id, pt.name as product_name, l.name as location_name,
|
||||
COALESCE(SUM(q.quantity), 0) as on_hand,
|
||||
COALESCE(SUM(q.reserved_quantity), 0) as reserved,
|
||||
COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available
|
||||
FROM stock_quant q
|
||||
JOIN product_product p ON p.id = q.product_id
|
||||
JOIN product_template pt ON pt.id = p.product_tmpl_id
|
||||
JOIN stock_location l ON l.id = q.location_id
|
||||
WHERE l.usage = 'internal'
|
||||
GROUP BY p.id, pt.name, l.name
|
||||
ORDER BY pt.name, l.name`
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.report: query stock data: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var prodID int64
|
||||
var prodName, locName string
|
||||
var onHand, reserved, available float64
|
||||
if err := rows.Scan(&prodID, &prodName, &locName, &onHand, &reserved, &available); err != nil {
|
||||
return nil, fmt.Errorf("stock.report: scan row: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"product_id": prodID, "product": prodName, "location": locName,
|
||||
"on_hand": onHand, "reserved": reserved, "available": available,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{"lines": lines}, nil
|
||||
})
|
||||
|
||||
// get_stock_data_by_product: Aggregate stock for a specific product across all internal locations.
|
||||
// Mirrors: stock.report filtered by product
|
||||
m.RegisterMethod("get_stock_data_by_product", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.report.get_stock_data_by_product requires product_id")
|
||||
}
|
||||
productID, _ := args[0].(int64)
|
||||
if productID == 0 {
|
||||
return nil, fmt.Errorf("stock.report: invalid product_id")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT l.id, l.complete_name,
|
||||
COALESCE(SUM(q.quantity), 0) as on_hand,
|
||||
COALESCE(SUM(q.reserved_quantity), 0) as reserved,
|
||||
COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available
|
||||
FROM stock_quant q
|
||||
JOIN stock_location l ON l.id = q.location_id
|
||||
WHERE q.product_id = $1 AND l.usage = 'internal'
|
||||
GROUP BY l.id, l.complete_name
|
||||
ORDER BY l.complete_name`,
|
||||
productID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.report: query by product: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var totalOnHand, totalReserved, totalAvailable float64
|
||||
for rows.Next() {
|
||||
var locID int64
|
||||
var locName string
|
||||
var onHand, reserved, available float64
|
||||
if err := rows.Scan(&locID, &locName, &onHand, &reserved, &available); err != nil {
|
||||
return nil, fmt.Errorf("stock.report: scan by product row: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"location_id": locID, "location": locName,
|
||||
"on_hand": onHand, "reserved": reserved, "available": available,
|
||||
})
|
||||
totalOnHand += onHand
|
||||
totalReserved += reserved
|
||||
totalAvailable += available
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"lines": lines,
|
||||
"total_on_hand": totalOnHand,
|
||||
"total_reserved": totalReserved,
|
||||
"total_available": totalAvailable,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_stock_data_by_location: Aggregate stock for a specific location across all products.
|
||||
// Mirrors: stock.report filtered by location
|
||||
m.RegisterMethod("get_stock_data_by_location", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.report.get_stock_data_by_location requires location_id")
|
||||
}
|
||||
locationID, _ := args[0].(int64)
|
||||
if locationID == 0 {
|
||||
return nil, fmt.Errorf("stock.report: invalid location_id")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT p.id, pt.name as product_name,
|
||||
COALESCE(SUM(q.quantity), 0) as on_hand,
|
||||
COALESCE(SUM(q.reserved_quantity), 0) as reserved,
|
||||
COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available
|
||||
FROM stock_quant q
|
||||
JOIN product_product p ON p.id = q.product_id
|
||||
JOIN product_template pt ON pt.id = p.product_tmpl_id
|
||||
WHERE q.location_id = $1
|
||||
GROUP BY p.id, pt.name
|
||||
ORDER BY pt.name`,
|
||||
locationID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.report: query by location: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var totalOnHand, totalReserved, totalAvailable float64
|
||||
for rows.Next() {
|
||||
var prodID int64
|
||||
var prodName string
|
||||
var onHand, reserved, available float64
|
||||
if err := rows.Scan(&prodID, &prodName, &onHand, &reserved, &available); err != nil {
|
||||
return nil, fmt.Errorf("stock.report: scan by location row: %w", err)
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"product_id": prodID, "product": prodName,
|
||||
"on_hand": onHand, "reserved": reserved, "available": available,
|
||||
})
|
||||
totalOnHand += onHand
|
||||
totalReserved += reserved
|
||||
totalAvailable += available
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"location_id": locationID,
|
||||
"lines": lines,
|
||||
"total_on_hand": totalOnHand,
|
||||
"total_reserved": totalReserved,
|
||||
"total_available": totalAvailable,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_move_history: Return stock move history with filters.
|
||||
// Mirrors: stock.move.line reporting / traceability
|
||||
m.RegisterMethod("get_move_history", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
query := `
|
||||
SELECT sm.id, sm.name, sm.product_id, pt.name as product_name,
|
||||
sm.product_uom_qty, sm.state,
|
||||
sl_src.name as source_location, sl_dst.name as dest_location,
|
||||
sm.date, sm.origin
|
||||
FROM stock_move sm
|
||||
JOIN product_product pp ON pp.id = sm.product_id
|
||||
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
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
|
||||
WHERE sm.state = 'done'
|
||||
ORDER BY sm.date DESC
|
||||
LIMIT 100`
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(), query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.report: query move history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var moves []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var moveID, productID int64
|
||||
var name, productName, state, srcLoc, dstLoc string
|
||||
var qty float64
|
||||
var date, origin *string
|
||||
if err := rows.Scan(&moveID, &name, &productID, &productName, &qty, &state, &srcLoc, &dstLoc, &date, &origin); err != nil {
|
||||
return nil, fmt.Errorf("stock.report: scan move history row: %w", err)
|
||||
}
|
||||
dateStr := ""
|
||||
if date != nil {
|
||||
dateStr = *date
|
||||
}
|
||||
originStr := ""
|
||||
if origin != nil {
|
||||
originStr = *origin
|
||||
}
|
||||
moves = append(moves, map[string]interface{}{
|
||||
"id": moveID, "name": name, "product_id": productID, "product": productName,
|
||||
"quantity": qty, "state": state, "source_location": srcLoc,
|
||||
"dest_location": dstLoc, "date": dateStr, "origin": originStr,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"moves": moves}, nil
|
||||
})
|
||||
|
||||
// get_inventory_valuation: Return total inventory valuation by product.
|
||||
// Mirrors: stock report valuation views
|
||||
m.RegisterMethod("get_inventory_valuation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT p.id, pt.name as product_name,
|
||||
COALESCE(SUM(q.quantity), 0) as total_qty,
|
||||
COALESCE(SUM(q.value), 0) as total_value
|
||||
FROM stock_quant q
|
||||
JOIN product_product p ON p.id = q.product_id
|
||||
JOIN product_template pt ON pt.id = p.product_tmpl_id
|
||||
JOIN stock_location l ON l.id = q.location_id
|
||||
WHERE l.usage = 'internal'
|
||||
GROUP BY p.id, pt.name
|
||||
HAVING SUM(q.quantity) > 0
|
||||
ORDER BY pt.name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.report: query valuation: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []map[string]interface{}
|
||||
var grandTotalQty, grandTotalValue float64
|
||||
for rows.Next() {
|
||||
var prodID int64
|
||||
var prodName string
|
||||
var totalQty, totalValue float64
|
||||
if err := rows.Scan(&prodID, &prodName, &totalQty, &totalValue); err != nil {
|
||||
return nil, fmt.Errorf("stock.report: scan valuation row: %w", err)
|
||||
}
|
||||
avgCost := float64(0)
|
||||
if totalQty > 0 {
|
||||
avgCost = totalValue / totalQty
|
||||
}
|
||||
lines = append(lines, map[string]interface{}{
|
||||
"product_id": prodID, "product": prodName,
|
||||
"quantity": totalQty, "value": totalValue, "average_cost": avgCost,
|
||||
})
|
||||
grandTotalQty += totalQty
|
||||
grandTotalValue += totalValue
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"lines": lines,
|
||||
"total_qty": grandTotalQty,
|
||||
"total_value": grandTotalValue,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initStockForecast registers stock.forecasted.product — transient model for forecast computation.
|
||||
// Mirrors: odoo/addons/stock/models/stock_forecasted.py
|
||||
func initStockForecast() {
|
||||
m := orm.NewModel("stock.forecasted.product", orm.ModelOpts{
|
||||
Description: "Forecasted Stock",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}),
|
||||
)
|
||||
|
||||
// get_forecast: Compute on-hand, incoming, outgoing and forecast for a product.
|
||||
// Mirrors: stock.forecasted.product_product._get_report_data()
|
||||
m.RegisterMethod("get_forecast", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
productID := int64(0)
|
||||
if len(args) > 0 {
|
||||
if p, ok := args[0].(float64); ok {
|
||||
productID = int64(p)
|
||||
}
|
||||
}
|
||||
|
||||
// On hand
|
||||
var onHand float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant
|
||||
WHERE product_id = $1 AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`,
|
||||
productID).Scan(&onHand)
|
||||
|
||||
// Incoming (confirmed moves TO internal locations)
|
||||
var incoming float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move
|
||||
WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting')
|
||||
AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`,
|
||||
productID).Scan(&incoming)
|
||||
|
||||
// Outgoing (confirmed moves FROM internal locations)
|
||||
var outgoing float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move
|
||||
WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting')
|
||||
AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`,
|
||||
productID).Scan(&outgoing)
|
||||
|
||||
return map[string]interface{}{
|
||||
"on_hand": onHand, "incoming": incoming, "outgoing": outgoing,
|
||||
"forecast": onHand + incoming - outgoing,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_forecast_details: Detailed forecast with move-level breakdown.
|
||||
// Mirrors: stock.forecasted.product_product._get_report_lines()
|
||||
m.RegisterMethod("get_forecast_details", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
productID := int64(0)
|
||||
if len(args) > 0 {
|
||||
if p, ok := args[0].(float64); ok {
|
||||
productID = int64(p)
|
||||
}
|
||||
}
|
||||
|
||||
if productID == 0 {
|
||||
return nil, fmt.Errorf("stock.forecasted.product: product_id required")
|
||||
}
|
||||
|
||||
// On hand
|
||||
var onHand float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant
|
||||
WHERE product_id = $1 AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`,
|
||||
productID).Scan(&onHand)
|
||||
|
||||
// Incoming moves
|
||||
inRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state,
|
||||
sl.name as source_location, sld.name as dest_location,
|
||||
sp.name as picking_name
|
||||
FROM stock_move sm
|
||||
JOIN stock_location sl ON sl.id = sm.location_id
|
||||
JOIN stock_location sld ON sld.id = sm.location_dest_id
|
||||
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||
WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting')
|
||||
AND sm.location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal')
|
||||
ORDER BY sm.date`,
|
||||
productID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: query incoming moves: %w", err)
|
||||
}
|
||||
defer inRows.Close()
|
||||
|
||||
var incomingMoves []map[string]interface{}
|
||||
var totalIncoming float64
|
||||
for inRows.Next() {
|
||||
var moveID int64
|
||||
var name, state, srcLoc, dstLoc string
|
||||
var qty float64
|
||||
var date, pickingName *string
|
||||
if err := inRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: scan incoming move: %w", err)
|
||||
}
|
||||
dateStr := ""
|
||||
if date != nil {
|
||||
dateStr = *date
|
||||
}
|
||||
pickStr := ""
|
||||
if pickingName != nil {
|
||||
pickStr = *pickingName
|
||||
}
|
||||
incomingMoves = append(incomingMoves, map[string]interface{}{
|
||||
"id": moveID, "name": name, "quantity": qty, "date": dateStr,
|
||||
"state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr,
|
||||
})
|
||||
totalIncoming += qty
|
||||
}
|
||||
|
||||
// Outgoing moves
|
||||
outRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state,
|
||||
sl.name as source_location, sld.name as dest_location,
|
||||
sp.name as picking_name
|
||||
FROM stock_move sm
|
||||
JOIN stock_location sl ON sl.id = sm.location_id
|
||||
JOIN stock_location sld ON sld.id = sm.location_dest_id
|
||||
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||
WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting')
|
||||
AND sm.location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')
|
||||
ORDER BY sm.date`,
|
||||
productID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: query outgoing moves: %w", err)
|
||||
}
|
||||
defer outRows.Close()
|
||||
|
||||
var outgoingMoves []map[string]interface{}
|
||||
var totalOutgoing float64
|
||||
for outRows.Next() {
|
||||
var moveID int64
|
||||
var name, state, srcLoc, dstLoc string
|
||||
var qty float64
|
||||
var date, pickingName *string
|
||||
if err := outRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: scan outgoing move: %w", err)
|
||||
}
|
||||
dateStr := ""
|
||||
if date != nil {
|
||||
dateStr = *date
|
||||
}
|
||||
pickStr := ""
|
||||
if pickingName != nil {
|
||||
pickStr = *pickingName
|
||||
}
|
||||
outgoingMoves = append(outgoingMoves, map[string]interface{}{
|
||||
"id": moveID, "name": name, "quantity": qty, "date": dateStr,
|
||||
"state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr,
|
||||
})
|
||||
totalOutgoing += qty
|
||||
}
|
||||
|
||||
forecast := onHand + totalIncoming - totalOutgoing
|
||||
|
||||
return map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"on_hand": onHand,
|
||||
"incoming": totalIncoming,
|
||||
"outgoing": totalOutgoing,
|
||||
"forecast": forecast,
|
||||
"incoming_moves": incomingMoves,
|
||||
"outgoing_moves": outgoingMoves,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_forecast_all: Compute forecast for all products with stock or pending moves.
|
||||
// Mirrors: stock.forecasted overview
|
||||
m.RegisterMethod("get_forecast_all", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT p.id, pt.name as product_name,
|
||||
COALESCE(oh.on_hand, 0) as on_hand,
|
||||
COALESCE(inc.incoming, 0) as incoming,
|
||||
COALESCE(outg.outgoing, 0) as outgoing
|
||||
FROM product_product p
|
||||
JOIN product_template pt ON pt.id = p.product_tmpl_id
|
||||
LEFT JOIN (
|
||||
SELECT product_id, SUM(quantity - reserved_quantity) as on_hand
|
||||
FROM stock_quant
|
||||
WHERE location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')
|
||||
GROUP BY product_id
|
||||
) oh ON oh.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT product_id, SUM(product_uom_qty) as incoming
|
||||
FROM stock_move
|
||||
WHERE state IN ('confirmed','assigned','waiting')
|
||||
AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal')
|
||||
GROUP BY product_id
|
||||
) inc ON inc.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT product_id, SUM(product_uom_qty) as outgoing
|
||||
FROM stock_move
|
||||
WHERE state IN ('confirmed','assigned','waiting')
|
||||
AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')
|
||||
GROUP BY product_id
|
||||
) outg ON outg.product_id = p.id
|
||||
WHERE COALESCE(oh.on_hand, 0) != 0
|
||||
OR COALESCE(inc.incoming, 0) != 0
|
||||
OR COALESCE(outg.outgoing, 0) != 0
|
||||
ORDER BY pt.name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: query all forecasts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var products []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var prodID int64
|
||||
var prodName string
|
||||
var onHand, incoming, outgoing float64
|
||||
if err := rows.Scan(&prodID, &prodName, &onHand, &incoming, &outgoing); err != nil {
|
||||
return nil, fmt.Errorf("stock.forecasted: scan forecast row: %w", err)
|
||||
}
|
||||
products = append(products, map[string]interface{}{
|
||||
"product_id": prodID, "product": prodName,
|
||||
"on_hand": onHand, "incoming": incoming, "outgoing": outgoing,
|
||||
"forecast": onHand + incoming - outgoing,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
229
addons/stock/models/stock_valuation.go
Normal file
229
addons/stock/models/stock_valuation.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initStockValuationLayer registers stock.valuation.layer — tracks inventory valuation per move.
|
||||
// Mirrors: odoo/addons/stock_account/models/stock_valuation_layer.py
|
||||
func initStockValuationLayer() {
|
||||
m := orm.NewModel("stock.valuation.layer", orm.ModelOpts{
|
||||
Description: "Stock Valuation Layer",
|
||||
Order: "create_date, id",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true, Index: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
||||
orm.Many2one("stock_move_id", "stock.move", orm.FieldOpts{String: "Stock Move"}),
|
||||
orm.Float("quantity", orm.FieldOpts{String: "Quantity"}),
|
||||
orm.Monetary("unit_cost", orm.FieldOpts{String: "Unit Value", CurrencyField: "currency_id"}),
|
||||
orm.Monetary("value", orm.FieldOpts{String: "Total Value", CurrencyField: "currency_id"}),
|
||||
orm.Monetary("remaining_value", orm.FieldOpts{String: "Remaining Value", CurrencyField: "currency_id"}),
|
||||
orm.Float("remaining_qty", orm.FieldOpts{String: "Remaining Qty"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Char("description", orm.FieldOpts{String: "Description"}),
|
||||
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
||||
)
|
||||
|
||||
// create_valuation_layer: Creates a valuation layer for a stock move.
|
||||
// Mirrors: stock.valuation.layer.create() via product._run_fifo()
|
||||
m.RegisterMethod("create_valuation_layer", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 4 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer.create_valuation_layer requires product_id, move_id, quantity, unit_cost")
|
||||
}
|
||||
productID, _ := args[0].(int64)
|
||||
moveID, _ := args[1].(int64)
|
||||
quantity, _ := args[2].(float64)
|
||||
unitCost, _ := args[3].(float64)
|
||||
|
||||
if productID == 0 || quantity == 0 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: invalid product_id or quantity")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
totalValue := quantity * unitCost
|
||||
|
||||
var layerID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO stock_valuation_layer
|
||||
(product_id, stock_move_id, quantity, unit_cost, value, remaining_qty, remaining_value, company_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 1)
|
||||
RETURNING id`,
|
||||
productID, moveID, quantity, unitCost, totalValue, quantity, totalValue,
|
||||
).Scan(&layerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: create layer: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": layerID, "value": totalValue,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_product_valuation: Compute total valuation for a product across all remaining layers.
|
||||
// Mirrors: product.product._compute_stock_value()
|
||||
m.RegisterMethod("get_product_valuation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer.get_product_valuation requires product_id")
|
||||
}
|
||||
productID, _ := args[0].(int64)
|
||||
if productID == 0 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: invalid product_id")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
var totalQty, totalValue float64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(remaining_qty), 0), COALESCE(SUM(remaining_value), 0)
|
||||
FROM stock_valuation_layer
|
||||
WHERE product_id = $1 AND remaining_qty > 0`,
|
||||
productID,
|
||||
).Scan(&totalQty, &totalValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: get valuation: %w", err)
|
||||
}
|
||||
|
||||
var avgCost float64
|
||||
if totalQty > 0 {
|
||||
avgCost = totalValue / totalQty
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"total_qty": totalQty,
|
||||
"total_value": totalValue,
|
||||
"average_cost": avgCost,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// consume_fifo: Consume quantity from existing layers using FIFO order.
|
||||
// Mirrors: product.product._run_fifo() for outgoing moves
|
||||
m.RegisterMethod("consume_fifo", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer.consume_fifo requires product_id, quantity")
|
||||
}
|
||||
productID, _ := args[0].(int64)
|
||||
qtyToConsume, _ := args[1].(float64)
|
||||
|
||||
if productID == 0 || qtyToConsume <= 0 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: invalid product_id or quantity")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
// Get layers ordered by creation (FIFO)
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, remaining_qty, remaining_value, unit_cost
|
||||
FROM stock_valuation_layer
|
||||
WHERE product_id = $1 AND remaining_qty > 0
|
||||
ORDER BY create_date, id`,
|
||||
productID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: query layers for FIFO: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
consumed := remaining
|
||||
if consumed > layerQty {
|
||||
consumed = layerQty
|
||||
}
|
||||
|
||||
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`,
|
||||
c.newQty, c.newValue, c.id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", c.id, err)
|
||||
}
|
||||
totalConsumedValue += c.consumed * c.cost
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"consumed_qty": qtyToConsume - remaining,
|
||||
"consumed_value": totalConsumedValue,
|
||||
"remaining": remaining,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// get_valuation_history: Return valuation layers for a product within a date range.
|
||||
// Mirrors: stock.valuation.layer reporting views
|
||||
m.RegisterMethod("get_valuation_history", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer.get_valuation_history requires product_id")
|
||||
}
|
||||
productID, _ := args[0].(int64)
|
||||
if productID == 0 {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: invalid product_id")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, quantity, unit_cost, value, remaining_qty, remaining_value, description
|
||||
FROM stock_valuation_layer
|
||||
WHERE product_id = $1
|
||||
ORDER BY create_date DESC, id DESC`,
|
||||
productID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: query history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var layers []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var qty, unitCost, value, remQty, remValue float64
|
||||
var description *string
|
||||
if err := rows.Scan(&id, &qty, &unitCost, &value, &remQty, &remValue, &description); err != nil {
|
||||
return nil, fmt.Errorf("stock.valuation.layer: scan history row: %w", err)
|
||||
}
|
||||
desc := ""
|
||||
if description != nil {
|
||||
desc = *description
|
||||
}
|
||||
layers = append(layers, map[string]interface{}{
|
||||
"id": id, "quantity": qty, "unit_cost": unitCost, "value": value,
|
||||
"remaining_qty": remQty, "remaining_value": remValue, "description": desc,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{"layers": layers}, nil
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
// Import all modules (register models via init())
|
||||
_ "odoo-go/addons/base"
|
||||
_ "odoo-go/addons/mail"
|
||||
_ "odoo-go/addons/account"
|
||||
_ "odoo-go/addons/product"
|
||||
_ "odoo-go/addons/sale"
|
||||
@@ -126,6 +127,11 @@ func main() {
|
||||
log.Printf("odoo: session table init warning: %v", err)
|
||||
}
|
||||
|
||||
// Start cron scheduler
|
||||
cronScheduler := service.NewCronScheduler(pool)
|
||||
cronScheduler.Start()
|
||||
defer cronScheduler.Stop()
|
||||
|
||||
// Start HTTP server
|
||||
srv := server.New(cfg, pool)
|
||||
log.Printf("odoo: starting HTTP service on %s:%d", cfg.HTTPInterface, cfg.HTTPPort)
|
||||
|
||||
18
go.mod
18
go.mod
@@ -1,14 +1,24 @@
|
||||
module odoo-go
|
||||
|
||||
go 1.22.2
|
||||
go 1.24.0
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.7.4
|
||||
|
||||
require (
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.10.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
58
go.sum
58
go.sum
@@ -1,6 +1,12 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -11,17 +17,69 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
BIN
odoo-server
BIN
odoo-server
Binary file not shown.
25
open.md
Normal file
25
open.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Offene Punkte
|
||||
|
||||
> Stand: 2026-04-12
|
||||
> Business-Module: alle auf 95% — KOMPLETT
|
||||
> Odoo Community Core: Portal + Email Inbound + Discuss — KOMPLETT
|
||||
|
||||
---
|
||||
|
||||
## Odoo Community Core — KOMPLETT
|
||||
|
||||
- [x] **Portal** — Portal-User (share=true), /my/* Routes, Signup, Password Reset ✅ 2026-04-12
|
||||
- [x] **Email Inbound** — IMAP Polling (go-imap/v2), Email Parser, Thread Matching ✅ 2026-04-12
|
||||
- [x] **Discuss** — mail.channel + mail.channel.member, Long-Polling Bus, DM, Channel CRUD, Unread Count ✅ 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
## Frontend / UI Zukunft — 2 Items (langfristig)
|
||||
|
||||
| # | Was |
|
||||
|---|-----|
|
||||
| 1 | UI modernisieren — schrittweise schneller, stabiler, optisch erneuern |
|
||||
| 2 | View-Format — langfristig format-agnostisch (JSON-fähig), weg von XML wo möglich |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package orm
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ComputeFunc is a function that computes field values for a recordset.
|
||||
// Mirrors: @api.depends decorated methods in Odoo.
|
||||
@@ -253,7 +256,7 @@ func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changed
|
||||
|
||||
computed, err := fn(rs)
|
||||
if err != nil {
|
||||
// Non-fatal: skip failed computes during onchange
|
||||
log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
|
||||
continue
|
||||
}
|
||||
for k, v := range computed {
|
||||
|
||||
@@ -2,6 +2,8 @@ package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -152,6 +154,8 @@ func (dc *DomainCompiler) JoinSQL() string {
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// compileNodes compiles domain nodes in Polish (prefix) notation.
|
||||
// Returns the SQL string and the number of nodes consumed from the domain starting at pos.
|
||||
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if pos >= len(domain) {
|
||||
return "TRUE", nil
|
||||
@@ -167,7 +171,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -178,7 +183,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -196,8 +202,6 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
return dc.compileCondition(n)
|
||||
|
||||
case domainGroup:
|
||||
// domainGroup wraps a sub-domain as a single node.
|
||||
// Compile it recursively as a full domain.
|
||||
subSQL, _, err := dc.compileDomainGroup(Domain(n))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -208,6 +212,28 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
||||
}
|
||||
|
||||
// nodeSize returns the number of domain nodes consumed by the subtree at pos.
|
||||
// Operators (&, |) consume 1 + left subtree + right subtree.
|
||||
// NOT consumes 1 + inner subtree. Leaf nodes consume 1.
|
||||
func nodeSize(domain Domain, pos int) int {
|
||||
if pos >= len(domain) {
|
||||
return 0
|
||||
}
|
||||
switch n := domain[pos].(type) {
|
||||
case Operator:
|
||||
_ = n
|
||||
switch domain[pos].(Operator) {
|
||||
case OpAnd, OpOr:
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
rightSize := nodeSize(domain, pos+1+leftSize)
|
||||
return 1 + leftSize + rightSize
|
||||
case OpNot:
|
||||
return 1 + nodeSize(domain, pos+1)
|
||||
}
|
||||
}
|
||||
return 1 // Condition or domainGroup = 1 node
|
||||
}
|
||||
|
||||
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
|
||||
// It reuses the same DomainCompiler (sharing params and joins) so parameter
|
||||
// indices stay consistent with the outer query.
|
||||
@@ -227,14 +253,12 @@ func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
|
||||
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
||||
}
|
||||
|
||||
// Handle dot notation (e.g., "partner_id.name")
|
||||
// Handle dot notation (e.g., "partner_id.name", "partner_id.country_id.code")
|
||||
// by generating LEFT JOINs through the M2O relational chain.
|
||||
parts := strings.Split(c.Field, ".")
|
||||
column := parts[0]
|
||||
|
||||
// TODO: Handle JOINs for dot notation paths
|
||||
// For now, only support direct fields
|
||||
if len(parts) > 1 {
|
||||
// Placeholder for JOIN resolution
|
||||
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
||||
}
|
||||
|
||||
@@ -285,7 +309,7 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
||||
dc.joins = append(dc.joins, joinClause{
|
||||
table: comodel.Table(),
|
||||
alias: alias,
|
||||
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||
on: fmt.Sprintf("%q.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||
})
|
||||
|
||||
currentModel = comodel
|
||||
@@ -293,8 +317,12 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
||||
}
|
||||
|
||||
// The last segment is the actual field to filter on
|
||||
leafField := fieldPath[len(fieldPath)-1]
|
||||
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
|
||||
leafFieldName := fieldPath[len(fieldPath)-1]
|
||||
leafCol := leafFieldName
|
||||
if lf := currentModel.GetField(leafFieldName); lf != nil {
|
||||
leafCol = lf.Column()
|
||||
}
|
||||
qualifiedColumn := fmt.Sprintf("%q.%q", currentAlias, leafCol)
|
||||
|
||||
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
||||
}
|
||||
@@ -528,13 +556,8 @@ func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool)
|
||||
// Rebase parameter indices: shift them by the current param count
|
||||
baseIdx := len(dc.params)
|
||||
dc.params = append(dc.params, subParams...)
|
||||
rebased := subWhere
|
||||
// Replace $N with $(N+baseIdx) in the sub-where clause
|
||||
for i := len(subParams); i >= 1; i-- {
|
||||
old := fmt.Sprintf("$%d", i)
|
||||
new := fmt.Sprintf("$%d", i+baseIdx)
|
||||
rebased = strings.ReplaceAll(rebased, old, new)
|
||||
}
|
||||
// Replace $N with $(N+baseIdx) using regex to avoid $1 matching $10
|
||||
rebased := rebaseParams(subWhere, baseIdx)
|
||||
|
||||
// Determine the join condition based on field type
|
||||
var joinCond string
|
||||
@@ -676,3 +699,14 @@ func wrapLikeValue(value Value) Value {
|
||||
}
|
||||
return "%" + s + "%"
|
||||
}
|
||||
|
||||
// rebaseParams shifts $N placeholders in a SQL string by baseIdx.
|
||||
// Uses regex to avoid $1 matching inside $10.
|
||||
var paramRegex = regexp.MustCompile(`\$(\d+)`)
|
||||
|
||||
func rebaseParams(sql string, baseIdx int) string {
|
||||
return paramRegex.ReplaceAllStringFunc(sql, func(match string) string {
|
||||
n, _ := strconv.Atoi(match[1:])
|
||||
return fmt.Sprintf("$%d", n+baseIdx)
|
||||
})
|
||||
}
|
||||
|
||||
277
pkg/orm/domain_parse_test.go
Normal file
277
pkg/orm/domain_parse_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package orm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDomainStringSimple(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('name', '=', 'test')]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(domain))
|
||||
}
|
||||
cond, ok := domain[0].(Condition)
|
||||
if !ok {
|
||||
t.Fatal("expected Condition")
|
||||
}
|
||||
if cond.Field != "name" {
|
||||
t.Errorf("field: %s", cond.Field)
|
||||
}
|
||||
if cond.Operator != "=" {
|
||||
t.Errorf("op: %s", cond.Operator)
|
||||
}
|
||||
if cond.Value != "test" {
|
||||
t.Errorf("value: %v", cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringNumeric(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('age', '>', 18)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != int64(18) {
|
||||
t.Errorf("expected int64(18), got %T %v", cond.Value, cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringFloat(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('amount', '>', 99.5)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != float64(99.5) {
|
||||
t.Errorf("expected float64(99.5), got %T %v", cond.Value, cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringNegativeNumber(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('balance', '<', -100)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != int64(-100) {
|
||||
t.Errorf("expected int64(-100), got %T %v", cond.Value, cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringBoolean(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('active', '=', True)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != true {
|
||||
t.Errorf("expected true, got %v", cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringBooleanFalse(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('active', '=', False)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != false {
|
||||
t.Errorf("expected false, got %v", cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringList(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('id', 'in', [1, 2, 3])]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
vals, ok := cond.Value.([]int64)
|
||||
if !ok {
|
||||
t.Fatalf("expected []int64, got %T", cond.Value)
|
||||
}
|
||||
if len(vals) != 3 {
|
||||
t.Errorf("expected 3, got %d", len(vals))
|
||||
}
|
||||
if vals[0] != 1 || vals[1] != 2 || vals[2] != 3 {
|
||||
t.Errorf("values: %v", vals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringStringList(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('state', 'in', ['draft', 'sent'])]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
vals, ok := cond.Value.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected []string, got %T", cond.Value)
|
||||
}
|
||||
if len(vals) != 2 {
|
||||
t.Errorf("expected 2, got %d", len(vals))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringEmptyList(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('id', 'in', [])]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
vals, ok := cond.Value.([]int64)
|
||||
if !ok {
|
||||
t.Fatalf("expected []int64, got %T", cond.Value)
|
||||
}
|
||||
if len(vals) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(vals))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringOperators(t *testing.T) {
|
||||
domain, err := ParseDomainString("['&', ('a', '=', 1), ('b', '=', 2)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 3 {
|
||||
t.Fatalf("expected 3 nodes, got %d", len(domain))
|
||||
}
|
||||
if domain[0] != OpAnd {
|
||||
t.Error("expected & operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringOrOperator(t *testing.T) {
|
||||
domain, err := ParseDomainString("['|', ('a', '=', 1), ('b', '=', 2)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 3 {
|
||||
t.Fatalf("expected 3 nodes, got %d", len(domain))
|
||||
}
|
||||
if domain[0] != OpOr {
|
||||
t.Error("expected | operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringNotOperator(t *testing.T) {
|
||||
domain, err := ParseDomainString("['!', ('active', '=', True)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d", len(domain))
|
||||
}
|
||||
if domain[0] != OpNot {
|
||||
t.Error("expected ! operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringEmpty(t *testing.T) {
|
||||
domain, err := ParseDomainString("[]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 0 {
|
||||
t.Errorf("expected 0 nodes, got %d", len(domain))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringEmptyString(t *testing.T) {
|
||||
domain, err := ParseDomainString("", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 0 {
|
||||
t.Errorf("expected 0 nodes, got %d", len(domain))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringNone(t *testing.T) {
|
||||
domain, err := ParseDomainString("[('field', '=', None)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != nil {
|
||||
t.Errorf("expected nil, got %v", cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringImplicitAnd(t *testing.T) {
|
||||
// Multiple leaves without explicit operator should be implicitly ANDed
|
||||
domain, err := ParseDomainString("[('a', '=', 1), ('b', '=', 2)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// normalizeDomainNodes wraps with And() → [&, leaf, leaf] = 3 nodes
|
||||
if len(domain) != 3 {
|
||||
t.Fatalf("expected 3 nodes (implicit AND), got %d", len(domain))
|
||||
}
|
||||
if domain[0] != OpAnd {
|
||||
t.Error("expected implicit & operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringDoubleQuotes(t *testing.T) {
|
||||
domain, err := ParseDomainString(`[("name", "=", "test")]`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domain) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(domain))
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Field != "name" {
|
||||
t.Errorf("field: %s", cond.Field)
|
||||
}
|
||||
if cond.Value != "test" {
|
||||
t.Errorf("value: %v", cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringContextVar(t *testing.T) {
|
||||
// Without env, context vars should resolve to int64(0)
|
||||
domain, err := ParseDomainString("[('user_id', '=', user.id)]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != int64(0) {
|
||||
t.Errorf("expected int64(0), got %T %v", cond.Value, cond.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringInvalidSyntax(t *testing.T) {
|
||||
_, err := ParseDomainString("not a domain", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid syntax")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringTupleAsList(t *testing.T) {
|
||||
// Some domain_force uses tuple syntax for list values
|
||||
domain, err := ParseDomainString("[('id', 'in', (1, 2, 3))]", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
vals, ok := cond.Value.([]int64)
|
||||
if !ok {
|
||||
t.Fatalf("expected []int64, got %T", cond.Value)
|
||||
}
|
||||
if len(vals) != 3 {
|
||||
t.Errorf("expected 3, got %d", len(vals))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainStringEscapedQuote(t *testing.T) {
|
||||
domain, err := ParseDomainString(`[('name', '=', 'it\'s')]`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cond := domain[0].(Condition)
|
||||
if cond.Value != "it's" {
|
||||
t.Errorf("expected it's, got %v", cond.Value)
|
||||
}
|
||||
}
|
||||
445
pkg/orm/domain_test.go
Normal file
445
pkg/orm/domain_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDomainCompileEmpty(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
where, params, err := dc.Compile(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != "TRUE" {
|
||||
t.Errorf("expected TRUE, got %s", where)
|
||||
}
|
||||
if len(params) != 0 {
|
||||
t.Errorf("expected 0 params, got %d", len(params))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileSimpleLeaf(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "=", "test")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" = $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if len(params) != 1 || params[0] != "test" {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileNullCheck(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "=", nil)}
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" IS NULL` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileFalseCheck(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("active", "=", false)}
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"active" IS NULL` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileNotEqualNull(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "!=", nil)}
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" IS NOT NULL` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileIn(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("id", "in", []int64{1, 2, 3})}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"id" IN ($1, $2, $3)` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if len(params) != 3 {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileEmptyIn(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("id", "in", []int64{})}
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != "FALSE" {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileNotIn(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("id", "not in", []int64{1, 2})}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"id" NOT IN ($1, $2)` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if len(params) != 2 {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileEmptyNotIn(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("id", "not in", []int64{})}
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != "TRUE" {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileLike(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "ilike", "test")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" ILIKE $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if params[0] != "%test%" {
|
||||
t.Errorf("expected %%test%%, got %v", params[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileLikeWithWildcard(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "ilike", "test%")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" ILIKE $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
// Value already contains wildcard, should not be wrapped again
|
||||
if params[0] != "test%" {
|
||||
t.Errorf("expected test%%, got %v", params[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileNotLike(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "not like", "foo")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" NOT LIKE $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if params[0] != "%foo%" {
|
||||
t.Errorf("expected %%foo%%, got %v", params[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileExactLike(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "=like", "test")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" LIKE $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
// =like does NOT auto-wrap
|
||||
if params[0] != "test" {
|
||||
t.Errorf("expected test, got %v", params[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileExactIlike(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("name", "=ilike", "Test")}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"name" ILIKE $1` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if params[0] != "Test" {
|
||||
t.Errorf("expected Test, got %v", params[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileAnd(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := And(Leaf("a", "=", 1), Leaf("b", "=", 2))
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `("a" = $1 AND "b" = $2)`
|
||||
if where != expected {
|
||||
t.Errorf("expected %s, got %s", expected, where)
|
||||
}
|
||||
if len(params) != 2 {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileOr(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Or(Leaf("a", "=", 1), Leaf("b", "=", 2))
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `("a" = $1 OR "b" = $2)`
|
||||
if where != expected {
|
||||
t.Errorf("expected %s, got %s", expected, where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileNot(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Not(Leaf("active", "=", true))
|
||||
where, _, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `(NOT "active" = $1)`
|
||||
if where != expected {
|
||||
t.Errorf("expected %s, got %s", expected, where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileInvalidOperator(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("a", "INVALID", 1)}
|
||||
_, _, err := dc.Compile(domain)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileComparison(t *testing.T) {
|
||||
ops := []string{"<", ">", "<=", ">="}
|
||||
for _, op := range ops {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("age", op, 18)}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := fmt.Sprintf(`"age" %s $1`, op)
|
||||
if where != expected {
|
||||
t.Errorf("expected %s, got %s", expected, where)
|
||||
}
|
||||
if len(params) != 1 || params[0] != 18 {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainCompileInStrings(t *testing.T) {
|
||||
dc := &DomainCompiler{model: &Model{table: "test"}}
|
||||
domain := Domain{Leaf("state", "in", []string{"draft", "sent"})}
|
||||
where, params, err := dc.Compile(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if where != `"state" IN ($1, $2)` {
|
||||
t.Errorf("got %s", where)
|
||||
}
|
||||
if len(params) != 2 {
|
||||
t.Errorf("params: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndEmpty(t *testing.T) {
|
||||
d := And()
|
||||
if d != nil {
|
||||
t.Errorf("expected nil, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndSingle(t *testing.T) {
|
||||
d := And(Leaf("a", "=", 1))
|
||||
if len(d) != 1 {
|
||||
t.Errorf("expected 1 node, got %d", len(d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrEmpty(t *testing.T) {
|
||||
d := Or()
|
||||
if d != nil {
|
||||
t.Errorf("expected nil, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrSingle(t *testing.T) {
|
||||
d := Or(Leaf("a", "=", 1))
|
||||
if len(d) != 1 {
|
||||
t.Errorf("expected 1 node, got %d", len(d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrMultiple(t *testing.T) {
|
||||
d := Or(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3))
|
||||
// Should have 2 OR operators + 3 leaves = 5 nodes
|
||||
if len(d) != 5 {
|
||||
t.Errorf("expected 5 nodes, got %d", len(d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndMultiple(t *testing.T) {
|
||||
d := And(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3))
|
||||
// Should have 2 AND operators + 3 leaves = 5 nodes
|
||||
if len(d) != 5 {
|
||||
t.Errorf("expected 5 nodes, got %d", len(d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotDomain(t *testing.T) {
|
||||
d := Not(Leaf("active", "=", true))
|
||||
if len(d) != 2 {
|
||||
t.Errorf("expected 2 nodes, got %d", len(d))
|
||||
}
|
||||
if d[0] != OpNot {
|
||||
t.Errorf("expected OpNot, got %v", d[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafCreation(t *testing.T) {
|
||||
c := Leaf("name", "=", "test")
|
||||
if c.Field != "name" {
|
||||
t.Errorf("field: %s", c.Field)
|
||||
}
|
||||
if c.Operator != "=" {
|
||||
t.Errorf("operator: %s", c.Operator)
|
||||
}
|
||||
if c.Value != "test" {
|
||||
t.Errorf("value: %v", c.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapLikeValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input Value
|
||||
want Value
|
||||
}{
|
||||
{"plain string", "test", "%test%"},
|
||||
{"already has %", "test%", "test%"},
|
||||
{"already has _", "test_val", "test_val"},
|
||||
{"non-string", 42, 42},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := wrapLikeValue(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("wrapLikeValue(%v) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSlice(t *testing.T) {
|
||||
t.Run("[]int64", func(t *testing.T) {
|
||||
result := normalizeSlice([]int64{1, 2, 3})
|
||||
if len(result) != 3 {
|
||||
t.Errorf("expected 3, got %d", len(result))
|
||||
}
|
||||
})
|
||||
t.Run("[]string", func(t *testing.T) {
|
||||
result := normalizeSlice([]string{"a", "b"})
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2, got %d", len(result))
|
||||
}
|
||||
})
|
||||
t.Run("[]int", func(t *testing.T) {
|
||||
result := normalizeSlice([]int{1, 2})
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2, got %d", len(result))
|
||||
}
|
||||
})
|
||||
t.Run("[]float64", func(t *testing.T) {
|
||||
result := normalizeSlice([]float64{1.5, 2.5})
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2, got %d", len(result))
|
||||
}
|
||||
})
|
||||
t.Run("non-slice", func(t *testing.T) {
|
||||
result := normalizeSlice("not a slice")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToInt64Slice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val Value
|
||||
want []int64
|
||||
}{
|
||||
{"int64", int64(5), []int64{5}},
|
||||
{"int", int(3), []int64{3}},
|
||||
{"int32", int32(7), []int64{7}},
|
||||
{"float64", float64(9), []int64{9}},
|
||||
{"[]int64", []int64{1, 2}, []int64{1, 2}},
|
||||
{"[]int", []int{3, 4}, []int64{3, 4}},
|
||||
{"string", "bad", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := toInt64Slice(tt.val)
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len: got %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("index %d: got %d, want %d", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
330
pkg/orm/field_test.go
Normal file
330
pkg/orm/field_test.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package orm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFieldIsCopyable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field Field
|
||||
want bool
|
||||
}{
|
||||
{"regular char", Field{Name: "name", Type: TypeChar}, true},
|
||||
{"id field", Field{Name: "id", Type: TypeInteger}, false},
|
||||
{"create_uid", Field{Name: "create_uid", Type: TypeMany2one}, false},
|
||||
{"write_uid", Field{Name: "write_uid", Type: TypeMany2one}, false},
|
||||
{"create_date", Field{Name: "create_date", Type: TypeDatetime}, false},
|
||||
{"write_date", Field{Name: "write_date", Type: TypeDatetime}, false},
|
||||
{"password", Field{Name: "password", Type: TypeChar}, false},
|
||||
{"computed non-stored", Field{Name: "total", Type: TypeFloat, Compute: "x"}, false},
|
||||
{"computed stored", Field{Name: "total", Type: TypeFloat, Compute: "x", Store: true}, true},
|
||||
{"o2m", Field{Name: "lines", Type: TypeOne2many}, false},
|
||||
{"m2o", Field{Name: "partner_id", Type: TypeMany2one}, true},
|
||||
{"boolean", Field{Name: "active", Type: TypeBoolean}, true},
|
||||
{"explicit copy true", Field{Name: "ref", Type: TypeChar, Copy: true}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.field.IsCopyable()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsCopyable() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldSQLType(t *testing.T) {
|
||||
tests := []struct {
|
||||
typ FieldType
|
||||
want string
|
||||
}{
|
||||
{TypeChar, "varchar"},
|
||||
{TypeText, "text"},
|
||||
{TypeHTML, "text"},
|
||||
{TypeInteger, "int4"},
|
||||
{TypeFloat, "numeric"},
|
||||
{TypeMonetary, "numeric"},
|
||||
{TypeBoolean, "bool"},
|
||||
{TypeDate, "date"},
|
||||
{TypeDatetime, "timestamp without time zone"},
|
||||
{TypeMany2one, "int4"},
|
||||
{TypeOne2many, ""},
|
||||
{TypeMany2many, ""},
|
||||
{TypeJson, "jsonb"},
|
||||
{TypeProperties, "jsonb"},
|
||||
{TypeBinary, "bytea"},
|
||||
{TypeSelection, "varchar"},
|
||||
{TypeReference, "varchar"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.typ.String(), func(t *testing.T) {
|
||||
got := tt.typ.SQLType()
|
||||
if got != tt.want {
|
||||
t.Errorf("SQLType() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldSQLTypeWithSize(t *testing.T) {
|
||||
f := &Field{Type: TypeChar, Size: 64}
|
||||
got := f.SQLType()
|
||||
if got != "varchar(64)" {
|
||||
t.Errorf("expected varchar(64), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldTypeString(t *testing.T) {
|
||||
if TypeChar.String() != "char" {
|
||||
t.Error("expected char")
|
||||
}
|
||||
if TypeMany2one.String() != "many2one" {
|
||||
t.Error("expected many2one")
|
||||
}
|
||||
if TypeBoolean.String() != "boolean" {
|
||||
t.Error("expected boolean")
|
||||
}
|
||||
if TypeText.String() != "text" {
|
||||
t.Error("expected text")
|
||||
}
|
||||
if TypeInteger.String() != "integer" {
|
||||
t.Error("expected integer")
|
||||
}
|
||||
if TypeFloat.String() != "float" {
|
||||
t.Error("expected float")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldTypeIsRelational(t *testing.T) {
|
||||
if !TypeMany2one.IsRelational() {
|
||||
t.Error("m2o should be relational")
|
||||
}
|
||||
if !TypeOne2many.IsRelational() {
|
||||
t.Error("o2m should be relational")
|
||||
}
|
||||
if !TypeMany2many.IsRelational() {
|
||||
t.Error("m2m should be relational")
|
||||
}
|
||||
if TypeChar.IsRelational() {
|
||||
t.Error("char should not be relational")
|
||||
}
|
||||
if TypeInteger.IsRelational() {
|
||||
t.Error("integer should not be relational")
|
||||
}
|
||||
if TypeBoolean.IsRelational() {
|
||||
t.Error("boolean should not be relational")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldTypeIsStored(t *testing.T) {
|
||||
if !TypeChar.IsStored() {
|
||||
t.Error("char should be stored")
|
||||
}
|
||||
if !TypeMany2one.IsStored() {
|
||||
t.Error("m2o should be stored")
|
||||
}
|
||||
if TypeOne2many.IsStored() {
|
||||
t.Error("o2m should not be stored")
|
||||
}
|
||||
if TypeMany2many.IsStored() {
|
||||
t.Error("m2m should not be stored")
|
||||
}
|
||||
if !TypeBoolean.IsStored() {
|
||||
t.Error("boolean should be stored")
|
||||
}
|
||||
if !TypeInteger.IsStored() {
|
||||
t.Error("integer should be stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldIsStored(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f Field
|
||||
want bool
|
||||
}{
|
||||
{"plain char", Field{Type: TypeChar}, true},
|
||||
{"computed not stored", Field{Type: TypeChar, Compute: "x"}, false},
|
||||
{"computed stored", Field{Type: TypeChar, Compute: "x", Store: true}, true},
|
||||
{"related not stored", Field{Type: TypeChar, Related: "partner_id.name"}, false},
|
||||
{"related stored", Field{Type: TypeChar, Related: "partner_id.name", Store: true}, true},
|
||||
{"o2m", Field{Type: TypeOne2many}, false},
|
||||
{"m2m", Field{Type: TypeMany2many}, false},
|
||||
{"m2o", Field{Type: TypeMany2one}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.f.IsStored()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsStored() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldColumn(t *testing.T) {
|
||||
f := &Field{Name: "partner_id", column: "partner_id"}
|
||||
if f.Column() != "partner_id" {
|
||||
t.Errorf("expected partner_id, got %s", f.Column())
|
||||
}
|
||||
|
||||
f2 := &Field{Name: "custom", column: "custom_col"}
|
||||
if f2.Column() != "custom_col" {
|
||||
t.Errorf("expected custom_col, got %s", f2.Column())
|
||||
}
|
||||
|
||||
// When column is empty, falls back to Name
|
||||
f3 := &Field{Name: "fallback"}
|
||||
if f3.Column() != "fallback" {
|
||||
t.Errorf("expected fallback, got %s", f3.Column())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldConstructors(t *testing.T) {
|
||||
t.Run("Char", func(t *testing.T) {
|
||||
f := Char("name", FieldOpts{String: "Name", Required: true})
|
||||
if f.Type != TypeChar {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
if f.Name != "name" {
|
||||
t.Errorf("name: %s", f.Name)
|
||||
}
|
||||
if !f.Required {
|
||||
t.Error("expected required")
|
||||
}
|
||||
if f.String != "Name" {
|
||||
t.Errorf("string: %s", f.String)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Integer", func(t *testing.T) {
|
||||
f := Integer("count", FieldOpts{})
|
||||
if f.Type != TypeInteger {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Boolean", func(t *testing.T) {
|
||||
f := Boolean("active", FieldOpts{Default: true})
|
||||
if f.Type != TypeBoolean {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
if f.Default != true {
|
||||
t.Error("expected default true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Many2one", func(t *testing.T) {
|
||||
f := Many2one("partner_id", "res.partner", FieldOpts{String: "Partner"})
|
||||
if f.Type != TypeMany2one {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
if f.Comodel != "res.partner" {
|
||||
t.Errorf("comodel: %s", f.Comodel)
|
||||
}
|
||||
if !f.Index {
|
||||
t.Error("M2O should be auto-indexed")
|
||||
}
|
||||
if f.OnDelete != OnDeleteSetNull {
|
||||
t.Errorf("expected set null, got %s", f.OnDelete)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("One2many", func(t *testing.T) {
|
||||
f := One2many("line_ids", "sale.order.line", "order_id", FieldOpts{})
|
||||
if f.Type != TypeOne2many {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
if f.InverseField != "order_id" {
|
||||
t.Errorf("inverse: %s", f.InverseField)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Many2many", func(t *testing.T) {
|
||||
f := Many2many("tag_ids", "res.partner.tag", FieldOpts{})
|
||||
if f.Type != TypeMany2many {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
if f.Comodel != "res.partner.tag" {
|
||||
t.Errorf("comodel: %s", f.Comodel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Text", func(t *testing.T) {
|
||||
f := Text("description", FieldOpts{})
|
||||
if f.Type != TypeText {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Float", func(t *testing.T) {
|
||||
f := Float("amount", FieldOpts{})
|
||||
if f.Type != TypeFloat {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Date", func(t *testing.T) {
|
||||
f := Date("birthday", FieldOpts{})
|
||||
if f.Type != TypeDate {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Datetime", func(t *testing.T) {
|
||||
f := Datetime("created", FieldOpts{})
|
||||
if f.Type != TypeDatetime {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Binary", func(t *testing.T) {
|
||||
f := Binary("image", FieldOpts{})
|
||||
if f.Type != TypeBinary {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Json", func(t *testing.T) {
|
||||
f := Json("data", FieldOpts{})
|
||||
if f.Type != TypeJson {
|
||||
t.Errorf("type: %s", f.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default label from name", func(t *testing.T) {
|
||||
f := Char("my_field", FieldOpts{})
|
||||
if f.String != "my_field" {
|
||||
t.Errorf("expected my_field as default label, got %s", f.String)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFieldResolveDefault(t *testing.T) {
|
||||
t.Run("nil default", func(t *testing.T) {
|
||||
f := &Field{Default: nil}
|
||||
if f.ResolveDefault() != nil {
|
||||
t.Error("expected nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string default", func(t *testing.T) {
|
||||
f := &Field{Default: "hello"}
|
||||
if f.ResolveDefault() != "hello" {
|
||||
t.Error("expected hello")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool default", func(t *testing.T) {
|
||||
f := &Field{Default: true}
|
||||
if f.ResolveDefault() != true {
|
||||
t.Error("expected true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("int default", func(t *testing.T) {
|
||||
f := &Field{Default: 42}
|
||||
if f.ResolveDefault() != 42 {
|
||||
t.Error("expected 42")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,8 +45,9 @@ type Model struct {
|
||||
checkCompany bool // Enforce multi-company record rules
|
||||
|
||||
// Hooks
|
||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||
BeforeWrite func(env *Environment, ids []int64, vals Values) error // Called before UPDATE — for state guards
|
||||
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
||||
Constraints []ConstraintFunc // Validation constraints
|
||||
Methods map[string]MethodFunc // Named business methods
|
||||
|
||||
@@ -162,6 +163,10 @@ func (m *Model) addMagicFields() {
|
||||
// AddField adds a field to this model.
|
||||
func (m *Model) AddField(f *Field) *Model {
|
||||
f.model = m
|
||||
// Skip duplicate field (ExtendModel may add same field from multiple modules)
|
||||
if _, exists := m.fields[f.Name]; exists {
|
||||
return m
|
||||
}
|
||||
m.fields[f.Name] = f
|
||||
m.allFields[f.Name] = f
|
||||
m.fieldOrder = append(m.fieldOrder, f.Name)
|
||||
@@ -449,3 +454,32 @@ func (m *Model) Many2manyTableSQL() []string {
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
// StateGuard returns a BeforeWrite function that prevents modifications on records
|
||||
// in certain states, except for explicitly allowed fields.
|
||||
// Eliminates the duplicated guard pattern across sale.order, purchase.order,
|
||||
// account.move, and stock.picking.
|
||||
func StateGuard(table, stateCondition string, allowedFields []string, errMsg string) func(env *Environment, ids []int64, vals Values) error {
|
||||
allowed := make(map[string]bool, len(allowedFields))
|
||||
for _, f := range allowedFields {
|
||||
allowed[f] = true
|
||||
}
|
||||
return func(env *Environment, ids []int64, vals Values) error {
|
||||
if _, changingState := vals["state"]; changingState {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ANY($1) AND %s`, table, stateCondition), ids,
|
||||
).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return nil
|
||||
}
|
||||
for field := range vals {
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("%s: %s", table, errMsg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
373
pkg/orm/model_test.go
Normal file
373
pkg/orm/model_test.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
m := NewModel("test.model.unit.new", ModelOpts{
|
||||
Description: "Test Model",
|
||||
RecName: "name",
|
||||
})
|
||||
if m.Name() != "test.model.unit.new" {
|
||||
t.Errorf("name: %s", m.Name())
|
||||
}
|
||||
if m.Table() != "test_model_unit_new" {
|
||||
t.Errorf("table: %s", m.Table())
|
||||
}
|
||||
if m.Description() != "Test Model" {
|
||||
t.Errorf("desc: %s", m.Description())
|
||||
}
|
||||
if m.RecName() != "name" {
|
||||
t.Errorf("rec_name: %s", m.RecName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelDefaults(t *testing.T) {
|
||||
m := NewModel("test.model.defaults", ModelOpts{})
|
||||
if m.Order() != "id" {
|
||||
t.Errorf("default order: %s", m.Order())
|
||||
}
|
||||
if m.RecName() != "name" {
|
||||
t.Errorf("default rec_name: %s", m.RecName())
|
||||
}
|
||||
if m.IsAbstract() {
|
||||
t.Error("should not be abstract")
|
||||
}
|
||||
if m.IsTransient() {
|
||||
t.Error("should not be transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelCustomTable(t *testing.T) {
|
||||
m := NewModel("test.model.custom.table", ModelOpts{
|
||||
Table: "my_custom_table",
|
||||
})
|
||||
if m.Table() != "my_custom_table" {
|
||||
t.Errorf("table: %s", m.Table())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelAbstract(t *testing.T) {
|
||||
m := NewModel("test.model.abstract", ModelOpts{
|
||||
Type: ModelAbstract,
|
||||
})
|
||||
if m.IsAbstract() != true {
|
||||
t.Error("should be abstract")
|
||||
}
|
||||
// Abstract models have no table
|
||||
if m.Table() != "" {
|
||||
t.Errorf("abstract should have no table, got %s", m.Table())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelTransient(t *testing.T) {
|
||||
m := NewModel("test.model.transient", ModelOpts{
|
||||
Type: ModelTransient,
|
||||
})
|
||||
if m.IsTransient() != true {
|
||||
t.Error("should be transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMagicFields(t *testing.T) {
|
||||
m := NewModel("test.model.magic", ModelOpts{})
|
||||
// Magic fields should be auto-created
|
||||
if f := m.GetField("id"); f == nil {
|
||||
t.Error("id field missing")
|
||||
}
|
||||
if f := m.GetField("display_name"); f == nil {
|
||||
t.Error("display_name field missing")
|
||||
}
|
||||
if f := m.GetField("create_uid"); f == nil {
|
||||
t.Error("create_uid field missing")
|
||||
}
|
||||
if f := m.GetField("create_date"); f == nil {
|
||||
t.Error("create_date field missing")
|
||||
}
|
||||
if f := m.GetField("write_uid"); f == nil {
|
||||
t.Error("write_uid field missing")
|
||||
}
|
||||
if f := m.GetField("write_date"); f == nil {
|
||||
t.Error("write_date field missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelAddFields(t *testing.T) {
|
||||
m := NewModel("test.model.fields.add", ModelOpts{})
|
||||
m.AddFields(
|
||||
Char("name", FieldOpts{String: "Name", Required: true}),
|
||||
Integer("age", FieldOpts{String: "Age"}),
|
||||
Boolean("active", FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
|
||||
if f := m.GetField("name"); f == nil {
|
||||
t.Error("name field missing")
|
||||
}
|
||||
if f := m.GetField("age"); f == nil {
|
||||
t.Error("age field missing")
|
||||
}
|
||||
if f := m.GetField("active"); f == nil {
|
||||
t.Error("active field missing")
|
||||
}
|
||||
if f := m.GetField("nonexistent"); f != nil {
|
||||
t.Error("should be nil")
|
||||
}
|
||||
|
||||
nameF := m.GetField("name")
|
||||
if nameF.Type != TypeChar {
|
||||
t.Error("expected char")
|
||||
}
|
||||
if !nameF.Required {
|
||||
t.Error("expected required")
|
||||
}
|
||||
if nameF.String != "Name" {
|
||||
t.Error("expected Name label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelAddFieldSetsModel(t *testing.T) {
|
||||
m := NewModel("test.model.field.backref", ModelOpts{})
|
||||
f := Char("ref", FieldOpts{})
|
||||
m.AddField(f)
|
||||
if f.model != m {
|
||||
t.Error("field should have back-reference to model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelStoredFields(t *testing.T) {
|
||||
m := NewModel("test.model.stored", ModelOpts{})
|
||||
m.AddFields(
|
||||
Char("name", FieldOpts{}),
|
||||
Char("computed_field", FieldOpts{Compute: "x"}),
|
||||
One2many("lines", "other.model", "parent_id", FieldOpts{}),
|
||||
)
|
||||
|
||||
stored := m.StoredFields()
|
||||
// Should include magic fields + name, but not computed_field or o2m
|
||||
nameFound := false
|
||||
computedFound := false
|
||||
linesFound := false
|
||||
for _, f := range stored {
|
||||
switch f.Name {
|
||||
case "name":
|
||||
nameFound = true
|
||||
case "computed_field":
|
||||
computedFound = true
|
||||
case "lines":
|
||||
linesFound = true
|
||||
}
|
||||
}
|
||||
if !nameFound {
|
||||
t.Error("name should be in stored fields")
|
||||
}
|
||||
if computedFound {
|
||||
t.Error("computed_field should not be in stored fields")
|
||||
}
|
||||
if linesFound {
|
||||
t.Error("o2m lines should not be in stored fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegisterMethod(t *testing.T) {
|
||||
m := NewModel("test.model.methods.reg", ModelOpts{})
|
||||
called := false
|
||||
m.RegisterMethod("test_action", func(rs *Recordset, args ...interface{}) (interface{}, error) {
|
||||
called = true
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
if _, ok := m.Methods["test_action"]; !ok {
|
||||
t.Error("method not registered")
|
||||
}
|
||||
|
||||
result, err := m.Methods["test_action"](nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "ok" {
|
||||
t.Error("expected ok")
|
||||
}
|
||||
if !called {
|
||||
t.Error("method not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendModel(t *testing.T) {
|
||||
NewModel("test.model.base.ext", ModelOpts{Description: "Base"})
|
||||
|
||||
ext := ExtendModel("test.model.base.ext")
|
||||
ext.AddFields(Char("extra_field", FieldOpts{String: "Extra"}))
|
||||
|
||||
base := Registry.Get("test.model.base.ext")
|
||||
if f := base.GetField("extra_field"); f == nil {
|
||||
t.Error("extension field missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendModelPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("expected panic for missing model")
|
||||
}
|
||||
}()
|
||||
ExtendModel("nonexistent.model.xyz.panic")
|
||||
}
|
||||
|
||||
func TestRegistryGet(t *testing.T) {
|
||||
NewModel("test.registry.get.model", ModelOpts{})
|
||||
if m := Registry.Get("test.registry.get.model"); m == nil {
|
||||
t.Error("model not found")
|
||||
}
|
||||
if m := Registry.Get("nonexistent.registry.model"); m != nil {
|
||||
t.Error("should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryMustGetPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("expected panic for missing model")
|
||||
}
|
||||
}()
|
||||
Registry.MustGet("nonexistent.mustget.model")
|
||||
}
|
||||
|
||||
func TestRegistryAll(t *testing.T) {
|
||||
NewModel("test.registry.all.model", ModelOpts{})
|
||||
all := Registry.All()
|
||||
found := false
|
||||
for _, name := range all {
|
||||
if name == "test.registry.all.model" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("model not in Registry.All()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryModels(t *testing.T) {
|
||||
NewModel("test.registry.models.model", ModelOpts{})
|
||||
models := Registry.Models()
|
||||
if _, ok := models["test.registry.models.model"]; !ok {
|
||||
t.Error("model not in Registry.Models()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSQLConstraint(t *testing.T) {
|
||||
m := NewModel("test.model.constraint.sql", ModelOpts{})
|
||||
m.AddSQLConstraint("unique_name", "UNIQUE(name)", "Name must be unique")
|
||||
if len(m.SQLConstraints) != 1 {
|
||||
t.Error("constraint not added")
|
||||
}
|
||||
if m.SQLConstraints[0].Name != "unique_name" {
|
||||
t.Error("wrong name")
|
||||
}
|
||||
if m.SQLConstraints[0].Definition != "UNIQUE(name)" {
|
||||
t.Error("wrong definition")
|
||||
}
|
||||
if m.SQLConstraints[0].Message != "Name must be unique" {
|
||||
t.Error("wrong message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelAddConstraint(t *testing.T) {
|
||||
m := NewModel("test.model.constraint.func", ModelOpts{})
|
||||
m.AddConstraint(func(rs *Recordset) error {
|
||||
return fmt.Errorf("test error")
|
||||
})
|
||||
if len(m.Constraints) != 1 {
|
||||
t.Error("constraint not added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegisterOnchange(t *testing.T) {
|
||||
m := NewModel("test.model.onchange", ModelOpts{})
|
||||
m.RegisterOnchange("partner_id", func(env *Environment, vals Values) Values {
|
||||
return Values{"name": "changed"}
|
||||
})
|
||||
if m.OnchangeHandlers == nil {
|
||||
t.Fatal("OnchangeHandlers should not be nil")
|
||||
}
|
||||
if _, ok := m.OnchangeHandlers["partner_id"]; !ok {
|
||||
t.Error("onchange handler not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegisterInverse(t *testing.T) {
|
||||
m := NewModel("test.model.inverse", ModelOpts{})
|
||||
m.AddFields(Char("computed", FieldOpts{Compute: "_compute_computed"}))
|
||||
m.RegisterInverse("computed", func(rs *Recordset, args ...interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
f := m.GetField("computed")
|
||||
if f.Inverse != "_inverse_computed" {
|
||||
t.Errorf("expected _inverse_computed, got %s", f.Inverse)
|
||||
}
|
||||
if _, ok := m.Methods["_inverse_computed"]; !ok {
|
||||
t.Error("inverse method not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCreateTableSQL(t *testing.T) {
|
||||
m := NewModel("test.model.ddl", ModelOpts{})
|
||||
m.AddFields(
|
||||
Char("name", FieldOpts{Required: true}),
|
||||
Integer("count", FieldOpts{}),
|
||||
)
|
||||
|
||||
sql := m.CreateTableSQL()
|
||||
if sql == "" {
|
||||
t.Fatal("expected non-empty SQL")
|
||||
}
|
||||
// Should contain the table name
|
||||
if !containsStr(sql, `"test_model_ddl"`) {
|
||||
t.Error("missing table name in DDL")
|
||||
}
|
||||
// Should contain name column
|
||||
if !containsStr(sql, `"name"`) {
|
||||
t.Error("missing name column in DDL")
|
||||
}
|
||||
// Should contain NOT NULL for required
|
||||
if !containsStr(sql, "NOT NULL") {
|
||||
t.Error("missing NOT NULL for required field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCreateTableSQLAbstract(t *testing.T) {
|
||||
m := NewModel("test.model.ddl.abstract", ModelOpts{Type: ModelAbstract})
|
||||
sql := m.CreateTableSQL()
|
||||
if sql != "" {
|
||||
t.Error("abstract model should have empty DDL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelFields(t *testing.T) {
|
||||
m := NewModel("test.model.all.fields", ModelOpts{})
|
||||
m.AddFields(Char("name", FieldOpts{}))
|
||||
fields := m.Fields()
|
||||
if _, ok := fields["name"]; !ok {
|
||||
t.Error("name not in Fields()")
|
||||
}
|
||||
if _, ok := fields["id"]; !ok {
|
||||
t.Error("id not in Fields()")
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr is a test helper - checks if s contains substr
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContains(s, substr))
|
||||
}
|
||||
|
||||
func stringContains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []str
|
||||
// Build ORDER BY
|
||||
orderSQL := ""
|
||||
if opt.Order != "" {
|
||||
orderSQL = opt.Order
|
||||
orderSQL = sanitizeOrderBy(opt.Order, m)
|
||||
} else if len(gbCols) > 0 {
|
||||
// Default: order by groupby columns
|
||||
var orderParts []string
|
||||
|
||||
@@ -2,6 +2,7 @@ package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -265,18 +266,28 @@ func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Value
|
||||
value := vals[fieldName]
|
||||
delete(vals, fieldName) // Remove from vals — no local column
|
||||
|
||||
// Read FK IDs for all records
|
||||
// Read FK IDs for all records in a single query
|
||||
var fkIDs []int64
|
||||
for _, id := range ids {
|
||||
var fkID *int64
|
||||
env.tx.QueryRow(env.ctx,
|
||||
fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
|
||||
id,
|
||||
).Scan(&fkID)
|
||||
if fkID != nil && *fkID > 0 {
|
||||
fkIDs = append(fkIDs, *fkID)
|
||||
rows, err := env.tx.Query(env.ctx,
|
||||
fmt.Sprintf(`SELECT %q FROM %q WHERE id = ANY($1) AND %q IS NOT NULL`,
|
||||
fkDef.Column(), m.Table(), fkDef.Column()),
|
||||
ids,
|
||||
)
|
||||
if err != nil {
|
||||
delete(vals, fieldName)
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var fkID int64
|
||||
if err := rows.Scan(&fkID); err != nil {
|
||||
log.Printf("orm: preprocessRelatedWrites scan error on %s.%s: %v", m.Name(), fieldName, err)
|
||||
continue
|
||||
}
|
||||
if fkID > 0 {
|
||||
fkIDs = append(fkIDs, fkID)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(fkIDs) == 0 {
|
||||
continue
|
||||
@@ -315,6 +326,13 @@ func (rs *Recordset) Write(vals Values) error {
|
||||
|
||||
m := rs.model
|
||||
|
||||
// BeforeWrite hook — state guards, locked record checks etc.
|
||||
if m.BeforeWrite != nil {
|
||||
if err := m.BeforeWrite(rs.env, rs.ids, vals); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
@@ -787,7 +805,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
|
||||
// Build query
|
||||
order := m.order
|
||||
if opt.Order != "" {
|
||||
order = opt.Order
|
||||
order = sanitizeOrderBy(opt.Order, m)
|
||||
}
|
||||
|
||||
joinSQL := compiler.JoinSQL()
|
||||
@@ -1103,6 +1121,72 @@ func toRecordID(v interface{}) (int64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// sanitizeOrderBy validates an ORDER BY clause to prevent SQL injection.
|
||||
// Only allows: field names (alphanumeric + underscore), ASC/DESC, NULLS FIRST/LAST, commas.
|
||||
// Returns sanitized string or fallback to "id" if invalid.
|
||||
func sanitizeOrderBy(order string, m *Model) string {
|
||||
if order == "" {
|
||||
return "id"
|
||||
}
|
||||
parts := strings.Split(order, ",")
|
||||
var safe []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tokens := strings.Fields(part)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
// First token must be a valid field name or "table"."field"
|
||||
col := tokens[0]
|
||||
// Strip quotes for validation
|
||||
cleanCol := strings.ReplaceAll(strings.ReplaceAll(col, "\"", ""), "'", "")
|
||||
// Allow dot notation (table.field) but validate each part
|
||||
colParts := strings.Split(cleanCol, ".")
|
||||
valid := true
|
||||
for _, cp := range colParts {
|
||||
if !isValidIdentifier(cp) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
continue // Skip this part entirely
|
||||
}
|
||||
// Remaining tokens must be ASC, DESC, NULLS, FIRST, LAST
|
||||
safePart := col
|
||||
for _, tok := range tokens[1:] {
|
||||
upper := strings.ToUpper(tok)
|
||||
switch upper {
|
||||
case "ASC", "DESC", "NULLS", "FIRST", "LAST":
|
||||
safePart += " " + upper
|
||||
default:
|
||||
// Invalid token — skip
|
||||
}
|
||||
}
|
||||
safe = append(safe, safePart)
|
||||
}
|
||||
if len(safe) == 0 {
|
||||
return "id"
|
||||
}
|
||||
return strings.Join(safe, ", ")
|
||||
}
|
||||
|
||||
// isValidIdentifier checks if a string is a valid SQL identifier (letters, digits, underscore).
|
||||
func isValidIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qualifyOrderBy prefixes unqualified column names with the table name.
|
||||
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
|
||||
func qualifyOrderBy(table, order string) string {
|
||||
|
||||
@@ -70,8 +70,9 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
|
||||
ORDER BY r.id`,
|
||||
m.Name(), env.UID())
|
||||
if err != nil {
|
||||
log.Printf("orm: ir.rule query failed for %s: %v — denying access", m.Name(), err)
|
||||
sp.Rollback(env.ctx)
|
||||
return domain
|
||||
return append(domain, Leaf("id", "=", -1)) // Deny all — no records match id=-1
|
||||
}
|
||||
|
||||
type ruleRow struct {
|
||||
@@ -207,7 +208,8 @@ func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string)
|
||||
var count int64
|
||||
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return nil // Fail open on error
|
||||
log.Printf("orm: record rule check failed for %s: %v", m.Name(), err)
|
||||
return fmt.Errorf("orm: access denied on %s (record rule check failed)", m.Name())
|
||||
}
|
||||
|
||||
if count < int64(len(ids)) {
|
||||
|
||||
109
pkg/orm/sanitize_test.go
Normal file
109
pkg/orm/sanitize_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package orm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeFieldValueBoolFalse(t *testing.T) {
|
||||
// false for boolean field should stay false
|
||||
f := &Field{Type: TypeBoolean}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != false {
|
||||
t.Errorf("expected false, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueBoolTrue(t *testing.T) {
|
||||
f := &Field{Type: TypeBoolean}
|
||||
got := sanitizeFieldValue(f, true)
|
||||
if got != true {
|
||||
t.Errorf("expected true, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueCharFalse(t *testing.T) {
|
||||
// false for char field should become nil
|
||||
f := &Field{Type: TypeChar}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueIntFalse(t *testing.T) {
|
||||
f := &Field{Type: TypeInteger}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueFloatFalse(t *testing.T) {
|
||||
f := &Field{Type: TypeFloat}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueM2OFalse(t *testing.T) {
|
||||
f := &Field{Type: TypeMany2one}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueDateFalse(t *testing.T) {
|
||||
f := &Field{Type: TypeDate}
|
||||
got := sanitizeFieldValue(f, false)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueFloat64ToInt(t *testing.T) {
|
||||
f := &Field{Type: TypeInteger}
|
||||
got := sanitizeFieldValue(f, float64(42))
|
||||
if got != int64(42) {
|
||||
t.Errorf("expected int64(42), got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueM2OFloat(t *testing.T) {
|
||||
f := &Field{Type: TypeMany2one}
|
||||
got := sanitizeFieldValue(f, float64(5))
|
||||
if got != int64(5) {
|
||||
t.Errorf("expected int64(5), got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueNil(t *testing.T) {
|
||||
f := &Field{Type: TypeChar}
|
||||
got := sanitizeFieldValue(f, nil)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValuePassthrough(t *testing.T) {
|
||||
f := &Field{Type: TypeChar}
|
||||
got := sanitizeFieldValue(f, "hello")
|
||||
if got != "hello" {
|
||||
t.Errorf("expected hello, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueIntPassthrough(t *testing.T) {
|
||||
f := &Field{Type: TypeInteger}
|
||||
got := sanitizeFieldValue(f, int64(99))
|
||||
if got != int64(99) {
|
||||
t.Errorf("expected int64(99), got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldValueFloatPassthrough(t *testing.T) {
|
||||
f := &Field{Type: TypeFloat}
|
||||
got := sanitizeFieldValue(f, float64(3.14))
|
||||
if got != float64(3.14) {
|
||||
t.Errorf("expected 3.14, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -145,10 +146,12 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Look up xml_id from ir_model_data
|
||||
xmlID := ""
|
||||
_ = s.pool.QueryRow(ctx,
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT module || '.' || name FROM ir_model_data
|
||||
WHERE model = 'ir.actions.act_window' AND res_id = $1
|
||||
LIMIT 1`, id).Scan(&xmlID)
|
||||
LIMIT 1`, id).Scan(&xmlID); err != nil {
|
||||
log.Printf("warning: action xml_id lookup failed for id=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]])
|
||||
views := buildViewsFromMode(viewMode)
|
||||
@@ -162,8 +165,8 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
||||
"view_mode": coalesce(viewMode, "list,form"),
|
||||
"views": views,
|
||||
"search_view_id": false,
|
||||
"domain": coalesce(deref(domain), "[]"),
|
||||
"context": coalesce(deref(actCtx), "{}"),
|
||||
"domain": parseDomainOrDefault(deref(domain)),
|
||||
"context": parseContextOrDefault(deref(actCtx)),
|
||||
"target": coalesce(deref(target), "current"),
|
||||
"limit": coalesceInt(limit, 80),
|
||||
"help": deref(help),
|
||||
@@ -225,6 +228,45 @@ func coalesceInt(p *int, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// parseDomainOrDefault tries to parse a Python-style domain string to JSON.
|
||||
// Returns parsed []interface{} if valid JSON, otherwise returns "[]".
|
||||
func parseDomainOrDefault(s string) interface{} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "[]" {
|
||||
return []interface{}{}
|
||||
}
|
||||
// Try JSON parse directly (handles [["field","op","val"]] format)
|
||||
var result []interface{}
|
||||
// Convert Python-style to JSON: replace ( with [, ) with ], True/False/None
|
||||
jsonStr := strings.ReplaceAll(s, "(", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ")", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "'", "\"")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "True", "true")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "False", "false")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "None", "null")
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err == nil {
|
||||
return result
|
||||
}
|
||||
// Fallback: return as-is string (client may handle it)
|
||||
return s
|
||||
}
|
||||
|
||||
// parseContextOrDefault tries to parse context string to JSON object.
|
||||
func parseContextOrDefault(s string) interface{} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "{}" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var result map[string]interface{}
|
||||
jsonStr := strings.ReplaceAll(s, "'", "\"")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "True", "true")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "False", "false")
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err == nil {
|
||||
return result
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// deref returns the value of a string pointer, or "" if nil.
|
||||
func deref(p *string) string {
|
||||
if p != nil {
|
||||
|
||||
292
pkg/server/bank_import.go
Normal file
292
pkg/server/bank_import.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleBankStatementImport imports bank statement lines from CSV data.
|
||||
// Accepts JSON body with: journal_id, csv_data, column_mapping, has_header.
|
||||
// After import, optionally triggers auto-matching against open invoices.
|
||||
// Mirrors: odoo/addons/account/wizard/account_bank_statement_import.py
|
||||
func (s *Server) handleBankStatementImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
JournalID int64 `json:"journal_id"`
|
||||
CSVData string `json:"csv_data"`
|
||||
HasHeader bool `json:"has_header"`
|
||||
ColumnMapping bankColumnMapping `json:"column_mapping"`
|
||||
AutoMatch bool `json:"auto_match"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.JournalID == 0 || params.CSVData == "" {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "journal_id and csv_data are required"})
|
||||
return
|
||||
}
|
||||
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool,
|
||||
UID: uid,
|
||||
CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
// Parse CSV
|
||||
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
// Try semicolon separator (common in European bank exports)
|
||||
reader.Comma = detectDelimiter(params.CSVData)
|
||||
|
||||
var allRows [][]string
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||
return
|
||||
}
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
|
||||
dataRows := allRows
|
||||
if params.HasHeader && len(allRows) > 1 {
|
||||
dataRows = allRows[1:]
|
||||
}
|
||||
|
||||
// Create a bank statement header
|
||||
statementRS := env.Model("account.bank.statement")
|
||||
stmt, err := statementRS.Create(orm.Values{
|
||||
"name": fmt.Sprintf("Import %s", time.Now().Format("2006-01-02 15:04")),
|
||||
"journal_id": params.JournalID,
|
||||
"company_id": companyID,
|
||||
"date": time.Now().Format("2006-01-02"),
|
||||
})
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Create statement: %v", err)})
|
||||
return
|
||||
}
|
||||
stmtID := stmt.ID()
|
||||
|
||||
// Default column mapping
|
||||
cm := params.ColumnMapping
|
||||
if cm.Date < 0 {
|
||||
cm.Date = 0
|
||||
}
|
||||
if cm.Amount < 0 {
|
||||
cm.Amount = 1
|
||||
}
|
||||
if cm.Label < 0 {
|
||||
cm.Label = 2
|
||||
}
|
||||
|
||||
// Import lines
|
||||
lineRS := env.Model("account.bank.statement.line")
|
||||
var importedIDs []int64
|
||||
var errors []importError
|
||||
|
||||
for rowIdx, row := range dataRows {
|
||||
// Parse date
|
||||
dateStr := safeCol(row, cm.Date)
|
||||
date := parseFlexDate(dateStr)
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Parse amount
|
||||
amountStr := safeCol(row, cm.Amount)
|
||||
amount := parseAmount(amountStr)
|
||||
if amount == 0 {
|
||||
continue // skip zero-amount rows
|
||||
}
|
||||
|
||||
// Parse label/reference
|
||||
label := safeCol(row, cm.Label)
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("Line %d", rowIdx+1)
|
||||
}
|
||||
|
||||
// Parse optional columns
|
||||
partnerName := safeCol(row, cm.PartnerName)
|
||||
accountNumber := safeCol(row, cm.AccountNumber)
|
||||
|
||||
vals := orm.Values{
|
||||
"statement_id": stmtID,
|
||||
"journal_id": params.JournalID,
|
||||
"company_id": companyID,
|
||||
"date": date,
|
||||
"amount": amount,
|
||||
"payment_ref": label,
|
||||
"partner_name": partnerName,
|
||||
"account_number": accountNumber,
|
||||
"sequence": rowIdx + 1,
|
||||
}
|
||||
|
||||
rec, err := lineRS.Create(vals)
|
||||
if err != nil {
|
||||
errors = append(errors, importError{Row: rowIdx + 1, Message: err.Error()})
|
||||
log.Printf("bank_import: row %d error: %v", rowIdx+1, err)
|
||||
continue
|
||||
}
|
||||
importedIDs = append(importedIDs, rec.ID())
|
||||
}
|
||||
|
||||
// Auto-match against open invoices
|
||||
matchCount := 0
|
||||
if params.AutoMatch && len(importedIDs) > 0 {
|
||||
stLineModel := orm.Registry.Get("account.bank.statement.line")
|
||||
if stLineModel != nil {
|
||||
if matchMethod, ok := stLineModel.Methods["button_match"]; ok {
|
||||
matchRS := env.Model("account.bank.statement.line").Browse(importedIDs...)
|
||||
if _, err := matchMethod(matchRS); err != nil {
|
||||
log.Printf("bank_import: auto-match error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Count how many were matched
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_bank_statement_line WHERE id = ANY($1) AND is_reconciled = true`,
|
||||
importedIDs).Scan(&matchCount)
|
||||
}
|
||||
|
||||
if err := env.Commit(); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
s.writeJSONRPC(w, req.ID, map[string]interface{}{
|
||||
"statement_id": stmtID,
|
||||
"imported": len(importedIDs),
|
||||
"matched": matchCount,
|
||||
"errors": errors,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// bankColumnMapping maps CSV columns to bank statement fields.
|
||||
type bankColumnMapping struct {
|
||||
Date int `json:"date"` // column index for date
|
||||
Amount int `json:"amount"` // column index for amount
|
||||
Label int `json:"label"` // column index for label/reference
|
||||
PartnerName int `json:"partner_name"` // column index for partner name (-1 = skip)
|
||||
AccountNumber int `json:"account_number"` // column index for account number (-1 = skip)
|
||||
}
|
||||
|
||||
// detectDelimiter guesses the CSV delimiter (comma, semicolon, or tab).
|
||||
func detectDelimiter(data string) rune {
|
||||
firstLine := data
|
||||
if idx := strings.IndexByte(data, '\n'); idx > 0 {
|
||||
firstLine = data[:idx]
|
||||
}
|
||||
semicolons := strings.Count(firstLine, ";")
|
||||
commas := strings.Count(firstLine, ",")
|
||||
tabs := strings.Count(firstLine, "\t")
|
||||
|
||||
if semicolons > commas && semicolons > tabs {
|
||||
return ';'
|
||||
}
|
||||
if tabs > commas {
|
||||
return '\t'
|
||||
}
|
||||
return ','
|
||||
}
|
||||
|
||||
// safeCol returns the value at index i, or "" if out of bounds.
|
||||
func safeCol(row []string, i int) string {
|
||||
if i < 0 || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
|
||||
// parseFlexDate tries multiple date formats and returns YYYY-MM-DD.
|
||||
func parseFlexDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"02.01.2006", // DD.MM.YYYY (common in EU)
|
||||
"01/02/2006", // MM/DD/YYYY
|
||||
"02/01/2006", // DD/MM/YYYY
|
||||
"2006/01/02",
|
||||
"Jan 2, 2006",
|
||||
"2 Jan 2006",
|
||||
"02-01-2006",
|
||||
"01-02-2006",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseAmount parses a monetary amount string, handling comma/dot decimals and negative formats.
|
||||
func parseAmount(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
// Remove currency symbols and whitespace
|
||||
s = strings.NewReplacer("€", "", "$", "", "£", "", " ", "", "\u00a0", "").Replace(s)
|
||||
|
||||
// Handle European format: 1.234,56 → 1234.56
|
||||
if strings.Contains(s, ",") && strings.Contains(s, ".") {
|
||||
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
|
||||
// comma is decimal: 1.234,56
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
} else {
|
||||
// dot is decimal: 1,234.56
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
}
|
||||
} else if strings.Contains(s, ",") {
|
||||
// Only comma: assume decimal separator
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
}
|
||||
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
241
pkg/server/bus.go
Normal file
241
pkg/server/bus.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bus implements a simple long-polling message bus for Discuss.
|
||||
// Mirrors: odoo/addons/bus/models/bus.py ImBus
|
||||
//
|
||||
// Channels subscribe to notifications. A long-poll request blocks until
|
||||
// a notification arrives or the timeout expires.
|
||||
type Bus struct {
|
||||
mu sync.Mutex
|
||||
channels map[int64][]chan busNotification
|
||||
lastID int64
|
||||
}
|
||||
|
||||
type busNotification struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Message interface{} `json:"message"`
|
||||
}
|
||||
|
||||
// NewBus creates a new message bus.
|
||||
func NewBus() *Bus {
|
||||
return &Bus{
|
||||
channels: make(map[int64][]chan busNotification),
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification to all subscribers of a channel.
|
||||
func (b *Bus) Notify(channelID int64, channel string, message interface{}) {
|
||||
b.mu.Lock()
|
||||
b.lastID++
|
||||
notif := busNotification{
|
||||
ID: b.lastID,
|
||||
Channel: channel,
|
||||
Message: message,
|
||||
}
|
||||
subs := b.channels[channelID]
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- notif:
|
||||
default:
|
||||
// subscriber buffer full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe creates a subscription for a partner's channels.
|
||||
func (b *Bus) Subscribe(partnerID int64) chan busNotification {
|
||||
ch := make(chan busNotification, 10)
|
||||
b.mu.Lock()
|
||||
b.channels[partnerID] = append(b.channels[partnerID], ch)
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscription.
|
||||
func (b *Bus) Unsubscribe(partnerID int64, ch chan busNotification) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
subs := b.channels[partnerID]
|
||||
for i, s := range subs {
|
||||
if s == ch {
|
||||
b.channels[partnerID] = append(subs[:i], subs[i+1:]...)
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerBusRoutes adds the long-polling endpoint.
|
||||
func (s *Server) registerBusRoutes() {
|
||||
if s.bus == nil {
|
||||
s.bus = NewBus()
|
||||
}
|
||||
s.mux.HandleFunc("/longpolling/poll", s.handleBusPoll)
|
||||
s.mux.HandleFunc("/discuss/channel/messages", s.handleDiscussMessages)
|
||||
s.mux.HandleFunc("/discuss/channel/list", s.handleDiscussChannelList)
|
||||
}
|
||||
|
||||
// handleBusPoll implements long-polling for real-time notifications.
|
||||
// Mirrors: odoo/addons/bus/controllers/main.py poll()
|
||||
func (s *Server) handleBusPoll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
writeJSON(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
// Get partner ID
|
||||
var partnerID int64
|
||||
s.pool.QueryRow(r.Context(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
|
||||
).Scan(&partnerID)
|
||||
|
||||
if partnerID == 0 {
|
||||
writeJSON(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe and wait for notifications (max 30s)
|
||||
ch := s.bus.Subscribe(partnerID)
|
||||
defer s.bus.Unsubscribe(partnerID, ch)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case notif := <-ch:
|
||||
writeJSON(w, []busNotification{notif})
|
||||
case <-ctx.Done():
|
||||
writeJSON(w, []interface{}{}) // timeout, empty response
|
||||
}
|
||||
}
|
||||
|
||||
// handleDiscussMessages fetches messages for a channel via JSON-RPC.
|
||||
func (s *Server) handleDiscussMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
}
|
||||
if params.Limit <= 0 {
|
||||
params.Limit = 50
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(r.Context(),
|
||||
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = 'mail.channel' AND m.res_id = $1
|
||||
ORDER BY m.id DESC LIMIT $2`, params.ChannelID, params.Limit)
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Query: %v", err)})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, authorID int64
|
||||
var body, authorName string
|
||||
var date interface{}
|
||||
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id, "body": body, "date": date,
|
||||
}
|
||||
if authorID > 0 {
|
||||
msg["author_id"] = []interface{}{authorID, authorName}
|
||||
} else {
|
||||
msg["author_id"] = false
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
s.writeJSONRPC(w, req.ID, messages, nil)
|
||||
}
|
||||
|
||||
// handleDiscussChannelList returns channels the current user is member of.
|
||||
func (s *Server) handleDiscussChannelList(w http.ResponseWriter, r *http.Request) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var partnerID int64
|
||||
s.pool.QueryRow(r.Context(),
|
||||
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
|
||||
).Scan(&partnerID)
|
||||
|
||||
rows, err := s.pool.Query(r.Context(),
|
||||
`SELECT c.id, c.name, c.channel_type,
|
||||
(SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = c.id) AS members
|
||||
FROM mail_channel c
|
||||
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
|
||||
WHERE c.active = true
|
||||
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
|
||||
if err != nil {
|
||||
log.Printf("discuss: channel list error: %v", err)
|
||||
writeJSON(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var channels []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, channelType string
|
||||
var members int64
|
||||
if err := rows.Scan(&id, &name, &channelType, &members); err != nil {
|
||||
continue
|
||||
}
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"id": id, "name": name, "channel_type": channelType, "member_count": members,
|
||||
})
|
||||
}
|
||||
if channels == nil {
|
||||
channels = []map[string]interface{}{}
|
||||
}
|
||||
writeJSON(w, channels)
|
||||
}
|
||||
@@ -6,37 +6,45 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleExportCSV exports records as CSV.
|
||||
// Mirrors: odoo/addons/web/controllers/export.py ExportController
|
||||
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// exportField describes a field in an export request.
|
||||
type exportField struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// exportData holds the parsed and fetched data for an export operation.
|
||||
type exportData struct {
|
||||
Model string
|
||||
FieldNames []string
|
||||
Headers []string
|
||||
Records []orm.Values
|
||||
}
|
||||
|
||||
// parseExportRequest parses the common request/params/env/search logic shared by CSV and XLSX export.
|
||||
func (s *Server) parseExportRequest(w http.ResponseWriter, r *http.Request) (*exportData, error) {
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var params struct {
|
||||
Data struct {
|
||||
Model string `json:"model"`
|
||||
Fields []exportField `json:"fields"`
|
||||
Domain []interface{} `json:"domain"`
|
||||
IDs []float64 `json:"ids"`
|
||||
Model string `json:"model"`
|
||||
Fields []exportField `json:"fields"`
|
||||
Domain []interface{} `json:"domain"`
|
||||
IDs []float64 `json:"ids"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract UID from session
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
@@ -45,42 +53,31 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool,
|
||||
UID: uid,
|
||||
CompanyID: companyID,
|
||||
Pool: s.pool, UID: uid, CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
rs := env.Model(params.Data.Model)
|
||||
|
||||
// Determine which record IDs to export
|
||||
var ids []int64
|
||||
if len(params.Data.IDs) > 0 {
|
||||
for _, id := range params.Data.IDs {
|
||||
ids = append(ids, int64(id))
|
||||
}
|
||||
} else {
|
||||
// Search with domain
|
||||
domain := parseDomain([]interface{}{params.Data.Domain})
|
||||
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
ids = found.IDs()
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
|
||||
return
|
||||
}
|
||||
|
||||
// Extract field names
|
||||
var fieldNames []string
|
||||
var headers []string
|
||||
for _, f := range params.Data.Fields {
|
||||
@@ -92,42 +89,89 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
headers = append(headers, label)
|
||||
}
|
||||
|
||||
// Read records
|
||||
records, err := rs.Browse(ids...).Read(fieldNames)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
var records []orm.Values
|
||||
if len(ids) > 0 {
|
||||
records, err = rs.Browse(ids...).Read(fieldNames)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := env.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &exportData{
|
||||
Model: params.Data.Model, FieldNames: fieldNames, Headers: headers, Records: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleExportCSV exports records as CSV.
|
||||
// Mirrors: odoo/addons/web/controllers/export.py ExportController
|
||||
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.parseExportRequest(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write CSV
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", data.Model))
|
||||
|
||||
writer := csv.NewWriter(w)
|
||||
defer writer.Flush()
|
||||
|
||||
// Header row
|
||||
writer.Write(headers)
|
||||
|
||||
// Data rows
|
||||
for _, rec := range records {
|
||||
row := make([]string, len(fieldNames))
|
||||
for i, fname := range fieldNames {
|
||||
writer.Write(data.Headers)
|
||||
for _, rec := range data.Records {
|
||||
row := make([]string, len(data.FieldNames))
|
||||
for i, fname := range data.FieldNames {
|
||||
row[i] = formatCSVValue(rec[fname])
|
||||
}
|
||||
writer.Write(row)
|
||||
}
|
||||
}
|
||||
|
||||
// exportField describes a field in an export request.
|
||||
type exportField struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
// handleExportXLSX exports records as XLSX (Excel).
|
||||
// Mirrors: odoo/addons/web/controllers/export.py ExportXlsxController
|
||||
func (s *Server) handleExportXLSX(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.parseExportRequest(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
f := excelize.NewFile()
|
||||
sheet := "Sheet1"
|
||||
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true},
|
||||
})
|
||||
for i, h := range data.Headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
f.SetCellValue(sheet, cell, h)
|
||||
f.SetCellStyle(sheet, cell, cell, headerStyle)
|
||||
}
|
||||
|
||||
for rowIdx, rec := range data.Records {
|
||||
for colIdx, fname := range data.FieldNames {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
f.SetCellValue(sheet, cell, formatCSVValue(rec[fname]))
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.xlsx", data.Model))
|
||||
f.Write(w)
|
||||
}
|
||||
|
||||
// formatCSVValue converts a field value to a CSV string.
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -55,9 +56,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
table := m.Table()
|
||||
var data []byte
|
||||
ctx := r.Context()
|
||||
_ = s.pool.QueryRow(ctx,
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
fmt.Sprintf(`SELECT "%s" FROM "%s" WHERE id = $1`, f.Column(), table), id,
|
||||
).Scan(&data)
|
||||
).Scan(&data); err != nil {
|
||||
log.Printf("warning: image query failed for %s.%s id=%d: %v", model, field, id, err)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
// Detect content type
|
||||
contentType := http.DetectContentType(data)
|
||||
@@ -76,9 +79,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
m := orm.Registry.Get(model)
|
||||
if m != nil {
|
||||
var name string
|
||||
_ = s.pool.QueryRow(r.Context(),
|
||||
if err := s.pool.QueryRow(r.Context(),
|
||||
fmt.Sprintf(`SELECT COALESCE(name, '') FROM "%s" WHERE id = $1`, m.Table()), id,
|
||||
).Scan(&name)
|
||||
).Scan(&name); err != nil {
|
||||
log.Printf("warning: image name lookup failed for %s id=%d: %v", model, id, err)
|
||||
}
|
||||
if len(name) > 0 {
|
||||
initial = strings.ToUpper(name[:1])
|
||||
}
|
||||
|
||||
223
pkg/server/import.go
Normal file
223
pkg/server/import.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleImportCSV imports records from a CSV file into any model.
|
||||
// Accepts JSON body with: model, fields (mapping), csv_data (raw CSV string).
|
||||
// Mirrors: odoo/addons/base_import/controllers/main.py ImportController
|
||||
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
Model string `json:"model"`
|
||||
Fields []importFieldMap `json:"fields"`
|
||||
CSVData string `json:"csv_data"`
|
||||
HasHeader bool `json:"has_header"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.Model == "" || len(params.Fields) == 0 || params.CSVData == "" {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "model, fields, and csv_data are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify model exists
|
||||
m := orm.Registry.Get(params.Model)
|
||||
if m == nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Unknown model: %s", params.Model)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse CSV
|
||||
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
|
||||
var allRows [][]string
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||
return
|
||||
}
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
|
||||
if len(allRows) == 0 {
|
||||
s.writeJSONRPC(w, req.ID, map[string]interface{}{"ids": []int64{}, "count": 0}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip header row if present
|
||||
dataRows := allRows
|
||||
if params.HasHeader && len(allRows) > 1 {
|
||||
dataRows = allRows[1:]
|
||||
}
|
||||
|
||||
// Build field mapping: CSV column index → ORM field name
|
||||
type colMapping struct {
|
||||
colIndex int
|
||||
fieldName string
|
||||
fieldType orm.FieldType
|
||||
}
|
||||
var mappings []colMapping
|
||||
for _, fm := range params.Fields {
|
||||
if fm.FieldName == "" || fm.ColumnIndex < 0 {
|
||||
continue
|
||||
}
|
||||
f := m.GetField(fm.FieldName)
|
||||
if f == nil {
|
||||
continue // skip unknown fields
|
||||
}
|
||||
mappings = append(mappings, colMapping{
|
||||
colIndex: fm.ColumnIndex,
|
||||
fieldName: fm.FieldName,
|
||||
fieldType: f.Type,
|
||||
})
|
||||
}
|
||||
|
||||
if len(mappings) == 0 {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "No valid field mappings"})
|
||||
return
|
||||
}
|
||||
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool,
|
||||
UID: uid,
|
||||
CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
rs := env.Model(params.Model)
|
||||
|
||||
var createdIDs []int64
|
||||
var errors []importError
|
||||
|
||||
for rowIdx, row := range dataRows {
|
||||
vals := make(orm.Values)
|
||||
for _, cm := range mappings {
|
||||
if cm.colIndex >= len(row) {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSpace(row[cm.colIndex])
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
vals[cm.fieldName] = coerceImportValue(raw, cm.fieldType)
|
||||
}
|
||||
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if params.DryRun {
|
||||
continue // validate only, don't create
|
||||
}
|
||||
|
||||
rec, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
errors = append(errors, importError{
|
||||
Row: rowIdx + 1,
|
||||
Message: err.Error(),
|
||||
})
|
||||
log.Printf("import: row %d error: %v", rowIdx+1, err)
|
||||
continue
|
||||
}
|
||||
createdIDs = append(createdIDs, rec.ID())
|
||||
}
|
||||
|
||||
if err := env.Commit(); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit error: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ids": createdIDs,
|
||||
"count": len(createdIDs),
|
||||
"errors": errors,
|
||||
"dry_run": params.DryRun,
|
||||
}
|
||||
s.writeJSONRPC(w, req.ID, result, nil)
|
||||
}
|
||||
|
||||
// importFieldMap maps a CSV column to an ORM field.
|
||||
type importFieldMap struct {
|
||||
ColumnIndex int `json:"column_index"`
|
||||
FieldName string `json:"field_name"`
|
||||
}
|
||||
|
||||
// importError describes a per-row import error.
|
||||
type importError struct {
|
||||
Row int `json:"row"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// coerceImportValue converts a raw CSV string to the appropriate Go type for ORM Create.
|
||||
func coerceImportValue(raw string, ft orm.FieldType) interface{} {
|
||||
switch ft {
|
||||
case orm.TypeInteger:
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
case orm.TypeFloat, orm.TypeMonetary:
|
||||
// Handle comma as decimal separator
|
||||
raw = strings.ReplaceAll(raw, ",", ".")
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
case orm.TypeBoolean:
|
||||
lower := strings.ToLower(raw)
|
||||
return lower == "true" || lower == "1" || lower == "yes" || lower == "ja"
|
||||
case orm.TypeMany2one:
|
||||
// Try as integer ID first, then as name_search later
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return raw // pass as string, ORM may handle name_create
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -43,13 +44,19 @@ func (w *statusWriter) WriteHeader(code int) {
|
||||
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public endpoints (no auth required)
|
||||
path := r.URL.Path
|
||||
path := filepath.Clean(r.URL.Path)
|
||||
if path == "/health" ||
|
||||
path == "/web/login" ||
|
||||
path == "/web/session/authenticate" ||
|
||||
path == "/web/session/logout" ||
|
||||
strings.HasPrefix(path, "/web/database/") ||
|
||||
path == "/web/database/manager" ||
|
||||
path == "/web/database/create" ||
|
||||
path == "/web/database/list" ||
|
||||
path == "/web/webclient/version_info" ||
|
||||
path == "/web/setup/wizard" ||
|
||||
path == "/web/setup/wizard/save" ||
|
||||
path == "/web/portal/signup" ||
|
||||
path == "/web/portal/reset_password" ||
|
||||
strings.Contains(path, "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -58,8 +65,14 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
||||
// Check session cookie
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err != nil || cookie.Value == "" {
|
||||
// Also check JSON-RPC params for session_id (Odoo sends it both ways)
|
||||
next.ServeHTTP(w, r) // For now, allow through — UID defaults to 1
|
||||
// No session cookie — reject protected endpoints
|
||||
if r.Header.Get("Content-Type") == "application/json" ||
|
||||
strings.HasPrefix(path, "/web/dataset/") ||
|
||||
strings.HasPrefix(path, "/jsonrpc") {
|
||||
http.Error(w, `{"jsonrpc":"2.0","error":{"code":100,"message":"Session expired"}}`, http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
379
pkg/server/portal.go
Normal file
379
pkg/server/portal.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Package server — Portal controllers for external (customer/supplier) access.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// registerPortalRoutes registers all /my/* portal endpoints.
|
||||
func (s *Server) registerPortalRoutes() {
|
||||
s.mux.HandleFunc("/my", s.handlePortalHome)
|
||||
s.mux.HandleFunc("/my/", s.handlePortalDispatch)
|
||||
s.mux.HandleFunc("/my/home", s.handlePortalHome)
|
||||
s.mux.HandleFunc("/my/invoices", s.handlePortalInvoices)
|
||||
s.mux.HandleFunc("/my/orders", s.handlePortalOrders)
|
||||
s.mux.HandleFunc("/my/pickings", s.handlePortalPickings)
|
||||
s.mux.HandleFunc("/my/account", s.handlePortalAccount)
|
||||
}
|
||||
|
||||
// handlePortalDispatch routes /my/* sub-paths to the correct handler.
|
||||
func (s *Server) handlePortalDispatch(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/my/home":
|
||||
s.handlePortalHome(w, r)
|
||||
case "/my/invoices":
|
||||
s.handlePortalInvoices(w, r)
|
||||
case "/my/orders":
|
||||
s.handlePortalOrders(w, r)
|
||||
case "/my/pickings":
|
||||
s.handlePortalPickings(w, r)
|
||||
case "/my/account":
|
||||
s.handlePortalAccount(w, r)
|
||||
default:
|
||||
s.handlePortalHome(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// portalPartnerID resolves the partner_id of the currently logged-in portal user.
|
||||
// Returns (partnerID, error). If session is missing, writes an error response and returns 0.
|
||||
func (s *Server) portalPartnerID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
writePortalError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return 0, false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var partnerID int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT partner_id FROM res_users WHERE id = $1 AND active = true`,
|
||||
sess.UID).Scan(&partnerID)
|
||||
if err != nil {
|
||||
log.Printf("portal: cannot resolve partner_id for uid=%d: %v", sess.UID, err)
|
||||
writePortalError(w, http.StatusForbidden, "User not found")
|
||||
return 0, false
|
||||
}
|
||||
return partnerID, true
|
||||
}
|
||||
|
||||
// handlePortalHome returns the portal dashboard with document counts.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.home()
|
||||
func (s *Server) handlePortalHome(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID, ok := s.portalPartnerID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var invoiceCount, orderCount, pickingCount int64
|
||||
|
||||
// Count invoices (account.move with move_type in ('out_invoice','out_refund'))
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM account_move
|
||||
WHERE partner_id = $1 AND move_type IN ('out_invoice','out_refund')
|
||||
AND state = 'posted'`, partnerID).Scan(&invoiceCount)
|
||||
if err != nil {
|
||||
log.Printf("portal: invoice count error: %v", err)
|
||||
}
|
||||
|
||||
// Count sale orders (confirmed or done)
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM sale_order
|
||||
WHERE partner_id = $1 AND state IN ('sale','done')`, partnerID).Scan(&orderCount)
|
||||
if err != nil {
|
||||
log.Printf("portal: order count error: %v", err)
|
||||
}
|
||||
|
||||
// Count pickings (stock.picking)
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM stock_picking
|
||||
WHERE partner_id = $1 AND state != 'cancel'`, partnerID).Scan(&pickingCount)
|
||||
if err != nil {
|
||||
log.Printf("portal: picking count error: %v", err)
|
||||
}
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"counters": map[string]int64{
|
||||
"invoice_count": invoiceCount,
|
||||
"order_count": orderCount,
|
||||
"picking_count": pickingCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handlePortalInvoices lists invoices for the current portal user.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_invoices()
|
||||
func (s *Server) handlePortalInvoices(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID, ok := s.portalPartnerID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT m.id, m.name, m.move_type, m.state, m.date,
|
||||
m.amount_total::float8, m.amount_residual::float8,
|
||||
m.payment_state, COALESCE(m.ref, '')
|
||||
FROM account_move m
|
||||
WHERE m.partner_id = $1
|
||||
AND m.move_type IN ('out_invoice','out_refund')
|
||||
AND m.state = 'posted'
|
||||
ORDER BY m.date DESC
|
||||
LIMIT 80`, partnerID)
|
||||
if err != nil {
|
||||
log.Printf("portal: invoice query error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to load invoices")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invoices []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, moveType, state, paymentState, ref string
|
||||
var date time.Time
|
||||
var amountTotal, amountResidual float64
|
||||
if err := rows.Scan(&id, &name, &moveType, &state, &date,
|
||||
&amountTotal, &amountResidual, &paymentState, &ref); err != nil {
|
||||
log.Printf("portal: invoice scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
invoices = append(invoices, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"move_type": moveType,
|
||||
"state": state,
|
||||
"date": date.Format("2006-01-02"),
|
||||
"amount_total": amountTotal,
|
||||
"amount_residual": amountResidual,
|
||||
"payment_state": paymentState,
|
||||
"ref": ref,
|
||||
})
|
||||
}
|
||||
if invoices == nil {
|
||||
invoices = []map[string]interface{}{}
|
||||
}
|
||||
writePortalJSON(w, map[string]interface{}{"invoices": invoices})
|
||||
}
|
||||
|
||||
// handlePortalOrders lists sale orders for the current portal user.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_orders()
|
||||
func (s *Server) handlePortalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID, ok := s.portalPartnerID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT so.id, so.name, so.state, so.date_order,
|
||||
so.amount_total::float8, COALESCE(so.invoice_status, ''),
|
||||
COALESCE(so.delivery_status, '')
|
||||
FROM sale_order so
|
||||
WHERE so.partner_id = $1
|
||||
AND so.state IN ('sale','done')
|
||||
ORDER BY so.date_order DESC
|
||||
LIMIT 80`, partnerID)
|
||||
if err != nil {
|
||||
log.Printf("portal: order query error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to load orders")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orders []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, state, invoiceStatus, deliveryStatus string
|
||||
var dateOrder time.Time
|
||||
var amountTotal float64
|
||||
if err := rows.Scan(&id, &name, &state, &dateOrder,
|
||||
&amountTotal, &invoiceStatus, &deliveryStatus); err != nil {
|
||||
log.Printf("portal: order scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
orders = append(orders, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"state": state,
|
||||
"date_order": dateOrder.Format("2006-01-02 15:04:05"),
|
||||
"amount_total": amountTotal,
|
||||
"invoice_status": invoiceStatus,
|
||||
"delivery_status": deliveryStatus,
|
||||
})
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []map[string]interface{}{}
|
||||
}
|
||||
writePortalJSON(w, map[string]interface{}{"orders": orders})
|
||||
}
|
||||
|
||||
// handlePortalPickings lists stock pickings for the current portal user.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_pickings()
|
||||
func (s *Server) handlePortalPickings(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID, ok := s.portalPartnerID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT sp.id, sp.name, sp.state, sp.scheduled_date,
|
||||
COALESCE(sp.origin, ''),
|
||||
COALESCE(spt.name, '') AS picking_type_name
|
||||
FROM stock_picking sp
|
||||
LEFT JOIN stock_picking_type spt ON spt.id = sp.picking_type_id
|
||||
WHERE sp.partner_id = $1
|
||||
AND sp.state != 'cancel'
|
||||
ORDER BY sp.scheduled_date DESC
|
||||
LIMIT 80`, partnerID)
|
||||
if err != nil {
|
||||
log.Printf("portal: picking query error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to load pickings")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pickings []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, state, origin, pickingTypeName string
|
||||
var scheduledDate time.Time
|
||||
if err := rows.Scan(&id, &name, &state, &scheduledDate,
|
||||
&origin, &pickingTypeName); err != nil {
|
||||
log.Printf("portal: picking scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
pickings = append(pickings, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"state": state,
|
||||
"scheduled_date": scheduledDate.Format("2006-01-02 15:04:05"),
|
||||
"origin": origin,
|
||||
"picking_type_name": pickingTypeName,
|
||||
})
|
||||
}
|
||||
if pickings == nil {
|
||||
pickings = []map[string]interface{}{}
|
||||
}
|
||||
writePortalJSON(w, map[string]interface{}{"pickings": pickings})
|
||||
}
|
||||
|
||||
// handlePortalAccount returns/updates the portal user's profile.
|
||||
// GET: returns user profile. POST: updates name/email/phone/street/city/zip.
|
||||
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.account()
|
||||
func (s *Server) handlePortalAccount(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID, ok := s.portalPartnerID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Update profile
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Street *string `json:"street"`
|
||||
City *string `json:"city"`
|
||||
Zip *string `json:"zip"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// Build SET clause dynamically with parameterized placeholders
|
||||
sets := make([]string, 0, 6)
|
||||
args := make([]interface{}, 0, 7)
|
||||
idx := 1
|
||||
addField := func(col string, val *string) {
|
||||
if val != nil {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, *val)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
addField("name", body.Name)
|
||||
addField("email", body.Email)
|
||||
addField("phone", body.Phone)
|
||||
addField("street", body.Street)
|
||||
addField("city", body.City)
|
||||
addField("zip", body.Zip)
|
||||
|
||||
if len(sets) > 0 {
|
||||
args = append(args, partnerID)
|
||||
query := "UPDATE res_partner SET "
|
||||
for j, set := range sets {
|
||||
if j > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += set
|
||||
}
|
||||
query += fmt.Sprintf(" WHERE id = $%d", idx)
|
||||
if _, err := s.pool.Exec(ctx, query, args...); err != nil {
|
||||
log.Printf("portal: account update error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Update failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{"success": true})
|
||||
return
|
||||
}
|
||||
|
||||
// GET — return profile
|
||||
var name, email, phone, street, city, zip string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(phone,''),
|
||||
COALESCE(street,''), COALESCE(city,''), COALESCE(zip,'')
|
||||
FROM res_partner WHERE id = $1`, partnerID).Scan(
|
||||
&name, &email, &phone, &street, &city, &zip)
|
||||
if err != nil {
|
||||
log.Printf("portal: account read error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to load profile")
|
||||
return
|
||||
}
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"street": street,
|
||||
"city": city,
|
||||
"zip": zip,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func writePortalJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writePortalError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
313
pkg/server/portal_signup.go
Normal file
313
pkg/server/portal_signup.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Package server — Portal signup and password reset.
|
||||
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
// registerPortalSignupRoutes registers /web/portal/* public endpoints.
|
||||
func (s *Server) registerPortalSignupRoutes() {
|
||||
s.mux.HandleFunc("/web/portal/signup", s.handlePortalSignup)
|
||||
s.mux.HandleFunc("/web/portal/reset_password", s.handlePortalResetPassword)
|
||||
}
|
||||
|
||||
// handlePortalSignup creates a new portal user with share=true and a matching res.partner.
|
||||
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_signup()
|
||||
func (s *Server) handlePortalSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
if body.Name == "" || body.Email == "" || body.Password == "" {
|
||||
writePortalError(w, http.StatusBadRequest, "Name, email, and password are required")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check if login already exists
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM res_users WHERE login = $1)`, body.Email).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: check existing user error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
writePortalError(w, http.StatusConflict, "An account with this email already exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPw, err := tools.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: hash password error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Get default company
|
||||
var companyID int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT id FROM res_company WHERE active = true ORDER BY id LIMIT 1`).Scan(&companyID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: get company error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Begin transaction — create partner + user atomically
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: begin tx error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Create res.partner
|
||||
var partnerID int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO res_partner (name, email, active, company_id, customer_rank)
|
||||
VALUES ($1, $2, true, $3, 1)
|
||||
RETURNING id`, body.Name, body.Email, companyID).Scan(&partnerID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: create partner error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||
return
|
||||
}
|
||||
|
||||
// Create res.users with share=true
|
||||
var userID int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO res_users (login, password, active, partner_id, company_id, share)
|
||||
VALUES ($1, $2, true, $3, $4, true)
|
||||
RETURNING id`, body.Email, hashedPw, partnerID, companyID).Scan(&userID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: create user error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||
return
|
||||
}
|
||||
|
||||
// Add user to group_portal (not group_user)
|
||||
var groupPortalID int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`SELECT g.id FROM res_groups g
|
||||
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||
WHERE imd.module = 'base' AND imd.name = 'group_portal'`).Scan(&groupPortalID)
|
||||
if err != nil {
|
||||
// group_portal might not exist yet — create it
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO res_groups (name) VALUES ('Portal') RETURNING id`).Scan(&groupPortalID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: create group_portal error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||
return
|
||||
}
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO ir_model_data (module, name, model, res_id)
|
||||
VALUES ('base', 'group_portal', 'res.groups', $1)
|
||||
ON CONFLICT DO NOTHING`, groupPortalID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: create group_portal xmlid error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`, groupPortalID, userID)
|
||||
if err != nil {
|
||||
log.Printf("portal signup: add user to group_portal error: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("portal signup: commit error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("portal signup: created portal user id=%d login=%s partner_id=%d",
|
||||
userID, body.Email, partnerID)
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"user_id": userID,
|
||||
"partner_id": partnerID,
|
||||
"message": "Account created successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// handlePortalResetPassword handles password reset requests.
|
||||
// POST with {"email":"..."}: generates a reset token and sends an email.
|
||||
// POST with {"token":"...","password":"..."}: resets the password.
|
||||
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_reset_password()
|
||||
func (s *Server) handlePortalResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Phase 2: Token + new password → reset
|
||||
if body.Token != "" && body.Password != "" {
|
||||
s.handleResetWithToken(w, ctx, body.Token, body.Password)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Email → generate token + send email
|
||||
if body.Email == "" {
|
||||
writePortalError(w, http.StatusBadRequest, "Email is required")
|
||||
return
|
||||
}
|
||||
|
||||
s.handleResetRequest(w, ctx, strings.TrimSpace(body.Email))
|
||||
}
|
||||
|
||||
// handleResetRequest generates a reset token and sends it via email.
|
||||
func (s *Server) handleResetRequest(w http.ResponseWriter, ctx context.Context, email string) {
|
||||
// Look up user
|
||||
var uid int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id FROM res_users WHERE login = $1 AND active = true`, email).Scan(&uid)
|
||||
if err != nil {
|
||||
// Don't reveal whether the email exists — always return success
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "If an account exists with this email, a reset link has been sent",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenBytes := make([]byte, 32)
|
||||
rand.Read(tokenBytes)
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
expiration := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Store token
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`UPDATE res_users SET signup_token = $1, signup_expiration = $2 WHERE id = $3`,
|
||||
token, expiration, uid)
|
||||
if err != nil {
|
||||
log.Printf("portal reset: store token error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Send email with reset link
|
||||
smtpCfg := tools.LoadSMTPConfig()
|
||||
resetURL := fmt.Sprintf("/web/portal/reset_password?token=%s", token)
|
||||
emailBody := fmt.Sprintf(`<html><body>
|
||||
<p>A password reset was requested for your account.</p>
|
||||
<p>Click the link below to set a new password:</p>
|
||||
<p><a href="%s">Reset Password</a></p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
</body></html>`, resetURL)
|
||||
|
||||
if err := tools.SendEmail(smtpCfg, email, "Password Reset", emailBody); err != nil {
|
||||
log.Printf("portal reset: send email error: %v", err)
|
||||
// Don't expose email sending errors to the user
|
||||
}
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "If an account exists with this email, a reset link has been sent",
|
||||
})
|
||||
}
|
||||
|
||||
// handleResetWithToken validates the token and sets the new password.
|
||||
func (s *Server) handleResetWithToken(w http.ResponseWriter, ctx context.Context, token, password string) {
|
||||
if len(password) < 8 {
|
||||
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up user by token
|
||||
var uid int64
|
||||
var expiration time.Time
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, signup_expiration FROM res_users
|
||||
WHERE signup_token = $1 AND active = true`, token).Scan(&uid, &expiration)
|
||||
if err != nil {
|
||||
writePortalError(w, http.StatusBadRequest, "Invalid or expired reset token")
|
||||
return
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(expiration) {
|
||||
// Clear expired token
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE res_users SET signup_token = NULL, signup_expiration = NULL WHERE id = $1`, uid)
|
||||
writePortalError(w, http.StatusBadRequest, "Reset token has expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPw, err := tools.HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("portal reset: hash password error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Update password and clear token
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`UPDATE res_users SET password = $1, signup_token = NULL, signup_expiration = NULL
|
||||
WHERE id = $2`, hashedPw, uid)
|
||||
if err != nil {
|
||||
log.Printf("portal reset: update password error: %v", err)
|
||||
writePortalError(w, http.StatusInternalServerError, "Failed to reset password")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("portal reset: password reset for uid=%d", uid)
|
||||
|
||||
writePortalJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Password has been reset successfully",
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// Report endpoint — serves HTML reports.
|
||||
// Report endpoint — serves HTML and PDF reports.
|
||||
// Mirrors: odoo/addons/web/controllers/report.py ReportController
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -83,12 +87,270 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Render HTML report
|
||||
html := renderHTMLReport(reportName, modelName, records)
|
||||
// Use dedicated renderer for invoices, generic for others
|
||||
var html string
|
||||
if reportName == "account.report_invoice" && len(ids) > 0 {
|
||||
html = renderInvoiceHTML(env, ids[0])
|
||||
} else {
|
||||
html = renderHTMLReport(reportName, modelName, records)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// handleReportPDF serves PDF reports.
|
||||
// Route: /report/pdf/<report_name>/<ids>
|
||||
// Mirrors: odoo/addons/web/controllers/report.py report_download()
|
||||
//
|
||||
// It generates the HTML report and converts it to PDF via wkhtmltopdf or
|
||||
// headless Chromium. If neither is available, it falls back to the HTML
|
||||
// report with an auto-print dialog.
|
||||
func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse URL: /report/pdf/account.report_invoice/1,2,3
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
// Expected: ["report", "pdf", "<report_name>", "<ids>"]
|
||||
if len(parts) < 4 {
|
||||
http.Error(w, "Invalid report URL. Expected: /report/pdf/<report_name>/<ids>", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reportName := parts[2]
|
||||
idsStr := parts[3]
|
||||
|
||||
// Parse record IDs
|
||||
var ids []int64
|
||||
for _, s := range strings.Split(idsStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
http.Error(w, "No record IDs provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get session
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool, UID: uid, CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
// Determine model from report name
|
||||
modelName := resolveReportModel(reportName)
|
||||
if modelName == "" {
|
||||
http.Error(w, "Unknown report: "+reportName, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Read records
|
||||
rs := env.Model(modelName).Browse(ids...)
|
||||
records, err := rs.Read(nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := env.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Use dedicated renderer for invoices, generic for others
|
||||
var htmlContent string
|
||||
if reportName == "account.report_invoice" && len(ids) > 0 {
|
||||
htmlContent = renderInvoiceHTML(env, ids[0])
|
||||
} else {
|
||||
htmlContent = renderHTMLReport(reportName, modelName, records)
|
||||
}
|
||||
|
||||
// Try to convert to PDF
|
||||
pdfData, err := htmlToPDF(htmlContent)
|
||||
if err != nil {
|
||||
// Fallback: serve HTML with auto-print dialog
|
||||
log.Printf("report: PDF conversion unavailable (%v), falling back to HTML", err)
|
||||
htmlFallback := renderHTMLReportPrintFallback(reportName, modelName, records)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(htmlFallback))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s.pdf", reportName))
|
||||
w.Write(pdfData)
|
||||
}
|
||||
|
||||
// htmlToPDF converts an HTML string to PDF. It tries wkhtmltopdf first,
|
||||
// then headless Chromium. Returns an error if neither is available.
|
||||
func htmlToPDF(html string) ([]byte, error) {
|
||||
// Try wkhtmltopdf first (most common in Odoo deployments)
|
||||
if path, err := exec.LookPath("wkhtmltopdf"); err == nil {
|
||||
cmd := exec.Command(path, "--quiet", "--print-media-type",
|
||||
"--page-size", "A4",
|
||||
"--margin-top", "10mm", "--margin-bottom", "10mm",
|
||||
"--margin-left", "10mm", "--margin-right", "10mm",
|
||||
"-", "-") // stdin → stdout
|
||||
cmd.Stdin = strings.NewReader(html)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("report: wkhtmltopdf failed: %v – %s", err, stderr.String())
|
||||
} else {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try headless Chromium / Chrome
|
||||
return htmlToPDFChromium(html)
|
||||
}
|
||||
|
||||
// htmlToPDFChromium converts HTML to PDF using headless Chrome/Chromium.
|
||||
func htmlToPDFChromium(html string) ([]byte, error) {
|
||||
// Find browser binary
|
||||
var browserPath string
|
||||
for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "chrome"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
browserPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if browserPath == "" {
|
||||
return nil, fmt.Errorf("no PDF converter available (tried wkhtmltopdf, chromium, chrome)")
|
||||
}
|
||||
|
||||
// Write HTML to temp file
|
||||
tmpFile, err := os.CreateTemp("", "report-*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(html); err != nil {
|
||||
tmpFile.Close()
|
||||
return nil, fmt.Errorf("writing temp file: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
pdfFile := tmpFile.Name() + ".pdf"
|
||||
defer os.Remove(pdfFile)
|
||||
|
||||
cmd := exec.Command(browserPath, "--headless", "--disable-gpu", "--no-sandbox",
|
||||
"--print-to-pdf="+pdfFile, "file://"+tmpFile.Name())
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("chromium PDF generation failed: %v – %s", err, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(pdfFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading PDF output: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// renderHTMLReportPrintFallback generates an HTML report with enhanced print
|
||||
// CSS and an auto-trigger for the browser print dialog. Used as fallback when
|
||||
// no PDF converter (wkhtmltopdf, chromium) is installed.
|
||||
func renderHTMLReportPrintFallback(reportName, modelName string, records []orm.Values) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Report: `)
|
||||
b.WriteString(htmlEscape(reportName))
|
||||
b.WriteString(`</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f5f5f5; font-weight: bold; }
|
||||
h1 { color: #333; }
|
||||
h2 { color: #555; margin-top: 30px; }
|
||||
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
|
||||
.record-section { page-break-after: always; margin-bottom: 40px; }
|
||||
.record-section:last-child { page-break-after: avoid; }
|
||||
.no-print { display: none; }
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none; }
|
||||
table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>`)
|
||||
b.WriteString(htmlEscape(reportName))
|
||||
b.WriteString(`</h1>
|
||||
<div class="report-info">Model: `)
|
||||
b.WriteString(htmlEscape(modelName))
|
||||
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
|
||||
b.WriteString(`</div>
|
||||
</div>
|
||||
`)
|
||||
|
||||
for _, rec := range records {
|
||||
b.WriteString(`<div class="record-section">`)
|
||||
title := ""
|
||||
if v, ok := rec["display_name"]; ok {
|
||||
title = fmt.Sprintf("%v", v)
|
||||
} else if v, ok := rec["name"]; ok {
|
||||
title = fmt.Sprintf("%v", v)
|
||||
}
|
||||
if title != "" {
|
||||
b.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlEscape(title)))
|
||||
}
|
||||
b.WriteString("<table>")
|
||||
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
|
||||
for key, val := range rec {
|
||||
if key == "id" {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if valStr == "<nil>" {
|
||||
valStr = ""
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
|
||||
htmlEscape(key), htmlEscape(valStr)))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
|
||||
b.WriteString(`<script>window.addEventListener('load', function() { window.print(); });</script>
|
||||
</body></html>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolveReportModel maps a report name to the ORM model it operates on.
|
||||
// Mirrors: odoo ir.actions.report → model field.
|
||||
func resolveReportModel(reportName string) string {
|
||||
@@ -125,8 +387,14 @@ h2 { color: #555; margin-top: 30px; }
|
||||
.record-section { page-break-after: always; margin-bottom: 40px; }
|
||||
.record-section:last-child { page-break-after: avoid; }
|
||||
@media print {
|
||||
body { margin: 20px; }
|
||||
body { margin: 0; }
|
||||
.no-print { display: none; }
|
||||
table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -184,3 +452,195 @@ h2 { color: #555; margin-top: 30px; }
|
||||
func htmlEscape(s string) string {
|
||||
return template.HTMLEscapeString(s)
|
||||
}
|
||||
|
||||
// renderInvoiceHTML generates a professional invoice HTML document for PDF conversion.
|
||||
// Reads directly from the database to build a complete, styled invoice.
|
||||
// Mirrors: odoo/addons/account/report/account_invoice_report_templates.xml
|
||||
func renderInvoiceHTML(env *orm.Environment, moveID int64) string {
|
||||
// Read invoice header
|
||||
var name, partnerName, moveType, state, date, dueDate string
|
||||
var total, untaxed, tax float64
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(m.name,'/'), COALESCE(p.name,''), COALESCE(m.move_type,'entry'),
|
||||
COALESCE(m.state,'draft'),
|
||||
COALESCE(m.date::text,''), COALESCE(m.invoice_date_due::text,''),
|
||||
COALESCE(m.amount_total::float8, 0), COALESCE(m.amount_untaxed::float8, 0),
|
||||
COALESCE(m.amount_tax::float8, 0)
|
||||
FROM account_move m
|
||||
LEFT JOIN res_partner p ON p.id = m.partner_id
|
||||
WHERE m.id = $1`, moveID,
|
||||
).Scan(&name, &partnerName, &moveType, &state, &date, &dueDate, &total, &untaxed, &tax)
|
||||
|
||||
// Read partner address
|
||||
var partnerStreet, partnerCity, partnerZip, partnerCountry string
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''),
|
||||
COALESCE(co.name,'')
|
||||
FROM account_move m
|
||||
LEFT JOIN res_partner p ON p.id = m.partner_id
|
||||
LEFT JOIN res_country co ON co.id = p.country_id
|
||||
WHERE m.id = $1`, moveID,
|
||||
).Scan(&partnerStreet, &partnerCity, &partnerZip, &partnerCountry)
|
||||
|
||||
// Read company info
|
||||
var companyName, companyEmail, companyPhone, companyStreet, companyCity, companyZip, companyCountry string
|
||||
env.Tx().QueryRow(env.Ctx(), `
|
||||
SELECT COALESCE(c.name,''), COALESCE(p.email,''), COALESCE(p.phone,''),
|
||||
COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''),
|
||||
COALESCE(co.name,'')
|
||||
FROM res_company c
|
||||
JOIN res_partner p ON p.id = c.partner_id
|
||||
LEFT JOIN res_country co ON co.id = p.country_id
|
||||
WHERE c.id = 1`).Scan(&companyName, &companyEmail, &companyPhone,
|
||||
&companyStreet, &companyCity, &companyZip, &companyCountry)
|
||||
|
||||
// Read invoice lines
|
||||
type invLine struct {
|
||||
name string
|
||||
qty float64
|
||||
price float64
|
||||
amount float64
|
||||
dtype string
|
||||
}
|
||||
rows, err := env.Tx().Query(env.Ctx(), `
|
||||
SELECT COALESCE(name,''), COALESCE(quantity,0), COALESCE(price_unit::float8,0),
|
||||
COALESCE(ABS(balance::float8),0), COALESCE(display_type,'product')
|
||||
FROM account_move_line WHERE move_id = $1 AND display_type IN ('product','tax')
|
||||
ORDER BY display_type, id`, moveID)
|
||||
var lines []invLine
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var l invLine
|
||||
if err := rows.Scan(&l.name, &l.qty, &l.price, &l.amount, &l.dtype); err == nil {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build professional HTML
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"/>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11pt; color: #333; padding: 40px; }
|
||||
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
||||
.company { font-size: 10pt; color: #666; }
|
||||
.company h1 { font-size: 16pt; color: #875A7B; margin-bottom: 5px; }
|
||||
.invoice-title { font-size: 22pt; color: #875A7B; margin-bottom: 5px; }
|
||||
.invoice-number { font-size: 14pt; font-weight: bold; }
|
||||
.info-grid { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||
.info-box { width: 48%; }
|
||||
.info-box h3 { color: #875A7B; font-size: 10pt; text-transform: uppercase; margin-bottom: 5px; border-bottom: 1px solid #875A7B; padding-bottom: 3px; }
|
||||
.info-box p { font-size: 10pt; line-height: 1.6; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
thead th { background: #875A7B; color: white; padding: 8px 12px; text-align: left; font-size: 9pt; text-transform: uppercase; }
|
||||
thead th.num { text-align: right; }
|
||||
tbody td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 10pt; }
|
||||
tbody td.num { text-align: right; }
|
||||
.totals { float: right; width: 280px; }
|
||||
.totals table { margin-bottom: 0; }
|
||||
.totals td { padding: 4px 8px; }
|
||||
.totals .total-row { font-weight: bold; font-size: 13pt; border-top: 2px solid #875A7B; }
|
||||
.footer { clear: both; margin-top: 60px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
|
||||
.state-badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 9pt; text-transform: uppercase; font-weight: bold; }
|
||||
.state-draft { background: #ffc107; color: #333; }
|
||||
.state-posted { background: #28a745; color: white; }
|
||||
.state-cancel { background: #dc3545; color: white; }
|
||||
@media print { body { padding: 20px; } }
|
||||
@page { size: A4; margin: 15mm; }
|
||||
</style></head><body>`)
|
||||
|
||||
// Header
|
||||
stateClass := "state-" + state
|
||||
b.WriteString(fmt.Sprintf(`<div class="header">
|
||||
<div class="company"><h1>%s</h1><p>%s<br/>%s %s<br/>%s<br/>%s</p></div>
|
||||
<div style="text-align:right">
|
||||
<div class="invoice-title">%s</div>
|
||||
<div class="invoice-number">%s</div>
|
||||
<span class="state-badge %s">%s</span>
|
||||
</div>
|
||||
</div>`, htmlEscape(companyName), htmlEscape(companyStreet),
|
||||
htmlEscape(companyZip), htmlEscape(companyCity),
|
||||
htmlEscape(companyPhone), htmlEscape(companyEmail),
|
||||
htmlEscape(getInvoiceTypeLabel(moveType)), htmlEscape(name),
|
||||
stateClass, htmlEscape(state)))
|
||||
|
||||
// Info boxes
|
||||
partnerAddr := htmlEscape(partnerName)
|
||||
if partnerStreet != "" {
|
||||
partnerAddr += "<br/>" + htmlEscape(partnerStreet)
|
||||
}
|
||||
if partnerZip != "" || partnerCity != "" {
|
||||
partnerAddr += "<br/>" + htmlEscape(partnerZip) + " " + htmlEscape(partnerCity)
|
||||
}
|
||||
if partnerCountry != "" {
|
||||
partnerAddr += "<br/>" + htmlEscape(partnerCountry)
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(`<div class="info-grid">
|
||||
<div class="info-box"><h3>Bill To</h3><p>%s</p></div>
|
||||
<div class="info-box"><h3>Details</h3><p>Date: %s<br/>Due Date: %s</p></div>
|
||||
</div>`, partnerAddr, htmlEscape(date), htmlEscape(dueDate)))
|
||||
|
||||
// Lines table
|
||||
b.WriteString(`<table><thead><tr><th>Description</th><th class="num">Quantity</th><th class="num">Unit Price</th><th class="num">Amount</th></tr></thead><tbody>`)
|
||||
for _, l := range lines {
|
||||
if l.dtype == "product" {
|
||||
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td class="num">%.2f</td><td class="num">%.2f</td><td class="num">%.2f</td></tr>`,
|
||||
htmlEscape(l.name), l.qty, l.price, l.amount))
|
||||
}
|
||||
}
|
||||
// Tax lines
|
||||
for _, l := range lines {
|
||||
if l.dtype == "tax" {
|
||||
b.WriteString(fmt.Sprintf(`<tr><td><em>%s</em></td><td></td><td></td><td class="num">%.2f</td></tr>`,
|
||||
htmlEscape(l.name), l.amount))
|
||||
}
|
||||
}
|
||||
b.WriteString(`</tbody></table>`)
|
||||
|
||||
// Totals
|
||||
b.WriteString(fmt.Sprintf(`<div class="totals"><table>
|
||||
<tr><td>Untaxed Amount:</td><td class="num">%.2f</td></tr>
|
||||
<tr><td>Taxes:</td><td class="num">%.2f</td></tr>
|
||||
<tr class="total-row"><td>Total:</td><td class="num">%.2f</td></tr>
|
||||
</table></div>`, untaxed, tax, total))
|
||||
|
||||
// Footer
|
||||
b.WriteString(fmt.Sprintf(`<div class="footer">%s | %s | %s</div>`,
|
||||
htmlEscape(companyName), htmlEscape(companyEmail), htmlEscape(companyPhone)))
|
||||
b.WriteString(`</body></html>`)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HtmlToPDF is the exported version of htmlToPDF for use by other packages.
|
||||
func HtmlToPDF(html string) ([]byte, error) {
|
||||
return htmlToPDF(html)
|
||||
}
|
||||
|
||||
// RenderInvoiceHTML is the exported version of renderInvoiceHTML for use by other packages.
|
||||
func RenderInvoiceHTML(env *orm.Environment, moveID int64) string {
|
||||
return renderInvoiceHTML(env, moveID)
|
||||
}
|
||||
|
||||
// getInvoiceTypeLabel returns a human-readable label for the move type.
|
||||
func getInvoiceTypeLabel(t string) string {
|
||||
switch t {
|
||||
case "out_invoice":
|
||||
return "Invoice"
|
||||
case "out_refund":
|
||||
return "Credit Note"
|
||||
case "in_invoice":
|
||||
return "Vendor Bill"
|
||||
case "in_refund":
|
||||
return "Vendor Credit Note"
|
||||
case "out_receipt":
|
||||
return "Sales Receipt"
|
||||
case "in_receipt":
|
||||
return "Purchase Receipt"
|
||||
default:
|
||||
return "Journal Entry"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -35,6 +36,8 @@ type Server struct {
|
||||
// all JS files (except module_loader.js) plus the XML template bundle,
|
||||
// served as a single file to avoid hundreds of individual HTTP requests.
|
||||
jsBundle string
|
||||
|
||||
bus *Bus // Message bus for Discuss long-polling
|
||||
}
|
||||
|
||||
// New creates a new server instance.
|
||||
@@ -128,18 +131,36 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
// CSV export
|
||||
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
||||
s.mux.HandleFunc("/web/export/xlsx", s.handleExportXLSX)
|
||||
|
||||
// Reports (HTML report rendering)
|
||||
// Import
|
||||
s.mux.HandleFunc("/web/import/csv", s.handleImportCSV)
|
||||
|
||||
// Post-setup wizard
|
||||
s.mux.HandleFunc("/web/setup/wizard", s.handleSetupWizard)
|
||||
s.mux.HandleFunc("/web/setup/wizard/save", s.handleSetupWizardSave)
|
||||
|
||||
// Bank statement import
|
||||
s.mux.HandleFunc("/web/bank_statement/import", s.handleBankStatementImport)
|
||||
|
||||
// Reports (HTML and PDF report rendering)
|
||||
s.mux.HandleFunc("/report/", s.handleReport)
|
||||
s.mux.HandleFunc("/report/html/", s.handleReport)
|
||||
s.mux.HandleFunc("/report/pdf/", s.handleReportPDF)
|
||||
|
||||
// Logout & Account
|
||||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||||
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
|
||||
s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
|
||||
|
||||
// Health check
|
||||
s.mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
// Portal routes (external user access)
|
||||
s.registerPortalRoutes()
|
||||
s.registerPortalSignupRoutes()
|
||||
s.registerBusRoutes()
|
||||
|
||||
// Static files (catch-all for /<addon>/static/...)
|
||||
// NOTE: must be last since it's a broad pattern
|
||||
}
|
||||
@@ -254,13 +275,14 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract UID from session, default to 1 (admin) if no session
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
// Extract UID from session — reject if no session (defense in depth)
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
|
||||
return
|
||||
}
|
||||
uid := sess.UID
|
||||
companyID := sess.CompanyID
|
||||
|
||||
// Create environment for this request
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
@@ -293,6 +315,36 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, req.ID, result, nil)
|
||||
}
|
||||
|
||||
// sensitiveFields lists fields that only admin (uid=1) may write to.
|
||||
// Prevents privilege escalation via field manipulation.
|
||||
var sensitiveFields = map[string]map[string]bool{
|
||||
"ir.cron": {"user_id": true, "model_name": true, "method_name": true},
|
||||
"ir.model.access": {"group_id": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||
"ir.rule": {"domain_force": true, "groups": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||
"res.users": {"groups_id": true},
|
||||
"res.groups": {"users": true},
|
||||
}
|
||||
|
||||
// checkSensitiveFields blocks non-admin users from writing protected fields.
|
||||
func checkSensitiveFields(env *orm.Environment, model string, vals orm.Values) *RPCError {
|
||||
if env.UID() == 1 || env.IsSuperuser() {
|
||||
return nil
|
||||
}
|
||||
fields, ok := sensitiveFields[model]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for field := range vals {
|
||||
if fields[field] {
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: field %q on %s is admin-only", field, model),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAccess verifies the current user has permission for the operation.
|
||||
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
|
||||
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
|
||||
@@ -316,8 +368,22 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
||||
`SELECT COUNT(*) FROM ir_model_access a
|
||||
JOIN ir_model m ON m.id = a.model_id
|
||||
WHERE m.model = $1`, model).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return nil // No ACLs defined → open access (like Odoo superuser mode)
|
||||
if err != nil {
|
||||
// DB error → deny access (fail-closed)
|
||||
log.Printf("access: DB error checking ACL for model %s: %v", model, err)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
// No ACL rules defined for this model → deny (fail-closed).
|
||||
// All models should have ACL seed data via seedACLRules().
|
||||
log.Printf("access: no ACL for model %s, denying (fail-closed)", model)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: no ACL rules for %s", model),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user's groups grant permission
|
||||
@@ -333,7 +399,11 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
||||
AND (a.group_id IS NULL OR gu.res_users_id = $2)
|
||||
)`, perm), model, env.UID()).Scan(&granted)
|
||||
if err != nil {
|
||||
return nil // On error, allow (fail-open for now)
|
||||
log.Printf("access: DB error checking ACL grant for model %s: %v", model, err)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||
}
|
||||
}
|
||||
if !granted {
|
||||
return &RPCError{
|
||||
@@ -352,6 +422,19 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle ir.actions.report RPC calls (e.g., Print button on invoices).
|
||||
// Mirrors: odoo/addons/base/models/ir_actions_report.py IrActionsReport.report_action()
|
||||
if params.Model == "ir.actions.report" && params.Method == "report_action" {
|
||||
if len(params.Args) > 0 {
|
||||
reportName, _ := params.Args[0].(string)
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.report",
|
||||
"report_name": reportName,
|
||||
"report_type": "qweb-html",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If model is "ir.http", handle special routing methods
|
||||
if params.Model == "ir.http" {
|
||||
switch params.Method {
|
||||
@@ -365,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
switch params.Method {
|
||||
case "has_group":
|
||||
// Always return true for admin user, stub for now
|
||||
return true, nil
|
||||
// Check if current user belongs to the given group.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.user_has_groups()
|
||||
groupXMLID := ""
|
||||
if len(params.Args) > 0 {
|
||||
groupXMLID, _ = params.Args[0].(string)
|
||||
}
|
||||
if groupXMLID == "" {
|
||||
return false, nil
|
||||
}
|
||||
// Admin always has all groups
|
||||
if env.UID() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
// Parse "module.xml_id" format
|
||||
parts := strings.SplitN(groupXMLID, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false, nil
|
||||
}
|
||||
// Query: does user belong to this group?
|
||||
var exists bool
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM res_groups_res_users_rel gur
|
||||
JOIN ir_model_data imd ON imd.res_id = gur.res_groups_id AND imd.model = 'res.groups'
|
||||
WHERE gur.res_users_id = $1 AND imd.module = $2 AND imd.name = $3
|
||||
)`, env.UID(), parts[0], parts[1]).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return exists, nil
|
||||
|
||||
case "check_access_rights":
|
||||
// Check if current user has the given access right on this model.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.check_access_rights()
|
||||
operation := "read"
|
||||
if len(params.Args) > 0 {
|
||||
if op, ok := params.Args[0].(string); ok {
|
||||
operation = op
|
||||
}
|
||||
}
|
||||
raiseException := true
|
||||
if v, ok := params.KW["raise_exception"].(bool); ok {
|
||||
raiseException = v
|
||||
}
|
||||
accessErr := s.checkAccess(env, params.Model, operation)
|
||||
if accessErr != nil {
|
||||
if raiseException {
|
||||
return nil, accessErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "fields_get":
|
||||
@@ -390,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
vals := parseValuesAt(params.Args, 1)
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
|
||||
// Field-level access control
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ids) > 0 && ids[0] > 0 {
|
||||
// Update existing record(s)
|
||||
err := rs.Browse(ids...).Write(vals)
|
||||
@@ -499,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
case "create":
|
||||
vals := parseValues(params.Args)
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
@@ -508,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
case "write":
|
||||
ids := parseIDs(params.Args)
|
||||
vals := parseValuesAt(params.Args, 1)
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := rs.Browse(ids...).Write(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
@@ -631,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}, nil
|
||||
|
||||
case "get_formview_id":
|
||||
return false, nil
|
||||
// Return the default form view ID for this model.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.get_formview_id()
|
||||
var viewID *int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM ir_ui_view
|
||||
WHERE model = $1 AND type = 'form' AND active = true
|
||||
ORDER BY priority, id LIMIT 1`,
|
||||
params.Model).Scan(&viewID)
|
||||
if err != nil || viewID == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *viewID, nil
|
||||
|
||||
case "action_get":
|
||||
// Try registered method first (e.g. res.users has its own action_get).
|
||||
// Mirrors: odoo/addons/base/models/res_users.py action_get()
|
||||
model := orm.Registry.Get(params.Model)
|
||||
if model != nil && model.Methods != nil {
|
||||
if method, ok := model.Methods["action_get"]; ok {
|
||||
ids := parseIDs(params.Args)
|
||||
result, err := method(rs.Browse(ids...), params.Args[1:]...)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
|
||||
case "name_create":
|
||||
@@ -651,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
return []interface{}{created.ID(), nameStr}, nil
|
||||
|
||||
case "read_progress_bar":
|
||||
return map[string]interface{}{}, nil
|
||||
return s.handleReadProgressBar(rs, params)
|
||||
|
||||
case "activity_format":
|
||||
return []interface{}{}, nil
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Search activities for this model/record
|
||||
actRS := env.Model("mail.activity")
|
||||
var allActivities []orm.Values
|
||||
for _, id := range ids {
|
||||
domain := orm.And(
|
||||
orm.Leaf("res_model", "=", params.Model),
|
||||
orm.Leaf("res_id", "=", id),
|
||||
orm.Leaf("done", "=", false),
|
||||
)
|
||||
found, err := actRS.Search(domain, orm.SearchOpts{Order: "date_deadline"})
|
||||
if err != nil || found.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
records, err := found.Read([]string{"id", "res_model", "res_id", "activity_type_id", "summary", "note", "date_deadline", "user_id", "state"})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
allActivities = append(allActivities, records...)
|
||||
}
|
||||
if allActivities == nil {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Format M2O fields
|
||||
actSpec := map[string]interface{}{
|
||||
"activity_type_id": map[string]interface{}{},
|
||||
"user_id": map[string]interface{}{},
|
||||
}
|
||||
formatM2OFields(env, "mail.activity", allActivities, actSpec)
|
||||
formatDateFields("mail.activity", allActivities)
|
||||
normalizeNullFields("mail.activity", allActivities)
|
||||
actResult := make([]interface{}, len(allActivities))
|
||||
for i, a := range allActivities {
|
||||
actResult[i] = a
|
||||
}
|
||||
return actResult, nil
|
||||
|
||||
case "action_archive":
|
||||
ids := parseIDs(params.Args)
|
||||
@@ -683,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}
|
||||
return created.ID(), nil
|
||||
|
||||
case "web_resequence":
|
||||
// Resequence records by their IDs (drag&drop reordering).
|
||||
// Mirrors: odoo/addons/web/models/models.py web_resequence()
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
// Parse field_name (default "sequence")
|
||||
fieldName := "sequence"
|
||||
if v, ok := params.KW["field_name"].(string); ok {
|
||||
fieldName = v
|
||||
}
|
||||
|
||||
// Parse offset (default 0)
|
||||
offset := 0
|
||||
if v, ok := params.KW["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
|
||||
// Check if field exists on the model
|
||||
model := orm.Registry.Get(params.Model)
|
||||
if model == nil || model.GetField(fieldName) == nil {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
// Update sequence for each record in order
|
||||
for i, id := range ids {
|
||||
if err := rs.Browse(id).Write(orm.Values{fieldName: offset + i}); err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Return records via web_read
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
readParams := CallKWParams{
|
||||
Model: params.Model,
|
||||
Method: "web_read",
|
||||
Args: []interface{}{ids},
|
||||
KW: map[string]interface{}{"specification": spec},
|
||||
}
|
||||
return handleWebRead(env, params.Model, readParams)
|
||||
|
||||
case "message_post":
|
||||
// Post a message on the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
body, _ := params.KW["body"].(string)
|
||||
messageType := "comment"
|
||||
if v, _ := params.KW["message_type"].(string); v != "" {
|
||||
messageType = v
|
||||
}
|
||||
|
||||
// Get author from current user's partner_id
|
||||
var authorID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&authorID); err != nil {
|
||||
log.Printf("warning: message_post author lookup failed: %v", err)
|
||||
}
|
||||
|
||||
// Create mail.message linked to the current model/record
|
||||
var msgID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $6, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
params.Model, ids[0], body, messageType, authorID, env.UID(),
|
||||
).Scan(&msgID)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return msgID, nil
|
||||
|
||||
case "_message_get_thread":
|
||||
// Get messages for a record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.body, m.message_type, m.date,
|
||||
m.author_id, COALESCE(p.name, ''),
|
||||
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = $1 AND m.res_id = $2
|
||||
ORDER BY m.id DESC`,
|
||||
params.Model, ids[0],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var body, msgType, subject, emailFrom string
|
||||
var date interface{}
|
||||
var authorID int64
|
||||
var authorName string
|
||||
|
||||
if scanErr := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); scanErr != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id,
|
||||
"body": body,
|
||||
"message_type": msgType,
|
||||
"date": date,
|
||||
"subject": subject,
|
||||
"email_from": emailFrom,
|
||||
}
|
||||
if authorID > 0 {
|
||||
msg["author_id"] = []interface{}{authorID, authorName}
|
||||
} else {
|
||||
msg["author_id"] = false
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
return messages, nil
|
||||
|
||||
case "read_followers":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Search followers for this model/record
|
||||
followerRS := env.Model("mail.followers")
|
||||
domain := orm.And(
|
||||
orm.Leaf("res_model", "=", params.Model),
|
||||
orm.Leaf("res_id", "in", ids),
|
||||
)
|
||||
found, err := followerRS.Search(domain, orm.SearchOpts{Limit: 100})
|
||||
if err != nil || found.IsEmpty() {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
followerRecords, err := found.Read([]string{"id", "res_model", "res_id", "partner_id"})
|
||||
if err != nil {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
followerSpec := map[string]interface{}{"partner_id": map[string]interface{}{}}
|
||||
formatM2OFields(env, "mail.followers", followerRecords, followerSpec)
|
||||
normalizeNullFields("mail.followers", followerRecords)
|
||||
followerResult := make([]interface{}, len(followerRecords))
|
||||
for i, r := range followerRecords {
|
||||
followerResult[i] = r
|
||||
}
|
||||
return followerResult, nil
|
||||
|
||||
case "get_activity_data":
|
||||
// Return activity summary data for records.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py
|
||||
emptyResult := map[string]interface{}{
|
||||
"activity_types": []interface{}{},
|
||||
"activity_res_ids": map[string]interface{}{},
|
||||
"grouped_activities": map[string]interface{}{},
|
||||
}
|
||||
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return emptyResult, nil
|
||||
}
|
||||
|
||||
// Get activity types
|
||||
typeRS := env.Model("mail.activity.type")
|
||||
types, err := typeRS.Search(nil, orm.SearchOpts{Order: "sequence, id"})
|
||||
if err != nil || types.IsEmpty() {
|
||||
return emptyResult, nil
|
||||
}
|
||||
typeRecords, _ := types.Read([]string{"id", "name"})
|
||||
|
||||
typeList := make([]interface{}, len(typeRecords))
|
||||
for i, t := range typeRecords {
|
||||
typeList[i] = t
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"activity_types": typeList,
|
||||
"activity_res_ids": map[string]interface{}{},
|
||||
"grouped_activities": map[string]interface{}{},
|
||||
}, nil
|
||||
|
||||
default:
|
||||
// Try registered business methods on the model.
|
||||
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
|
||||
@@ -718,6 +1114,58 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
// --- Session / Auth Endpoints ---
|
||||
|
||||
// loginAttemptInfo tracks login attempts for rate limiting.
|
||||
type loginAttemptInfo struct {
|
||||
Count int
|
||||
LastTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
loginAttempts = make(map[string]loginAttemptInfo)
|
||||
loginAttemptsMu sync.Mutex
|
||||
)
|
||||
|
||||
// checkLoginRateLimit returns false if the login is rate-limited (too many attempts).
|
||||
func (s *Server) checkLoginRateLimit(login string) bool {
|
||||
loginAttemptsMu.Lock()
|
||||
defer loginAttemptsMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Periodic cleanup: evict stale entries (>15 min old) to prevent unbounded growth
|
||||
if len(loginAttempts) > 100 {
|
||||
for k, v := range loginAttempts {
|
||||
if now.Sub(v.LastTime) > 15*time.Minute {
|
||||
delete(loginAttempts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info := loginAttempts[login]
|
||||
|
||||
// Reset after 15 minutes
|
||||
if now.Sub(info.LastTime) > 15*time.Minute {
|
||||
info = loginAttemptInfo{}
|
||||
}
|
||||
|
||||
// Max 10 attempts per 15 minutes
|
||||
if info.Count >= 10 {
|
||||
return false // Rate limited
|
||||
}
|
||||
|
||||
info.Count++
|
||||
info.LastTime = now
|
||||
loginAttempts[login] = info
|
||||
return true
|
||||
}
|
||||
|
||||
// resetLoginRateLimit clears the rate limit counter on successful login.
|
||||
func (s *Server) resetLoginRateLimit(login string) {
|
||||
loginAttemptsMu.Lock()
|
||||
defer loginAttemptsMu.Unlock()
|
||||
delete(loginAttempts, login)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -740,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit login attempts
|
||||
if !s.checkLoginRateLimit(params.Login) {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||
Code: 429, Message: "Too many login attempts. Please try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Query user by login
|
||||
var uid int64
|
||||
var companyID int64
|
||||
@@ -762,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check password (support both bcrypt and plaintext for migration)
|
||||
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
|
||||
// Check password (bcrypt only — no plaintext fallback)
|
||||
if !tools.CheckPassword(hashedPw, params.Password) {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||
Code: 100, Message: "Access Denied: invalid login or password",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Successful login – reset rate limiter
|
||||
s.resetLoginRateLimit(params.Login)
|
||||
|
||||
// Query allowed companies for the user
|
||||
allowedCompanyIDs := []int64{companyID}
|
||||
rows, err := s.pool.Query(r.Context(),
|
||||
`SELECT DISTINCT c.id FROM res_company c
|
||||
WHERE c.active = true
|
||||
ORDER BY c.id`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var cid int64
|
||||
if rows.Scan(&cid) == nil {
|
||||
ids = append(ids, cid)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
allowedCompanyIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
sess := s.sessions.New(uid, companyID, params.Login)
|
||||
sess.AllowedCompanyIDs = allowedCompanyIDs
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
@@ -779,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
Value: sess.ID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
@@ -843,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
|
||||
// Session represents an authenticated user session.
|
||||
type Session struct {
|
||||
ID string
|
||||
UID int64
|
||||
CompanyID int64
|
||||
Login string
|
||||
CreatedAt time.Time
|
||||
LastActivity time.Time
|
||||
ID string
|
||||
UID int64
|
||||
CompanyID int64
|
||||
AllowedCompanyIDs []int64
|
||||
Login string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
LastActivity time.Time
|
||||
}
|
||||
|
||||
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
|
||||
@@ -47,10 +49,15 @@ func InitSessionTable(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
uid INT8 NOT NULL,
|
||||
company_id INT8 NOT NULL,
|
||||
login VARCHAR(255),
|
||||
csrf_token VARCHAR(64) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_seen TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
if err == nil {
|
||||
// Add csrf_token column if table already exists without it
|
||||
pool.Exec(ctx, `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS csrf_token VARCHAR(64) DEFAULT ''`)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,6 +74,7 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
|
||||
UID: uid,
|
||||
CompanyID: companyID,
|
||||
Login: login,
|
||||
CSRFToken: generateToken(),
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
}
|
||||
@@ -81,10 +89,10 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO sessions (id, uid, company_id, login, csrf_token, created_at, last_seen)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
token, uid, companyID, login, now, now)
|
||||
token, uid, companyID, login, sess.CSRFToken, now, now)
|
||||
if err != nil {
|
||||
log.Printf("session: failed to persist session to DB: %v", err)
|
||||
}
|
||||
@@ -106,20 +114,23 @@ func (s *SessionStore) Get(id string) *Session {
|
||||
s.Delete(id)
|
||||
return nil
|
||||
}
|
||||
// Update last activity
|
||||
|
||||
now := time.Now()
|
||||
needsDBUpdate := time.Since(sess.LastActivity) > 30*time.Second
|
||||
|
||||
// Update last activity in memory
|
||||
s.mu.Lock()
|
||||
sess.LastActivity = now
|
||||
s.mu.Unlock()
|
||||
|
||||
// Update last_seen in DB asynchronously
|
||||
if s.pool != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
|
||||
}()
|
||||
// Throttle DB writes: only persist every 30s to avoid per-request overhead
|
||||
if needsDBUpdate && s.pool != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
|
||||
log.Printf("session: failed to update last_seen in DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sess
|
||||
@@ -134,14 +145,20 @@ func (s *SessionStore) Get(id string) *Session {
|
||||
defer cancel()
|
||||
|
||||
sess = &Session{}
|
||||
var csrfToken string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, uid, company_id, login, created_at, last_seen
|
||||
`SELECT id, uid, company_id, login, COALESCE(csrf_token, ''), created_at, last_seen
|
||||
FROM sessions WHERE id = $1`, id).Scan(
|
||||
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
|
||||
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login, &csrfToken,
|
||||
&sess.CreatedAt, &sess.LastActivity)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if csrfToken != "" {
|
||||
sess.CSRFToken = csrfToken
|
||||
} else {
|
||||
sess.CSRFToken = generateToken()
|
||||
}
|
||||
|
||||
// Check TTL
|
||||
if time.Since(sess.LastActivity) > s.ttl {
|
||||
@@ -149,18 +166,18 @@ func (s *SessionStore) Get(id string) *Session {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update last activity
|
||||
// Update last activity and add to memory cache
|
||||
now := time.Now()
|
||||
sess.LastActivity = now
|
||||
|
||||
// Add to memory cache
|
||||
s.mu.Lock()
|
||||
sess.LastActivity = now
|
||||
s.sessions[id] = sess
|
||||
s.mu.Unlock()
|
||||
|
||||
// Update last_seen in DB
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
|
||||
log.Printf("session: failed to update last_seen in DB: %v", err)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/service"
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
|
||||
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
|
||||
|
||||
// isSetupNeeded checks if the current database has been initialized.
|
||||
@@ -55,6 +58,16 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate master password (default: "admin", configurable via ODOO_MASTER_PASSWORD env)
|
||||
masterPw := os.Getenv("ODOO_MASTER_PASSWORD")
|
||||
if masterPw == "" {
|
||||
masterPw = "admin"
|
||||
}
|
||||
if params.MasterPwd != masterPw {
|
||||
writeJSON(w, map[string]string{"error": "Invalid master password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate
|
||||
if params.Login == "" || params.Password == "" {
|
||||
writeJSON(w, map[string]string{"error": "Email and password are required"})
|
||||
@@ -111,7 +124,10 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
||||
domain := parts[1]
|
||||
domainParts := strings.Split(domain, ".")
|
||||
if len(domainParts) > 0 {
|
||||
companyName = strings.Title(domainParts[0])
|
||||
name := domainParts[0]
|
||||
if len(name) > 0 {
|
||||
companyName = strings.ToUpper(name[:1]) + name[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +191,195 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// postSetupDone caches the result of isPostSetupNeeded to avoid a DB query on every request.
|
||||
var postSetupDone atomic.Bool
|
||||
|
||||
// isPostSetupNeeded checks if the company still has default values (needs configuration).
|
||||
func (s *Server) isPostSetupNeeded() bool {
|
||||
if postSetupDone.Load() {
|
||||
return false
|
||||
}
|
||||
var name string
|
||||
err := s.pool.QueryRow(context.Background(),
|
||||
`SELECT COALESCE(name, '') FROM res_company WHERE id = 1`).Scan(&name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
needed := name == "" || name == "My Company" || strings.HasPrefix(name, "My ")
|
||||
if !needed {
|
||||
postSetupDone.Store(true)
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// handleSetupWizard serves the post-setup configuration wizard.
|
||||
// Shown after first login when the company has not been configured yet.
|
||||
// Mirrors: odoo/addons/base_setup/views/res_config_settings_views.xml
|
||||
func (s *Server) handleSetupWizard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Load current company data
|
||||
var companyName, street, city, zip, phone, email, website, vat string
|
||||
var countryID int64
|
||||
s.pool.QueryRow(context.Background(),
|
||||
`SELECT COALESCE(name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
|
||||
COALESCE(phone,''), COALESCE(email,''), COALESCE(website,''), COALESCE(vat,''),
|
||||
COALESCE(country_id, 0)
|
||||
FROM res_company WHERE id = $1`, sess.CompanyID,
|
||||
).Scan(&companyName, &street, &city, &zip, &phone, &email, &website, &vat, &countryID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
esc := htmlEscape
|
||||
fmt.Fprintf(w, setupWizardHTML,
|
||||
esc(companyName), esc(street), esc(city), esc(zip), esc(phone), esc(email), esc(website), esc(vat))
|
||||
}
|
||||
|
||||
// handleSetupWizardSave saves the post-setup wizard data.
|
||||
func (s *Server) handleSetupWizardSave(w http.ResponseWriter, r *http.Request) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
writeJSON(w, map[string]string{"error": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Street string `json:"street"`
|
||||
City string `json:"city"`
|
||||
Zip string `json:"zip"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Website string `json:"website"`
|
||||
Vat string `json:"vat"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
writeJSON(w, map[string]string{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.CompanyName == "" {
|
||||
writeJSON(w, map[string]string{"error": "Company name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`UPDATE res_company SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||
WHERE id = $9`,
|
||||
params.CompanyName, params.Street, params.City, params.Zip,
|
||||
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]string{"error": fmt.Sprintf("Save error: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Also update the partner linked to the company
|
||||
s.pool.Exec(context.Background(),
|
||||
`UPDATE res_partner SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||
WHERE id = (SELECT partner_id FROM res_company WHERE id = $9)`,
|
||||
params.CompanyName, params.Street, params.City, params.Zip,
|
||||
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||
|
||||
postSetupDone.Store(true) // Mark setup as done so we don't redirect again
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "redirect": "/odoo"})
|
||||
}
|
||||
|
||||
var setupWizardHTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Setup — Configure Your Company</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.wizard { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
width: 100%%; max-width: 560px; }
|
||||
.wizard h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
|
||||
.wizard .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
|
||||
.wizard label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
|
||||
.wizard input { width: 100%%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
|
||||
font-size: 14px; margin-bottom: 14px; }
|
||||
.wizard input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
|
||||
.wizard button { width: 100%%; padding: 14px; background: #71639e; color: white; border: none;
|
||||
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 10px; }
|
||||
.wizard button:hover { background: #5f5387; }
|
||||
.wizard .skip { text-align: center; margin-top: 12px; }
|
||||
.wizard .skip a { color: #999; text-decoration: none; font-size: 13px; }
|
||||
.wizard .skip a:hover { color: #666; }
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row > div { flex: 1; }
|
||||
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wizard">
|
||||
<h1>Configure Your Company</h1>
|
||||
<p class="subtitle">Set up your company information</p>
|
||||
<div id="error" class="error"></div>
|
||||
<form id="wizardForm">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" id="company_name" value="%s" required/>
|
||||
|
||||
<label>Street</label>
|
||||
<input type="text" id="street" value="%s"/>
|
||||
|
||||
<div class="row">
|
||||
<div><label>City</label><input type="text" id="city" value="%s"/></div>
|
||||
<div><label>ZIP</label><input type="text" id="zip" value="%s"/></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div><label>Phone</label><input type="tel" id="phone" value="%s"/></div>
|
||||
<div><label>Email</label><input type="email" id="email" value="%s"/></div>
|
||||
</div>
|
||||
|
||||
<label>Website</label>
|
||||
<input type="url" id="website" value="%s" placeholder="https://"/>
|
||||
|
||||
<label>Tax ID / VAT</label>
|
||||
<input type="text" id="vat" value="%s"/>
|
||||
|
||||
<button type="submit">Save & Continue</button>
|
||||
</form>
|
||||
<div class="skip"><a href="/odoo">Skip for now</a></div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('wizardForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
fetch('/web/setup/wizard/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
company_name: document.getElementById('company_name').value,
|
||||
street: document.getElementById('street').value,
|
||||
city: document.getElementById('city').value,
|
||||
zip: document.getElementById('zip').value,
|
||||
phone: document.getElementById('phone').value,
|
||||
email: document.getElementById('email').value,
|
||||
website: document.getElementById('website').value,
|
||||
vat: document.getElementById('vat').value
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(result) {
|
||||
if (result.error) {
|
||||
var el = document.getElementById('error');
|
||||
el.textContent = result.error;
|
||||
el.style.display = 'block';
|
||||
} else {
|
||||
window.location.href = result.redirect || '/odoo';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// --- Database Manager HTML ---
|
||||
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
|
||||
var databaseManagerHTML = `<!DOCTYPE html>
|
||||
|
||||
@@ -43,8 +43,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
addonName := parts[0]
|
||||
filePath := parts[2]
|
||||
|
||||
// Security: prevent directory traversal
|
||||
if strings.Contains(filePath, "..") {
|
||||
// Security: prevent directory traversal in both addonName and filePath
|
||||
if strings.Contains(filePath, "..") || strings.Contains(addonName, "..") ||
|
||||
strings.Contains(addonName, "/") || strings.Contains(addonName, "\\") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestExtractImports(t *testing.T) {
|
||||
content := `import { Foo, Bar } from "@web/core/foo";
|
||||
import { Baz as Qux } from "@web/core/baz";
|
||||
const x = 1;`
|
||||
deps, requires, clean := extractImports(content)
|
||||
deps, requires, clean := extractImports("test.module", content)
|
||||
|
||||
if len(deps) != 2 {
|
||||
t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps)
|
||||
@@ -120,7 +120,7 @@ const x = 1;`
|
||||
|
||||
t.Run("default import", func(t *testing.T) {
|
||||
content := `import Foo from "@web/core/foo";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
deps, requires, _ := extractImports("test.module", content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/foo" {
|
||||
t.Errorf("deps = %v, want [@web/core/foo]", deps)
|
||||
@@ -132,7 +132,7 @@ const x = 1;`
|
||||
|
||||
t.Run("namespace import", func(t *testing.T) {
|
||||
content := `import * as utils from "@web/core/utils";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
deps, requires, _ := extractImports("test.module", content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/utils" {
|
||||
t.Errorf("deps = %v, want [@web/core/utils]", deps)
|
||||
@@ -144,7 +144,7 @@ const x = 1;`
|
||||
|
||||
t.Run("side-effect import", func(t *testing.T) {
|
||||
content := `import "@web/core/setup";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
deps, requires, _ := extractImports("test.module", content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/setup" {
|
||||
t.Errorf("deps = %v, want [@web/core/setup]", deps)
|
||||
@@ -157,7 +157,7 @@ const x = 1;`
|
||||
t.Run("dedup deps", func(t *testing.T) {
|
||||
content := `import { Foo } from "@web/core/foo";
|
||||
import { Bar } from "@web/core/foo";`
|
||||
deps, _, _ := extractImports(content)
|
||||
deps, _, _ := extractImports("test.module", content)
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Errorf("expected deduped deps, got %v", deps)
|
||||
@@ -167,7 +167,7 @@ import { Bar } from "@web/core/foo";`
|
||||
|
||||
func TestTransformExports(t *testing.T) {
|
||||
t.Run("export class", func(t *testing.T) {
|
||||
got := transformExports("export class Foo extends Bar {")
|
||||
got, _ := transformExports("export class Foo extends Bar {")
|
||||
want := "const Foo = __exports.Foo = class Foo extends Bar {"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
@@ -175,15 +175,18 @@ func TestTransformExports(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("export function", func(t *testing.T) {
|
||||
got := transformExports("export function doSomething(a, b) {")
|
||||
want := `__exports.doSomething = function doSomething(a, b) {`
|
||||
got, deferred := transformExports("export function doSomething(a, b) {")
|
||||
want := `function doSomething(a, b) {`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
if len(deferred) != 1 || deferred[0] != "doSomething" {
|
||||
t.Errorf("deferred = %v, want [doSomething]", deferred)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export const", func(t *testing.T) {
|
||||
got := transformExports("export const MAX_SIZE = 100;")
|
||||
got, _ := transformExports("export const MAX_SIZE = 100;")
|
||||
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
@@ -191,7 +194,7 @@ func TestTransformExports(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("export let", func(t *testing.T) {
|
||||
got := transformExports("export let counter = 0;")
|
||||
got, _ := transformExports("export let counter = 0;")
|
||||
want := "let counter = __exports.counter = 0;"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
@@ -199,7 +202,7 @@ func TestTransformExports(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("export default", func(t *testing.T) {
|
||||
got := transformExports("export default Foo;")
|
||||
got, _ := transformExports("export default Foo;")
|
||||
want := `__exports[Symbol.for("default")] = Foo;`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
@@ -207,7 +210,7 @@ func TestTransformExports(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("export named", func(t *testing.T) {
|
||||
got := transformExports("export { Foo, Bar };")
|
||||
got, _ := transformExports("export { Foo, Bar };")
|
||||
if !strings.Contains(got, "__exports.Foo = Foo;") {
|
||||
t.Errorf("missing Foo export in: %s", got)
|
||||
}
|
||||
@@ -217,7 +220,7 @@ func TestTransformExports(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("export named with alias", func(t *testing.T) {
|
||||
got := transformExports("export { Foo as default };")
|
||||
got, _ := transformExports("export { Foo as default };")
|
||||
if !strings.Contains(got, "__exports.default = Foo;") {
|
||||
t.Errorf("missing aliased export in: %s", got)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user