Compare commits
80 Commits
b07f771d98
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1414ef2c1c | ||
|
|
42e8fe3d21 | ||
|
|
bad73fcea8 | ||
|
|
94249ba67d | ||
|
|
2abd859a29 | ||
|
|
98cb42d2e5 | ||
|
|
878d05685c | ||
|
|
bd2c037a97 | ||
|
|
44636e47fb | ||
|
|
06c49ecec6 | ||
|
|
37deaedf0d | ||
|
|
30f7f18472 | ||
|
|
66e9749853 | ||
|
|
c9be68a575 | ||
|
|
19d692afe7 | ||
|
|
0351dcd497 | ||
|
|
03fd3d7c1c | ||
|
|
f4c9ed3d24 | ||
|
|
ef885c66dc | ||
|
|
148aa5cba8 | ||
|
|
661c8ae227 | ||
|
|
a24a1ddf1a | ||
|
|
f05cacec22 | ||
|
|
9239ee2822 | ||
|
|
4733885211 | ||
|
|
8e708bf2c4 | ||
|
|
caf240daec | ||
|
|
4bed8ab2c5 | ||
|
|
50c209b8d3 | ||
|
|
65a1c4b17e | ||
|
|
91d3a3f9d1 | ||
|
|
70f855d91b | ||
|
|
85eddba546 | ||
|
|
48d3e48e61 | ||
|
|
f07e1bcce1 | ||
|
|
e7c6960de9 | ||
|
|
ad64b0b4c9 | ||
|
|
cd763fa1d7 | ||
|
|
f40f44aafd | ||
|
|
63bf271725 | ||
|
|
974b8a5152 | ||
|
|
0a32ed2da7 | ||
|
|
e4681a58c6 | ||
|
|
135cbd3a5c | ||
|
|
3182ca3c39 | ||
|
|
677e460438 | ||
|
|
c7b794f604 | ||
|
|
64c61dcca8 | ||
|
|
649b75d4a1 | ||
|
|
8aa817b1a0 | ||
|
|
80d1cc5639 | ||
|
|
2db789d7dd | ||
|
|
7a02382623 | ||
|
|
169e97af02 | ||
|
|
3c959771ae | ||
|
|
449f29fc7f | ||
|
|
3c2fb22346 | ||
|
|
3a41370189 | ||
|
|
d6513ff7ab | ||
|
|
457d9b7dbf | ||
|
|
c85a9bbf82 | ||
|
|
5b399fbdda | ||
|
|
b5416d242c | ||
|
|
fdbbd2852a | ||
|
|
be109c9c79 | ||
|
|
78d633f63f | ||
|
|
95cb73d91a | ||
|
|
0d85063b5e | ||
|
|
765a0a4c82 | ||
|
|
daf1235e20 | ||
|
|
3d4f003aba | ||
|
|
6c6fb8d2a4 | ||
|
|
1b1bebdcd8 | ||
|
|
e0d1998811 | ||
|
|
bc3f584851 | ||
|
|
105909470f | ||
|
|
6e67fc5ce3 | ||
|
|
fd9d4e775b | ||
|
|
2de5491693 | ||
|
|
671820427a |
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# Nexa Systems Inc — Chart of Accounts & Accounting Setup Design
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Target**: odoo-nexa production instance, database `nexamain`
|
||||
**Status**: Design — pending implementation plan
|
||||
|
||||
## 1. Context
|
||||
|
||||
Nexa Systems Inc is a Canadian CCPC providing IT services: custom software development, custom ERP, business apps, hosting, custom websites, and custom web apps. Operations are Canada-wide with planned global expansion. Workforce: solo founder today (Gurpreet, Canadian), hiring plan favours Canadian T4/T4A with occasional India contractors for burst capacity. Nexa will pursue SR&ED tax credits.
|
||||
|
||||
**Current state (as of 2026-05-12)**:
|
||||
- Odoo 19 Enterprise, l10n_ca localization loaded
|
||||
- 426 GL accounts (most unused — generic Canadian template bloat)
|
||||
- 49 active taxes with duplicates
|
||||
- 14 journals incl. 7 bank accounts (overprovisioned)
|
||||
- 776 journal entries, 125 invoices, data 2020-01-01 to 2026-05-04
|
||||
- **Historical Odoo data is NOT authoritative** — accountant has filed externally on Excel-based records. Past will be reconciled later.
|
||||
- All prior years filed with CRA. Fiscal year-end Dec 31.
|
||||
|
||||
**CRA registration & filing cadence**:
|
||||
- **Business Number / HST account**: `741224877` (currently stored as 9-digit BN root only on company record; needs to be updated to full 15-char format `741224877 RT0001` for Odoo's Canadian tax reports to validate cleanly).
|
||||
- **GST/HST filing**: annual. Return due **3 months after fiscal year-end** (March 31).
|
||||
- **T2 corporate income tax filing**: annual. Return due **6 months after fiscal year-end** (June 30). Balance owing due 3 months after year-end (March 31) for CCPCs eligible for SBD; 2 months otherwise.
|
||||
- **HST instalments**: annual filers must remit quarterly instalments if their net tax for the prior year was ≥ $3,000. Track via account 118200 GST/HST Instalments Paid.
|
||||
- **T2 instalments**: monthly or quarterly instalments required if Part I tax owing in prior year ≥ $3,000.
|
||||
|
||||
**Goals**:
|
||||
1. **CRA compliance** — clean tax handling, T2 Schedule 125 alignment, audit-ready
|
||||
2. **Tax savings** — SR&ED claim infrastructure from day 1, zero-rated export handling, CCA structure
|
||||
3. **Automation** — fiscal positions, default accounts, bank feeds, subscription billing
|
||||
4. **Ease of use** — invoicing is one-click after customer/product selection
|
||||
|
||||
**Scope**: Chart of accounts structure + tax/fiscal-position setup + analytic plans + automation hooks. **Out of scope**: bank feed onboarding (separate sub-project), CCA custom module (defer until volume warrants), historical data reconciliation (separate sub-project when accountant records arrive).
|
||||
|
||||
## 2. Approach
|
||||
|
||||
**Approach #2 — Hybrid**: keep l10n_ca's 6-digit code scheme (Canadian accountants recognize it), aggressively curate (~370 unused accounts archived, ~20 renamed, ~70 added), supplement with three analytic plans for finer reporting without GL proliferation.
|
||||
|
||||
**Rejected alternatives**:
|
||||
- *Surgical* — keep all 426 accounts unchanged. Rejected: bookkeeping burden, no IT-services shape.
|
||||
- *Clean slate (custom 4-digit)* — toss l10n_ca. Rejected: accountants would have to learn it; loses pre-mapped CRA tax structure.
|
||||
|
||||
## 3. Code Skeleton
|
||||
|
||||
```
|
||||
1xxxxx ASSETS
|
||||
111xxx Cash & cash equivalents
|
||||
112xxx Accounts receivable
|
||||
113xxx Prepaid expenses
|
||||
114xxx Other current assets
|
||||
115xxx Due from shareholder / related parties
|
||||
118xxx Tax assets (HST ITC, instalments)
|
||||
151xxx Capital assets — cost
|
||||
154xxx Accumulated depreciation (contra)
|
||||
|
||||
2xxxxx LIABILITIES
|
||||
211xxx Accounts payable
|
||||
213xxx HST/GST/QST collected
|
||||
214xxx Net tax payable
|
||||
215xxx Source deductions payable
|
||||
216xxx Corporate income tax payable
|
||||
221xxx Due to shareholder
|
||||
222xxx Due to related parties
|
||||
251xxx Long-term debt
|
||||
|
||||
3xxxxx EQUITY
|
||||
311xxx Share capital + contributed surplus
|
||||
321xxx Retained earnings + dividends
|
||||
|
||||
4xxxxx REVENUE (by service line — jurisdiction handled by tax codes, not by account)
|
||||
411xxx Recurring revenue (SaaS, hosting, support)
|
||||
412xxx Project revenue (custom dev, web app, website, ERP)
|
||||
413xxx Services (consulting, training, support hourly)
|
||||
414xxx Reseller revenue (third-party software/hardware)
|
||||
419xxx Sales adjustments (discounts, returns, bad debt recovery)
|
||||
|
||||
5xxxxx DIRECT COSTS (COGS)
|
||||
511xxx Infrastructure & hosting costs
|
||||
512xxx Project direct costs (subcontractors, project software, project travel)
|
||||
513xxx Cost of resold goods
|
||||
519xxx COGS adjustments
|
||||
|
||||
6xxxxx OPERATING EXPENSES
|
||||
611xxx Personnel — internal staff (T4)
|
||||
612xxx Personnel — contract (T4A non-project)
|
||||
621xxx Office & facilities
|
||||
631xxx Technology — operating (internal SaaS subs)
|
||||
641xxx Marketing & sales
|
||||
651xxx Professional fees
|
||||
661xxx Insurance
|
||||
671xxx Travel & entertainment
|
||||
681xxx Training & development
|
||||
691xxx Banking & finance charges
|
||||
699xxx Other (bad debt, donations, fines, FX losses, depreciation)
|
||||
|
||||
7xxxxx Other income (interest, FX gains)
|
||||
8xxxxx Other expenses (rare; mostly absorbed in 691/699)
|
||||
```
|
||||
|
||||
**Three analytic plans** (orthogonal tagging, applied per journal line):
|
||||
|
||||
| Plan | Required On | Purpose |
|
||||
|---|---|---|
|
||||
| **Project** | revenue, COGS, project costs | Project P&L, customer profitability, WIP, billable-hour realization |
|
||||
| **Department** | payroll, OpEx | Departmental P&L, overhead allocation |
|
||||
| **SR&ED Tag** | labour, contractors, materials (R&D) | T661 SR&ED claim — eligibility classification |
|
||||
|
||||
## 4. Revenue Accounts (4xxxxx)
|
||||
|
||||
```
|
||||
Recurring Revenue
|
||||
411100 SaaS Subscription Revenue
|
||||
411200 Hosting & Infrastructure Revenue
|
||||
411300 Support & Maintenance Contracts
|
||||
411400 Domain/SSL/Renewal Pass-through Revenue
|
||||
411500 Setup / Onboarding Fees
|
||||
|
||||
Project Revenue (one-time, milestone-billed)
|
||||
412100 Custom Software Development
|
||||
412200 Custom Web Application Development
|
||||
412300 Custom Website Development
|
||||
412400 ERP Implementation & Customization
|
||||
412500 Mobile App Development ← reserved for future
|
||||
412600 Business App / Integration Work
|
||||
|
||||
Services (hourly, retainer)
|
||||
413100 Consulting & Advisory
|
||||
413200 Training & Workshops
|
||||
413300 Technical Support — Per-incident / Hourly
|
||||
|
||||
Reseller / Pass-through
|
||||
414100 Third-party Software Resale (M365, Adobe)
|
||||
414200 Hardware Resale
|
||||
|
||||
Adjustments (contra-revenue)
|
||||
419100 Sales Discounts
|
||||
419200 Sales Returns & Refunds
|
||||
419300 Bad Debt Recovery
|
||||
```
|
||||
|
||||
**Design rule**: one revenue account per service line. Jurisdiction (ON/Atlantic/QC/export/etc.) tracked entirely through tax codes and fiscal positions, NOT duplicate accounts.
|
||||
|
||||
## 5. Direct Costs / COGS (5xxxxx)
|
||||
|
||||
```
|
||||
Infrastructure & Hosting
|
||||
511100 Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)
|
||||
511110 CDN & Edge Services (Cloudflare, Fastly)
|
||||
511120 Backup & Storage Services
|
||||
511130 Database & Backend Services (Supabase, hosted Postgres, Redis)
|
||||
511140 Monitoring & Observability (customer-facing only)
|
||||
511150 SSL Certificates & Domains (wholesale for resale)
|
||||
511160 DNS & Email Hosting (wholesale)
|
||||
|
||||
Third-party APIs & Per-transaction Costs
|
||||
511200 Third-party API Costs (Twilio, SendGrid, OpenAI)
|
||||
511210 Per-customer Licensing & Royalties
|
||||
|
||||
Note: 511100–511160 are shared between SaaS revenue (411100) and Hosting revenue (411200).
|
||||
Allocation to specific revenue line happens via the Project analytic plan, not separate accounts.
|
||||
|
||||
Project Direct Costs
|
||||
512100 Subcontracted Labour — Canadian (T4A) ← SR&ED-eligible
|
||||
512110 Subcontracted Labour — Foreign ← NOT SR&ED-eligible
|
||||
512200 Project-specific Software & Licenses
|
||||
512300 Project Travel & Onsite (rebilled)
|
||||
512400 Project Hardware (passed through)
|
||||
|
||||
Resold Goods & Services
|
||||
513100 Cost of Software Resold
|
||||
513200 Cost of Hardware Resold
|
||||
|
||||
Adjustments
|
||||
519100 COGS Adjustments / Write-offs
|
||||
```
|
||||
|
||||
**Design choices**:
|
||||
- **Salaries in OpEx, not COGS** — keeps SR&ED tracking clean; allocation to projects via Project analytic plan.
|
||||
- **Stripe/merchant fees in OpEx (691200)** — re-class to COGS later if SaaS revenue dominates.
|
||||
- **Canadian vs Foreign subcontractor split** — critical for SR&ED (80% × 35% = 28% credit on CA arm's length; 0% on foreign).
|
||||
|
||||
## 6. Operating Expenses (6xxxxx)
|
||||
|
||||
```
|
||||
Personnel — Internal Staff (T4)
|
||||
611100 Salaries & Wages — Development ← SR&ED-eligible base
|
||||
611200 Salaries & Wages — Sales & Marketing
|
||||
611300 Salaries & Wages — Admin & Operations
|
||||
611400 Salary — Shareholder/Officer (Gurpreet) ← 75% SR&ED cap (specified employee)
|
||||
611500 Employer CPP / QPP Contributions
|
||||
611600 Employer EI Premiums
|
||||
611700 Employer Health Tax (EHT/QHST)
|
||||
611800 WCB / WSIB Premiums
|
||||
611900 Employee Benefits (health, dental, group)
|
||||
611950 Bonuses & Incentives
|
||||
611960 Vacation Pay Accrual
|
||||
|
||||
Personnel — Contract (non-project)
|
||||
612100 Contract Labour — Canadian (admin/marketing/freelance)
|
||||
612200 Contract Labour — Foreign
|
||||
|
||||
Office & Facilities
|
||||
621100 Rent — Commercial Office
|
||||
621200 Home Office — Business Portion ← own account; allocated %
|
||||
621300 Utilities — Commercial
|
||||
621400 Internet & Phone — Business
|
||||
621500 Office Supplies & Consumables
|
||||
621600 Cleaning & Maintenance
|
||||
621700 Office Snacks & Refreshments
|
||||
|
||||
Technology — Operating
|
||||
631100 Software — Productivity (M365, Slack, Notion, Linear, GitHub)
|
||||
631200 Software — Development Tools (Cursor, Figma, IDEs)
|
||||
631300 Software — Internal Infrastructure
|
||||
631400 Software — Security & IT
|
||||
631500 Software — Sales & Marketing
|
||||
|
||||
Marketing & Sales
|
||||
641100 Advertising — Digital Ads
|
||||
641200 Advertising — Content / SEO
|
||||
641300 Trade Shows & Conferences
|
||||
641400 Promotional Items / Branded Swag
|
||||
641500 Website — Own (nexasystems.ca)
|
||||
|
||||
Professional Fees
|
||||
651100 Legal Fees — General
|
||||
651200 Accounting & Bookkeeping
|
||||
651300 Tax Preparation (T2, T1, GST/HST)
|
||||
651400 Business Consulting
|
||||
|
||||
Insurance
|
||||
661100 Commercial General Liability
|
||||
661200 Professional Liability / E&O
|
||||
661300 Cyber Liability
|
||||
661400 Property Insurance
|
||||
661500 Directors & Officers Insurance
|
||||
|
||||
Travel & Entertainment
|
||||
671100 Travel — Flights, Hotels, Ground Transport
|
||||
671200 Meals & Entertainment — 50% Deductible ← own account; 50% adjustment at year-end
|
||||
671300 Vehicle — Operating
|
||||
671400 Mileage Reimbursement — Personal Vehicle
|
||||
|
||||
Training & Development
|
||||
681100 Conferences & Seminars
|
||||
681200 Courses & Certifications
|
||||
681300 Books & Publications
|
||||
681400 Professional Memberships & Dues
|
||||
|
||||
Banking & Finance
|
||||
691100 Bank Service Charges
|
||||
691200 Merchant Processing Fees (Stripe, PayPal, Square)
|
||||
691300 Wire Transfer & FX Fees
|
||||
691400 Interest Expense — Bank Loans / LOC
|
||||
691500 Interest Expense — Credit Cards
|
||||
691600 Late Payment Penalties — Non-deductible
|
||||
|
||||
Other
|
||||
699100 Bad Debt Expense
|
||||
699200 Donations & Sponsorships
|
||||
699300 Penalties & Fines — Non-deductible
|
||||
699400 Realized FX Losses
|
||||
699500 Depreciation / CCA Expense
|
||||
```
|
||||
|
||||
**Notable design decisions**:
|
||||
- Salaries split by function (dev/sales/admin) — so SR&ED proxy applies cleanly to dev only.
|
||||
- Owner/Shareholder salary isolated (611400) — for T2 Schedule 11 (Compensation of Officers) and CRA reasonableness defence.
|
||||
- Non-deductible items isolated (691600, 699300) — prevents accidental deduction.
|
||||
- Meals & Entertainment own account (671200) — accountant applies the 50% adjustment cleanly.
|
||||
- Home office own account (621200) — business-use % applied to the whole account.
|
||||
|
||||
## 7. Capital Assets & CCA (1xxxxx + asset module)
|
||||
|
||||
```
|
||||
Capital Assets — Cost
|
||||
151100 Computer Hardware & Equipment (CCA Class 50, 55% DB)
|
||||
151200 Office Furniture & Equipment (CCA Class 8, 20% DB)
|
||||
151300 Vehicles (CCA Class 10 / 10.1)
|
||||
151400 Leasehold Improvements (CCA Class 13, SL)
|
||||
151500 Acquired Software/Intangibles (CCA Class 14.1, 5% DB)
|
||||
151600 Tools & Small Equipment <$500 (CCA Class 12, 100% Y1)
|
||||
|
||||
Accumulated Depreciation (contra)
|
||||
154100 Acc. Dep — Computer Hardware
|
||||
154200 Acc. Dep — Office Furniture
|
||||
154300 Acc. Dep — Vehicles
|
||||
154400 Acc. Dep — Leasehold Improvements
|
||||
154500 Acc. Dep — Acquired Software
|
||||
```
|
||||
|
||||
**Asset model approach**: book straight-line depreciation in Odoo for financial reporting (clean monthly journal); maintain CCA schedule separately for T2 filing. CCA rates: Class 50 effective 82.5% Y1 (with AccII through 2027); Class 14.1 software 100% Y1; Class 12 small tools 100% Y1.
|
||||
|
||||
## 8. Tax Accounts (1xxxxx + 2xxxxx)
|
||||
|
||||
```
|
||||
Tax Assets
|
||||
118100 HST/GST Input Tax Credit (ITC) Receivable
|
||||
118200 HST/GST Instalments Paid
|
||||
118300 QST Input Tax Refund Receivable
|
||||
|
||||
Tax Liabilities
|
||||
213100 HST/GST Collected on Sales ← single bucket; tax report breaks down by code
|
||||
213500 QST Collected
|
||||
214100 Net HST/GST Payable
|
||||
215100 Source Deductions Payable — Federal Tax
|
||||
215200 Source Deductions Payable — CPP
|
||||
215300 Source Deductions Payable — EI
|
||||
216100 Corporate Income Tax — Federal Payable
|
||||
216200 Corporate Income Tax — Provincial Payable
|
||||
216300 Corporate Tax Instalments Paid (contra)
|
||||
```
|
||||
|
||||
## 9. Shareholder, Associated Corporations & Equity
|
||||
|
||||
**Associated corporations** (Gurpreet >25% owner of each → ITA s.256 associated group):
|
||||
- Nexa Systems Inc (this company)
|
||||
- Westin Healthcare Inc
|
||||
- Divine Mobility Inc
|
||||
|
||||
**Treatment**: Westin and Divine are **regular Customers and Vendors of Nexa**, NOT slush accounts. Their transactions flow through normal AR/AP. They get partner records tagged `Related Party — Associated Corporation` for disclosure tracking. The "Due To/From Related Party" GL buckets exist only for true intercompany loans (cash moved between the corps' bank accounts without an invoice).
|
||||
|
||||
```
|
||||
Due From — Assets
|
||||
115100 Due From Shareholder — Gurpreet
|
||||
115900 Due From Associated Corporations (intercompany loans only — NOT customer AR)
|
||||
|
||||
Due To — Liabilities
|
||||
221100 Due To Shareholder — Gurpreet (short-term, <1 year)
|
||||
221200 Shareholder Loan — Gurpreet (long-term, with commercial terms)
|
||||
222900 Due To Associated Corporations (intercompany loans only — NOT vendor AP)
|
||||
|
||||
Equity
|
||||
311100 Share Capital — Common Shares
|
||||
311200 Share Capital — Preferred Shares (placeholder)
|
||||
311300 Contributed Surplus
|
||||
321100 Retained Earnings — Current Year
|
||||
321200 Retained Earnings — Prior Years
|
||||
321900 Dividends Declared (contra)
|
||||
```
|
||||
|
||||
**Partner setup** (under Contacts, not GL accounts):
|
||||
- `Westin Healthcare Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||
- `Divine Mobility Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||
- Nexa invoices Westin/Divine like any client → AR in 112xxx, revenue in 4xxxxx, HST 13% (Ontario)
|
||||
- Westin/Divine bill Nexa → AP in 211xxx, expense in 6xxxxx / COGS in 5xxxxx
|
||||
|
||||
**Intercompany compliance flags (CRITICAL — drives major tax decisions)**:
|
||||
|
||||
1. **Small Business Deduction (SBD) sharing — ITA s.125(5.1)**: The $500k federal SBD limit is **shared across all associated corporations**. If Nexa, Westin, and Divine are each profitable, they collectively get **one** $500k pool, not three. The corps must file Schedule 23 (T2) allocating the limit. Strategy: allocate the limit to whichever corp has the highest taxable income each year.
|
||||
|
||||
2. **SR&ED expenditure limit shared — ITA s.127(10.2)**: The $3M expenditure limit for the 35% refundable ITC is also shared across the associated group. Same Schedule 23 mechanism. Nexa being the dev shop probably consumes most/all of it.
|
||||
|
||||
3. **Transfer pricing — ITA s.247**: Services between related corps must be priced at fair market value. Nexa invoicing Westin at $50/hr while billing arm's-length clients $150/hr will be scrutinized. Document the rate methodology. Penalty for non-compliance is 10% of the adjustment.
|
||||
|
||||
4. **Subsection 15(2) shareholder loans**: outstanding >1 year past FY end → taxable to Gurpreet personally.
|
||||
|
||||
5. **T2 Schedule 9** (Related and Associated Corporations) must be filed by Nexa listing Westin and Divine.
|
||||
|
||||
6. **GAAR risk**: aggressive intercompany pricing or loan arrangements designed primarily for tax benefit can be challenged under general anti-avoidance rules.
|
||||
|
||||
## 10. Analytic Plans
|
||||
|
||||
### 10.1 Project Plan
|
||||
- One analytic account per customer engagement
|
||||
- Naming: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g., `PRJ-2026-WESTIN-ERP`)
|
||||
- Required on revenue, COGS, project costs
|
||||
- Linked to Odoo Project module for time tracking → automatic GL posting
|
||||
|
||||
### 10.2 Department Plan
|
||||
- `DEPT-DEV` — Development
|
||||
- `DEPT-SALES` — Sales & Marketing
|
||||
- `DEPT-ADMIN` — Admin & Operations
|
||||
- `DEPT-HOSTING` — Hosting Operations (optional future split)
|
||||
- Required on payroll, OpEx
|
||||
|
||||
### 10.3 SR&ED Tag Plan
|
||||
- `SRED-T4-DEV-SALARY` — T4 dev employees on R&D (full proxy 55%)
|
||||
- `SRED-SPECIFIED-EMPLOYEE` — Gurpreet/officers (75% basic salary cap)
|
||||
- `SRED-CONTRACTOR-CA-ARM-LENGTH` — Canadian arm's length (80% eligible)
|
||||
- `SRED-CONTRACTOR-CA-NON-ARM-LENGTH` — affiliated CA contractors
|
||||
- `SRED-MATERIALS-CONSUMED` — R&D materials
|
||||
- `SRED-OVERHEAD-PROXY-BASIS` — direct labour basis
|
||||
- `NOT-ELIGIBLE` — default
|
||||
|
||||
**T661 generation at year-end**: filter analytic report on SR&ED tag → eligible salaries + 55% proxy + 80% contractor + materials = total qualified expenditures × 35% refundable ITC.
|
||||
|
||||
## 11. Tax Setup & Fiscal Positions
|
||||
|
||||
**Consolidated active taxes** (~14, down from 49):
|
||||
|
||||
| Tax | Rate | Sale / Purchase | Applies |
|
||||
|---|---|---|---|
|
||||
| HST 13% Ontario | 13% | Both | ON |
|
||||
| HST 15% Atlantic | 15% | Both | NB, NS, PE, NL |
|
||||
| GST 5% | 5% | Both | AB, MB, SK, BC, YT, NT, NU |
|
||||
| GST 5% + PST 7% BC | 12% group | Both | BC (goods, rare for services) |
|
||||
| GST 5% + PST 7% MB | 12% group | Both | MB |
|
||||
| GST 5% + PST 6% SK | 11% group | Both | SK |
|
||||
| GST 5% + QST 9.975% QC | 14.975% group | Both | QC |
|
||||
| Zero-rated Export | 0% | Sale | US, EU, ROW |
|
||||
| Tax Exempt | 0% | Sale | Cert-holders |
|
||||
|
||||
**Fiscal Positions** (auto-applied based on customer billing address):
|
||||
|
||||
| Position | Customer Location | Auto-Substitute Default Tax |
|
||||
|---|---|---|
|
||||
| CA — Ontario (default) | ON | HST 13% |
|
||||
| CA — Atlantic | NB/NS/PE/NL | HST 15% |
|
||||
| CA — Quebec | QC | GST 5% + QST 9.975% |
|
||||
| CA — BC | BC | GST 5% (PST per-product) |
|
||||
| CA — Prairies / Territories | AB/MB/SK/YT/NT/NU | GST 5% |
|
||||
| Export — US | United States | 0% Zero-rated |
|
||||
| Export — International | Outside CA/US | 0% Zero-rated |
|
||||
| Tax Exempt | Tagged customers | 0% |
|
||||
|
||||
**Invoice flow**: customer → fiscal position auto-applies → product picks default tax → fiscal position substitutes → no manual tax decisions.
|
||||
|
||||
**Export advantage**: zero-rated sales charge no HST but retain ITC claims on all related inputs. For a small shop with 30% US revenue, this is ~$5–15k/year in recovered HST.
|
||||
|
||||
## 12. Cleanup Plan
|
||||
|
||||
### Phase 1 — Archive (~370 accounts)
|
||||
- Every l10n_ca account NOT in the keep-list (built from Sections 4–9).
|
||||
- Constraint: Odoo blocks archiving accounts with postings. Archive zero-history only.
|
||||
- Accounts with history we no longer want: stop posting; they go to $0 going forward.
|
||||
|
||||
### Phase 2 — Rename (~20 accounts)
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| 1400 Transferred to Gurpreet | 221100 Due To Shareholder — Gurpreet |
|
||||
| 1505 Sent to India | 612200 Contract Labour — Foreign |
|
||||
| 1580 Transferred to Westin | ARCHIVE — Westin is an associated corp, future transactions go through normal AR/AP via partner record `Westin Healthcare Inc` |
|
||||
| 1590 Transferred to Divine | ARCHIVE — Divine is an associated corp, future transactions go through normal AR/AP via partner record `Divine Mobility Inc` |
|
||||
| 1600 Transferred to Manpreet | ARCHIVE — Manpreet is an employee of another company, not a related party of Nexa; historical transactions to be re-classified by accountant during reconciliation |
|
||||
| 1500 Food & Entertainment | 671200 Meals & Entertainment — 50% Deductible |
|
||||
| 1501 Office Expenses | 621500 Office Supplies & Consumables |
|
||||
| 411000 Inside Sales | ARCHIVE (replaced by 412xxx) |
|
||||
| 412000 Harmonized Provinces Sales | ARCHIVE (jurisdiction = tax codes) |
|
||||
| 413000 Non-Harmonized Provinces Sales | ARCHIVE |
|
||||
| 414000 International Sales | ARCHIVE |
|
||||
| 12000 Abdul & Future Mobility | ARCHIVE (use partner subledger) |
|
||||
| 12001 MSI Account | ARCHIVE |
|
||||
| 110010 Bank Fee | 691100 Bank Service Charges |
|
||||
| 511100 Inside Purchases | ARCHIVE |
|
||||
|
||||
### Phase 3 — Add (~70 new accounts)
|
||||
All per Sections 4–9.
|
||||
|
||||
### Phase 4 — Bank Consolidation
|
||||
Current 8 bank journals (BMO, RBC, RBC VISA, Scotia ×3, Bank, Cash). Audit; archive inactive. Target: ≤5 active (primary operating, USD for future global, LOC, 1–2 credit cards).
|
||||
|
||||
### Phase 5 — Lock Prior Periods
|
||||
Set `fiscalyear_lock_date = 2025-12-31`. Blocks postings to closed periods. Forces all 2026 work into new structure.
|
||||
|
||||
## 13. Automation Hooks
|
||||
|
||||
### Product Categories with Default Accounts
|
||||
|
||||
| Product Category | Default Income | Default COGS | Default Tax |
|
||||
|---|---|---|---|
|
||||
| Services / SaaS Subscription | 411100 | — | per fiscal position |
|
||||
| Services / Hosting | 411200 | — | per fiscal position |
|
||||
| Services / Support Contract | 411300 | — | per fiscal position |
|
||||
| Services / Custom Software Dev | 412100 | — | per fiscal position |
|
||||
| Services / Web App Dev | 412200 | — | per fiscal position |
|
||||
| Services / Website Dev | 412300 | — | per fiscal position |
|
||||
| Services / ERP Implementation | 412400 | — | per fiscal position |
|
||||
| Services / Consulting | 413100 | — | per fiscal position |
|
||||
| Services / Training | 413200 | — | per fiscal position |
|
||||
| Services / Setup Fee | 411500 | — | per fiscal position |
|
||||
| Resale / Software | 414100 | 513100 | per fiscal position |
|
||||
| Resale / Hardware | 414200 | 513200 | per fiscal position |
|
||||
|
||||
### Bank Reconciliation Rules
|
||||
|
||||
| Pattern (description contains) | Auto-categorize To | Tax |
|
||||
|---|---|---|
|
||||
| `AMAZON WEB SERVICES`, `AWS` | 511100 Cloud Infrastructure | HST 13% ITC |
|
||||
| `HETZNER`, `OVH`, `DIGITALOCEAN`, `LINODE` | 511100 | 0% foreign |
|
||||
| `CLOUDFLARE`, `FASTLY` | 511110 CDN | mixed |
|
||||
| `GITHUB`, `JETBRAINS`, `CURSOR` | 631200 Software — Dev Tools | HST 13% ITC |
|
||||
| `MICROSOFT`, `SLACK`, `NOTION`, `LINEAR` | 631100 Software — Productivity | HST 13% ITC |
|
||||
| `STRIPE PAYOUT` | AR receipts journal | — |
|
||||
| `STRIPE FEE` | 691200 Merchant Processing | exempt |
|
||||
| `GOOGLE ADS`, `LINKEDIN ADS` | 641100 Advertising | HST 13% ITC |
|
||||
|
||||
### Bank Feeds (Plaid via Odoo Enterprise)
|
||||
Daily auto-import → bank reconciliation rules → ~70% of transactions auto-categorized.
|
||||
|
||||
### Subscription Module
|
||||
Already installed. Use for SaaS/Hosting/Support contracts: recurring invoices, Stripe auto-charge, MRR/ARR/churn dashboards.
|
||||
|
||||
### Default Journals
|
||||
- Customer Invoices → `INV`
|
||||
- Vendor Bills → `BILL`
|
||||
- Bank feeds → respective bank journals
|
||||
- HR Expenses → `EXP` (add if missing)
|
||||
- Misc → `MISC`
|
||||
- Exchange Difference → `EXCH`
|
||||
|
||||
## 14. Out-of-Scope (Future Sub-Projects)
|
||||
|
||||
- **Historical reconciliation** — load accountant's Excel records into new structure (requires accountant docs).
|
||||
- **Custom CCA module** — only if asset count grows; until then, accountant maintains CCA schedule separately.
|
||||
- **Multi-currency setup** — add USD bank + currency-rate-live config when first US client signs.
|
||||
- **Payroll system** — when first T4 employee is hired; integrate with Wagepoint/Payworks/ADP or Odoo Payroll.
|
||||
- **Approval workflows** — purchase approval, expense approval limits.
|
||||
- **Inventory** — N/A unless reselling hardware regularly.
|
||||
|
||||
## 15. Tax-Saving Opportunities Enabled
|
||||
|
||||
| Opportunity | Mechanism | Estimated Annual Value | Notes |
|
||||
|---|---|---|---|
|
||||
| SR&ED ITC | Analytic SR&ED tag + T661 filing | $30k–$100k (refundable) | **$3M expenditure limit SHARED across Nexa/Westin/Divine — allocate to Nexa via S23** |
|
||||
| Zero-rated exports | Fiscal position for US/international | $5–15k recovered HST on inputs | Per-company |
|
||||
| Small Business Deduction (SBD) | Federal 9% on first $500k taxable income | ~$30k/yr if hitting threshold | **$500k limit SHARED across associated group — allocate to highest-income corp via S23** |
|
||||
| CCA Class 50 + AccII | 82.5% Y1 deduction on computers/servers | Time-value, front-loads deductions | Per-company |
|
||||
| Quick Method GST/HST | If <$400k sales, simpler method | $500–2k/yr cash if eligible | **LIKELY UNAVAILABLE — Quick Method $400k threshold applies to associated-group totals; Nexa + Westin + Divine combined revenue probably exceeds limit. Re-verify with accountant.** |
|
||||
| OIDMTC (Ontario Interactive Digital Media) | If building interactive media products | 35–40% of eligible labour | Strict eligibility test; need to verify product fits |
|
||||
| Apprenticeship Job Creation TC | 10% of eligible apprentice wages, max $2k/yr per apprentice | Per apprentice hired | Activates when first apprentice T4 employee hired |
|
||||
| Intercompany cost recovery | Bill associated corps for shared services (back-office, hosting, IT) | Allocates expenses to highest-tax-rate corp | Requires arm's-length pricing documentation |
|
||||
|
||||
## 16. Risks & Open Questions
|
||||
|
||||
1. **Associated corporation tax planning** — Westin Healthcare Inc, Divine Mobility Inc, and Nexa Systems Inc share the $500k SBD limit and the $3M SR&ED expenditure limit. Yearly Schedule 23 allocation decision needs accountant input. Recommendation: allocate SR&ED limit primarily to Nexa (dev shop); allocate SBD to whichever corp has highest taxable income each year.
|
||||
2. **Transfer pricing on intercompany services** — Nexa billing Westin/Divine must be at fair market value. Document hourly rate methodology and apply consistently across all clients. Penalty: 10% of any adjustment.
|
||||
3. **Past data backposting** — once accountant records arrive, mapping old transactions into new structure requires care to avoid breaking the post-2025-12-31 lock.
|
||||
4. **BC PST on software services** — BC PST exempts custom software developed for a specific customer; off-the-shelf software and certain SaaS subscriptions ARE taxable. For Nexa's mix (most work is custom dev = exempt; SaaS sold off-the-shelf to BC customers = taxable at 7%), each BC customer/product combo needs review. Default to "GST only" for custom dev; flag SaaS-to-BC for review at first sale.
|
||||
5. **Quebec QST registration** — required if Nexa has QC customers and revenue >$30k. Confirm registration status. If not yet registered and you start taking QC clients, registration with Revenu Québec is separate from CRA.
|
||||
8. **HST filing cadence review** — currently annual. Once revenue clears $1.5M (combined Nexa-only, not associated group), CRA may auto-move you to **quarterly** filing. Monitor and update filing cadence in tax report config when it happens.
|
||||
6. **Specified employee SR&ED math** — Gurpreet's salary cap is 75%, no bonus inclusion. Accountant must apply at T661 time.
|
||||
7. **Multi-company Odoo (future sub-project)** — Westin and Divine currently run on separate Odoo databases (odoo-westin, odoo-mobility). Future option: migrate all three into one multi-company nexamain database to enable auto-mirrored intercompany invoices (Nexa invoices Westin → auto-creates Bill in Westin's books). Major data-migration effort; only worth it once intercompany volume justifies the effort.
|
||||
|
||||
## 17. Acceptance Criteria
|
||||
|
||||
- [ ] All 11 sections of CoA approved and present in odoo-nexa nexamain DB
|
||||
- [ ] ≥370 unused accounts archived
|
||||
- [ ] 14 active taxes (down from 49)
|
||||
- [ ] 8 fiscal positions configured with auto-detection
|
||||
- [ ] 3 analytic plans created (Project, Department, SR&ED Tag) with seed analytic accounts
|
||||
- [ ] Product categories created with default accounts
|
||||
- [ ] Bank reconciliation rules created
|
||||
- [ ] Fiscal year locked at 2025-12-31
|
||||
- [ ] Company HST/BN number stored in full 15-char form (`741224877 RT0001`)
|
||||
- [ ] HST report config set to **annual filer**, fiscal-year-end Dec 31, deadline March 31
|
||||
- [ ] Westin Healthcare Inc and Divine Mobility Inc partner records created with Customer + Vendor flags, tagged `RP-Associated`
|
||||
- [ ] Test invoice flows through correctly for: ON customer (HST 13%), US customer (Zero-rated), QC customer (GST+QST)
|
||||
- [ ] Test vendor bill creates correct ITC for: Canadian vendor (HST ITC), foreign vendor (no ITC)
|
||||
- [ ] Test intercompany invoice: Nexa → Westin generates proper AR + 13% HST collected (Westin is Ontario-based)
|
||||
- [ ] Bank consolidation complete; ≤5 active bank journals
|
||||
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# NFC Clock Kiosk — Design
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design — pending implementation plan
|
||||
**Pilot scope:** 1 station per company
|
||||
|
||||
## Problem
|
||||
|
||||
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
|
||||
|
||||
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
|
||||
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
|
||||
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
|
||||
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
|
||||
2. **Single-credential**: same card the employee uses for door access also clocks them in
|
||||
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
|
||||
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
|
||||
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
|
||||
6. **One-time setup**: enroll once, then employees never touch a setup flow again
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
|
||||
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
|
||||
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
|
||||
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
|
||||
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
|
||||
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
|
||||
|
||||
## Architecture decision
|
||||
|
||||
**Option B: Separate kiosk page, shared backend.**
|
||||
|
||||
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
|
||||
|
||||
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
|
||||
|
||||
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
|
||||
|
||||
## Hardware decision
|
||||
|
||||
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
|
||||
|
||||
- Built-in NFC antenna on the back, dead-center
|
||||
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
|
||||
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
|
||||
- Knox enables true kiosk lockdown
|
||||
- Pogo dock = magnetic constant power, no cable to yank
|
||||
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
|
||||
|
||||
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
|
||||
|
||||
## Data model
|
||||
|
||||
### `hr.employee` — new field
|
||||
- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null
|
||||
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
|
||||
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
|
||||
|
||||
### `res.company` — new field
|
||||
- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location`
|
||||
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
|
||||
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
|
||||
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
|
||||
|
||||
### `hr.attendance` — new fields
|
||||
- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in.
|
||||
- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out.
|
||||
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
|
||||
|
||||
### `ir.config_parameter` — new entries
|
||||
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
|
||||
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
|
||||
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
|
||||
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
|
||||
|
||||
### `res.config.settings` — new view section
|
||||
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
|
||||
|
||||
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
|
||||
|
||||
## Backend — controller and endpoints
|
||||
|
||||
**New file:** `controllers/clock_nfc_kiosk.py`
|
||||
|
||||
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
|
||||
|
||||
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
|
||||
|
||||
### `GET /fusion_clock/kiosk/nfc` — page render
|
||||
- Renders the NFC kiosk QWeb template
|
||||
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
|
||||
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
|
||||
- Logic:
|
||||
1. Normalize UID (uppercase, colon-separated, reject malformed input)
|
||||
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
|
||||
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
|
||||
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
|
||||
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
|
||||
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
|
||||
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
|
||||
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
|
||||
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
|
||||
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
|
||||
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
|
||||
- Logic:
|
||||
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
|
||||
2. Normalize UID
|
||||
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
|
||||
4. Write `x_fclk_nfc_card_uid` on the target employee
|
||||
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
|
||||
6. Return `{ success: true, employee_name, card_uid }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
|
||||
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
|
||||
|
||||
## Frontend — kiosk page UX
|
||||
|
||||
**Files:**
|
||||
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
|
||||
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
|
||||
|
||||
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
┌─── (3s timeout) ─────────────────────────┐
|
||||
▼ │
|
||||
┌─────────────────────────┐ tap detected ┌────────────────────┐
|
||||
│ IDLE │ ────────────────► │ PROCESSING │
|
||||
│ "Tap card to clock │ │ spinner, "Reading"│
|
||||
│ in or out" │ └────────────────────┘
|
||||
│ big clock, date, │ │
|
||||
│ company name │ success / error
|
||||
└─────────────────────────┘ ▼
|
||||
▲ ┌─────────────────────────┐
|
||||
│ │ RESULT │
|
||||
│ │ green: "Welcome John, │
|
||||
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
|
||||
│ red: "Card not │
|
||||
│ enrolled" │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### IDLE state
|
||||
- Top: company name + current time (HH:MM, updates every second) + date
|
||||
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
|
||||
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
|
||||
|
||||
### PROCESSING state
|
||||
- Brief spinner + "Reading card…"
|
||||
- Mostly imperceptible at typical network latency
|
||||
|
||||
### RESULT state — success
|
||||
- Green panel
|
||||
- Large employee avatar on the left
|
||||
- "John Smith" — name in big text
|
||||
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
|
||||
- Auto-return to IDLE after 3s
|
||||
|
||||
### RESULT state — error
|
||||
- Red panel
|
||||
- `card_unknown` → "Card not recognized. See your manager."
|
||||
- `network_error` → "No connection. Please try again."
|
||||
- `debounce` → silent (no UI change to avoid double-tap confusion)
|
||||
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
|
||||
- Auto-return to IDLE after 4s
|
||||
|
||||
### Web NFC implementation
|
||||
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
|
||||
- After activation, `NDEFReader.scan()` runs continuously
|
||||
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
|
||||
- UID format: hex bytes joined by colons, uppercased
|
||||
- If `scan()` throws, restart with a 1-second backoff
|
||||
|
||||
### Camera implementation
|
||||
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
|
||||
- Hidden `<video>` element streams continuously
|
||||
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~30–60 KB), POST as base64 in the same JSON payload as the UID
|
||||
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
|
||||
|
||||
### Enroll Mode
|
||||
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
|
||||
- Enroll Mode UI:
|
||||
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
|
||||
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
|
||||
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
|
||||
4. "Done" button → exit Enroll Mode → back to IDLE
|
||||
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
|
||||
|
||||
### One-time setup flow (first load on a new tablet)
|
||||
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
|
||||
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
|
||||
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
|
||||
4. "Setup complete." → enters IDLE
|
||||
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
|
||||
|
||||
### Mock-tap debug mode
|
||||
- Gated by `fusion_clock.nfc_kiosk_debug = True`
|
||||
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
|
||||
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
|
||||
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
|
||||
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
|
||||
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
|
||||
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
|
||||
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
|
||||
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
|
||||
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
|
||||
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
|
||||
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
|
||||
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
|
||||
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
|
||||
|
||||
## Hardware checklist (per company)
|
||||
|
||||
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
|
||||
- Samsung official Pogo charging dock — ~$100
|
||||
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
|
||||
- USB-C 30W PSU + cable — ~$25
|
||||
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
|
||||
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
|
||||
|
||||
**Total**: ~$915 per company, one-time.
|
||||
|
||||
## Provisioning script (one-time per tablet)
|
||||
|
||||
**Prerequisite — Odoo side (one-time per company):**
|
||||
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
|
||||
- Generate a long random password; store it in a password manager
|
||||
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
|
||||
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
|
||||
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
|
||||
|
||||
**Tablet side:**
|
||||
1. Factory reset
|
||||
2. Sign in with company Google account
|
||||
3. Install Fully Kiosk Browser from Play Store
|
||||
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
|
||||
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
|
||||
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
|
||||
7. Mount on dock
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
|
||||
- Tap with valid UID → attendance toggled, photo saved, activity logged
|
||||
- Tap with unknown UID → `card_unknown` error, no attendance row
|
||||
- Tap when `x_fclk_enable_clock=False` → `clock_disabled` error
|
||||
- Double-tap same UID within 5s → second is debounced
|
||||
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
|
||||
- Enroll with wrong password → 403
|
||||
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
|
||||
- UID normalization: lowercase input → stored uppercase
|
||||
|
||||
### Manual smoke tests (real tablet or Android phone for dev)
|
||||
- Cold boot → IDLE within 5s
|
||||
- Tap → RESULT within 1s
|
||||
- Photo attached to attendance record (verify in backend)
|
||||
- Enroll Mode password gate works; 60s timeout exits cleanly
|
||||
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
|
||||
- Tap own card 5x in fast succession → only one state change (debounce holds)
|
||||
|
||||
### Dev shortcut
|
||||
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
|
||||
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
|
||||
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
|
||||
|
||||
### Soak test (before declaring pilot ready)
|
||||
- 24h continuous on the dock
|
||||
- Periodic taps every few hours
|
||||
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
|
||||
|
||||
## Future considerations
|
||||
|
||||
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
|
||||
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
|
||||
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
|
||||
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
|
||||
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -76,12 +76,15 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_clock/static/src/css/portal_clock.css',
|
||||
'fusion_clock/static/src/scss/nfc_kiosk.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
from . import portal_clock
|
||||
from . import clock_api
|
||||
from . import clock_kiosk
|
||||
from . import clock_nfc_kiosk
|
||||
|
||||
249
fusion_clock/controllers/clock_nfc_kiosk.py
Normal file
249
fusion_clock/controllers/clock_nfc_kiosk.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
||||
|
||||
_DEBOUNCE_WINDOW_SECONDS = 5.0
|
||||
_recent_taps = {} # {card_uid: monotonic_ts}
|
||||
_recent_taps_lock = threading.Lock()
|
||||
|
||||
|
||||
def _is_debounced(uid):
|
||||
"""Return True if this UID was tapped within the debounce window."""
|
||||
now = time.monotonic()
|
||||
with _recent_taps_lock:
|
||||
last = _recent_taps.get(uid, 0)
|
||||
if now - last < _DEBOUNCE_WINDOW_SECONDS:
|
||||
return True
|
||||
_recent_taps[uid] = now
|
||||
# Opportunistic GC: drop entries older than 60s
|
||||
stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
|
||||
for k in stale_keys:
|
||||
_recent_taps.pop(k, None)
|
||||
return False
|
||||
|
||||
|
||||
def _strip_data_url_prefix(b64):
|
||||
"""Strip 'data:image/...;base64,' prefix from a data URL, returning raw base64."""
|
||||
if not b64:
|
||||
return b''
|
||||
if isinstance(b64, str) and b64.startswith('data:'):
|
||||
comma = b64.find(',')
|
||||
if comma >= 0:
|
||||
return b64[comma + 1:].encode('ascii', errors='ignore')
|
||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||
|
||||
|
||||
class FusionClockNfcKiosk(http.Controller):
|
||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_uid(uid):
|
||||
"""Normalize an NFC card UID to canonical hex (uppercase, colon-separated).
|
||||
|
||||
Returns None if the input is empty or not valid hex.
|
||||
"""
|
||||
if not uid:
|
||||
return None
|
||||
cleaned = uid.strip().upper().replace('-', '').replace(':', '').replace(' ', '')
|
||||
if not cleaned or not _UID_HEX_PATTERN.match(cleaned):
|
||||
return None
|
||||
if len(cleaned) % 2 != 0:
|
||||
return None
|
||||
return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2))
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc', type='http', auth='user', website=True)
|
||||
def nfc_kiosk_page(self, **kw):
|
||||
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
company = request.env.company
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
company_logo_url = (
|
||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||
)
|
||||
values = {
|
||||
'page_name': 'nfc_kiosk',
|
||||
'company_name': company.name,
|
||||
'company_logo_url': company_logo_url,
|
||||
'location_name': location.name if location else 'No location configured',
|
||||
'location_configured': bool(location),
|
||||
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||
|
||||
@staticmethod
|
||||
def _check_enroll_password(env, supplied):
|
||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||
configured = env['ir.config_parameter'].sudo().get_param('fusion_clock.nfc_enroll_password', '')
|
||||
if not configured:
|
||||
return True
|
||||
return (supplied or '') == configured
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
|
||||
normalized = self._normalize_uid(card_uid)
|
||||
if not normalized:
|
||||
return {'error': 'invalid_uid'}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
target = Employee.browse(int(employee_id or 0))
|
||||
if not target.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
|
||||
existing = Employee.search([
|
||||
('x_fclk_nfc_card_uid', '=', normalized),
|
||||
('id', '!=', target.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
|
||||
target.x_fclk_nfc_card_uid = normalized
|
||||
|
||||
# Activity log (uses 'card_enrollment' + 'nfc_kiosk' selections added in Task 2)
|
||||
request.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': target.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'description': f"NFC card {normalized} enrolled by {user.name}",
|
||||
'source': 'nfc_kiosk',
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_name': target.name,
|
||||
'card_uid': normalized,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||
"""Toggle attendance state for the employee owning this card UID."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return {'error': 'kiosk_disabled'}
|
||||
|
||||
normalized = self._normalize_uid(card_uid)
|
||||
if not normalized:
|
||||
return {'error': 'invalid_uid'}
|
||||
|
||||
if _is_debounced(normalized):
|
||||
return {'error': 'debounce'}
|
||||
|
||||
photo_required = ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
|
||||
if photo_required and not photo_b64:
|
||||
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
||||
|
||||
company = request.env.company
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
employee = Employee.search([('x_fclk_nfc_card_uid', '=', normalized)], limit=1)
|
||||
if not employee:
|
||||
_logger.warning("[nfc-kiosk] Unknown NFC card tapped: %s", normalized)
|
||||
return {'error': 'card_unknown', 'message': 'Card not enrolled. See your manager.'}
|
||||
|
||||
if not employee.x_fclk_enable_clock:
|
||||
return {'error': 'clock_disabled', 'message': 'Clock disabled for this account.'}
|
||||
|
||||
from .clock_api import FusionClockAPI
|
||||
api = FusionClockAPI()
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
'longitude': 0,
|
||||
'browser': 'nfc_kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or '',
|
||||
}
|
||||
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
|
||||
if not is_checked_in:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': 0.0,
|
||||
'x_fclk_clock_source': 'nfc_kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"NFC kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
'net_hours_today': 0.0,
|
||||
}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_distance': 0.0,
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'message': f'{employee.name} clocked out',
|
||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_employee_search(self, query='', **kw):
|
||||
"""Delegate to the existing kiosk search to avoid duplication."""
|
||||
from .clock_kiosk import FusionClockKiosk
|
||||
return FusionClockKiosk().kiosk_search(query=query)
|
||||
@@ -145,4 +145,22 @@
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- NFC Clock Kiosk -->
|
||||
<record id="config_enable_nfc_kiosk" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_nfc_kiosk</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
<record id="config_nfc_photo_required" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_photo_required</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_nfc_enroll_password" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_enroll_password</field>
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
<record id="config_nfc_kiosk_debug" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_kiosk_debug</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_correction
|
||||
from . import res_company
|
||||
|
||||
@@ -34,6 +34,8 @@ class FusionClockActivityLog(models.Model):
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
('card_enrollment', 'Card Enrollment'),
|
||||
('unknown_card_tap', 'Unknown Card Tap'),
|
||||
],
|
||||
string='Log Type',
|
||||
required=True,
|
||||
@@ -71,6 +73,7 @@ class FusionClockActivityLog(models.Model):
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('nfc_kiosk', 'NFC Kiosk'),
|
||||
('system', 'System (Cron)'),
|
||||
],
|
||||
string='Source',
|
||||
|
||||
@@ -130,6 +130,7 @@ class HrAttendance(models.Model):
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('nfc_kiosk', 'NFC Kiosk'),
|
||||
('manual', 'Manual'),
|
||||
('auto', 'Auto Clock-Out'),
|
||||
],
|
||||
@@ -147,6 +148,16 @@ class HrAttendance(models.Model):
|
||||
digits=(10, 2),
|
||||
help="Distance from location center at clock-out, in meters.",
|
||||
)
|
||||
x_fclk_check_in_photo = fields.Binary(
|
||||
string='Check-In Photo',
|
||||
attachment=True,
|
||||
help="Front-camera photo captured at NFC kiosk clock-in.",
|
||||
)
|
||||
x_fclk_check_out_photo = fields.Binary(
|
||||
string='Check-Out Photo',
|
||||
attachment=True,
|
||||
help="Front-camera photo captured at NFC kiosk clock-out.",
|
||||
)
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
|
||||
@@ -47,6 +47,25 @@ class HrEmployee(models.Model):
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
)
|
||||
|
||||
# NFC card (kiosk identification)
|
||||
x_fclk_nfc_card_uid = fields.Char(
|
||||
string='NFC Card UID',
|
||||
index=True,
|
||||
copy=False,
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
help="Hex UID of the NFC card assigned to this employee. "
|
||||
"Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
|
||||
"Same card the employee uses for door access.",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fclk_nfc_card_uid_unique',
|
||||
'UNIQUE(x_fclk_nfc_card_uid)',
|
||||
'This NFC card is already assigned to another employee.',
|
||||
),
|
||||
]
|
||||
|
||||
# On-time streak
|
||||
x_fclk_ontime_streak = fields.Integer(
|
||||
string='On-Time Streak',
|
||||
|
||||
17
fusion_clock/models/res_company.py
Normal file
17
fusion_clock/models/res_company.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
x_fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='NFC Kiosk Location',
|
||||
domain="[('company_id', '=', id)]",
|
||||
help="Designates which fusion.clock.location is bound to the NFC kiosk "
|
||||
"for this company. Required when NFC kiosk is enabled.",
|
||||
)
|
||||
@@ -232,6 +232,43 @@ class ResConfigSettings(models.TransientModel):
|
||||
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
|
||||
)
|
||||
|
||||
# ── NFC Clock Kiosk ────────────────────────────────────────────────
|
||||
fclk_enable_nfc_kiosk = fields.Boolean(
|
||||
string='Enable NFC Clock Kiosk',
|
||||
config_parameter='fusion_clock.enable_nfc_kiosk',
|
||||
default=False,
|
||||
help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
|
||||
)
|
||||
fclk_nfc_photo_required = fields.Boolean(
|
||||
string='Require Photo on Tap',
|
||||
config_parameter='fusion_clock.nfc_photo_required',
|
||||
default=True,
|
||||
help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
|
||||
"Recommended for buddy-punch deterrence.",
|
||||
)
|
||||
fclk_nfc_enroll_password = fields.Char(
|
||||
string='Enroll Mode Password',
|
||||
config_parameter='fusion_clock.nfc_enroll_password',
|
||||
help="Short password the manager types on the kiosk to enter Enroll Mode. "
|
||||
"Leave empty to fall back to manager-group membership only.",
|
||||
)
|
||||
fclk_nfc_kiosk_debug = fields.Boolean(
|
||||
string='Debug Mode (overlay + mock-tap)',
|
||||
config_parameter='fusion_clock.nfc_kiosk_debug',
|
||||
default=False,
|
||||
help="Enables two dev/troubleshooting features on the NFC kiosk page: "
|
||||
"(1) a green-text debug overlay at the top of the screen logging every NFC and tap event in real time, "
|
||||
"and (2) a Ctrl+Shift+T keyboard shortcut that simulates a tap with a configurable UID. "
|
||||
"Turn OFF in production — the overlay is intrusive for end users.",
|
||||
)
|
||||
fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||
related='company_id.x_fclk_nfc_kiosk_location_id',
|
||||
readonly=False,
|
||||
string='NFC Kiosk Location',
|
||||
help="Which clock location is bound to the NFC kiosk for this company. "
|
||||
"Required when the kiosk is enabled.",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
599
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
599
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
@@ -0,0 +1,599 @@
|
||||
/* @odoo-module */
|
||||
|
||||
// NFC Clock Kiosk — Web NFC + camera + state machine.
|
||||
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
|
||||
// element #nfc_kiosk_root only exists on that page, so the module is
|
||||
// inert elsewhere).
|
||||
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
const root = document.getElementById("nfc_kiosk_root");
|
||||
if (!root) return; // not on the kiosk page
|
||||
|
||||
const stateContainer = document.getElementById("nfc_state_container");
|
||||
const photoRequired = root.dataset.photoRequired === "1";
|
||||
const debugEnabled = root.dataset.debugEnabled === "1";
|
||||
const locationConfigured = root.dataset.locationConfigured === "1";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let _debugOverlayEl = null;
|
||||
function debugLog(msg) {
|
||||
try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {}
|
||||
if (!debugEnabled) return;
|
||||
if (!_debugOverlayEl) {
|
||||
_debugOverlayEl = document.createElement("div");
|
||||
_debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;";
|
||||
document.body.appendChild(_debugOverlayEl);
|
||||
}
|
||||
const line = document.createElement("div");
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
line.textContent = "[" + ts + "] " + msg;
|
||||
_debugOverlayEl.appendChild(line);
|
||||
while (_debugOverlayEl.childNodes.length > 40) {
|
||||
_debugOverlayEl.removeChild(_debugOverlayEl.firstChild);
|
||||
}
|
||||
_debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight;
|
||||
}
|
||||
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Dominant-hue extraction from company logo
|
||||
// Sets the CSS variable --nfc-h on <html> so SCSS can interpolate
|
||||
// the entire palette from the brand color. Falls back to default
|
||||
// (220 = aurora-blue) if no logo or extraction fails.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function rgbToHue(r, g, b) {
|
||||
const rN = r / 255, gN = g / 255, bN = b / 255;
|
||||
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
|
||||
const d = max - min;
|
||||
if (d === 0) return null; // grayscale, no hue info
|
||||
let h;
|
||||
if (max === rN) h = ((gN - bN) / d) % 6;
|
||||
else if (max === gN) h = (bN - rN) / d + 2;
|
||||
else h = (rN - gN) / d + 4;
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function extractDominantHue(img) {
|
||||
try {
|
||||
const c = document.createElement("canvas");
|
||||
const w = c.width = Math.min(img.naturalWidth, 200);
|
||||
const h = c.height = Math.min(img.naturalHeight, 200);
|
||||
const ctx = c.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < 128) continue; // skip transparent
|
||||
const red = data[i], green = data[i + 1], blue = data[i + 2];
|
||||
const lum = (red + green + blue) / 3;
|
||||
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
|
||||
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
|
||||
if (range < 25) continue; // skip near-grays
|
||||
r += red; g += green; b += blue; count++;
|
||||
}
|
||||
if (count < 50) {
|
||||
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
|
||||
return null;
|
||||
}
|
||||
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
|
||||
const hue = rgbToHue(avgR, avgG, avgB);
|
||||
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
|
||||
return hue;
|
||||
} catch (e) {
|
||||
debugLog("hue extraction failed: " + e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrandHue(hue) {
|
||||
if (hue == null) return;
|
||||
document.documentElement.style.setProperty("--nfc-h", String(hue));
|
||||
}
|
||||
|
||||
const logoImg = document.getElementById("nfc_company_logo");
|
||||
if (logoImg) {
|
||||
const tryExtract = () => {
|
||||
const hue = extractDominantHue(logoImg);
|
||||
applyBrandHue(hue);
|
||||
};
|
||||
if (logoImg.complete && logoImg.naturalWidth) {
|
||||
tryExtract();
|
||||
} else {
|
||||
logoImg.addEventListener("load", tryExtract);
|
||||
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
|
||||
}
|
||||
} else {
|
||||
debugLog("no company logo on page; using default hue");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// State machine
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
|
||||
let currentState = STATE.SETUP;
|
||||
|
||||
function setState(next, payload) {
|
||||
currentState = next;
|
||||
if (next === STATE.IDLE) renderIdle();
|
||||
else if (next === STATE.PROCESSING) renderProcessing();
|
||||
else if (next === STATE.RESULT) renderResult(payload);
|
||||
else if (next === STATE.ENROLL) renderEnroll(payload);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Rendering helpers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function renderIdle() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__idle">
|
||||
<svg class="nfc-kiosk__icon-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="nfc-wave nfc-wave-3" cx="100" cy="100" r="98"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-2" cx="100" cy="100" r="78"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
|
||||
rx="11" fill="currentColor"/>
|
||||
</svg>
|
||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProcessing() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__processing">
|
||||
<span>Reading card</span>
|
||||
<span class="dots"><span></span><span></span><span></span></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const isError = payload && payload.error;
|
||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||
|
||||
if (isError) {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.message || "Error")}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 4000);
|
||||
} else {
|
||||
const avatar = payload.employee_avatar_url || "";
|
||||
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
||||
const hours = payload.action === "clock_out" && payload.net_hours_today
|
||||
? `${payload.net_hours_today.toFixed(1)}h today`
|
||||
: "";
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
||||
<div class="action">${action} at ${time}</div>
|
||||
${hours ? `<div class="hours">${hours}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Enroll Mode
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let enrollPassword = "";
|
||||
let enrollSelectedEmployee = null;
|
||||
let enrollIdleTimer = null;
|
||||
|
||||
function resetEnrollIdleTimer() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = setTimeout(() => {
|
||||
// 60s of inactivity in Enroll Mode → exit
|
||||
exitEnrollMode();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function exitEnrollMode() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = null;
|
||||
enrollPassword = "";
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
function renderEnroll(payload) {
|
||||
const phase = (payload && payload.phase) || "password";
|
||||
resetEnrollIdleTimer();
|
||||
|
||||
if (phase === "password") {
|
||||
const masked = "•".repeat(enrollPassword.length);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Enter Enroll Mode Password</h2>
|
||||
<div class="pin-display">${masked}</div>
|
||||
<div class="numpad">
|
||||
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
||||
<button data-n="back">⌫</button>
|
||||
<button data-n="0">0</button>
|
||||
<button data-n="ok">OK</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
resetEnrollIdleTimer();
|
||||
const n = btn.dataset.n;
|
||||
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
|
||||
else if (n === "ok") {
|
||||
if (enrollPassword.length === 0) return;
|
||||
renderEnroll({ phase: "search" });
|
||||
return;
|
||||
}
|
||||
else enrollPassword += n;
|
||||
renderEnroll({ phase: "password" });
|
||||
});
|
||||
});
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "search") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Pick the employee to enroll</h2>
|
||||
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
|
||||
<div class="employee-list" id="enroll_list"></div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const searchEl = document.getElementById("enroll_search");
|
||||
const listEl = document.getElementById("enroll_list");
|
||||
let debounceTimer = null;
|
||||
searchEl.addEventListener("input", () => {
|
||||
resetEnrollIdleTimer();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
|
||||
listEl.innerHTML = (result.employees || []).map(e =>
|
||||
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
|
||||
).join("");
|
||||
listEl.querySelectorAll(".employee-row").forEach(row => {
|
||||
row.addEventListener("click", () => {
|
||||
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
|
||||
renderEnroll({ phase: "tap" });
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
});
|
||||
searchEl.focus();
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "tap") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
|
||||
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
|
||||
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "result") {
|
||||
const ok = !payload.error;
|
||||
const msg = ok
|
||||
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
: (payload.error === "invalid_password"
|
||||
? "Wrong password. Try again."
|
||||
: payload.error === "card_already_assigned"
|
||||
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
|
||||
: `Enroll failed: ${escapeHtml(payload.error)}`);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="confirm" id="enroll_another">Enroll another</button>
|
||||
<button class="cancel" id="enroll_done">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("enroll_another").addEventListener("click", () => {
|
||||
enrollSelectedEmployee = null;
|
||||
renderEnroll({ phase: ok ? "search" : "password" });
|
||||
});
|
||||
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function _onEnrollTap(uid) {
|
||||
if (!enrollSelectedEmployee) return;
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
||||
employee_id: enrollSelectedEmployee.id,
|
||||
card_uid: uid,
|
||||
enroll_password: enrollPassword,
|
||||
});
|
||||
renderEnroll({ phase: "result", ...result });
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode
|
||||
const settingsBtn = document.getElementById("nfc_settings_btn");
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener("click", () => {
|
||||
if (currentState !== STATE.IDLE) return;
|
||||
enrollPassword = "";
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.ENROLL, { phase: "password" });
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Clock display (centered top: time with AM/PM + date)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
let hours = now.getHours();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
hours = hours % 12;
|
||||
if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock
|
||||
const hh = String(hours).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||
const timeEl = document.getElementById("nfc_clock_time");
|
||||
const dateEl = document.getElementById("nfc_clock_date");
|
||||
if (timeEl) {
|
||||
// Render hh:mm + AM/PM as separate spans so SCSS can style them differently
|
||||
timeEl.innerHTML = `${hh}:${mm}<span class="ampm">${ampm}</span>`;
|
||||
}
|
||||
if (dateEl) dateEl.textContent = dateStr;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Web NFC reader
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let ndefReader = null;
|
||||
let nfcReady = false;
|
||||
|
||||
async function startNfcReader() {
|
||||
debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window));
|
||||
if (!("NDEFReader" in window)) {
|
||||
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
|
||||
}
|
||||
ndefReader = new NDEFReader();
|
||||
debugLog("startNfcReader: ndefReader created, calling scan()...");
|
||||
await ndefReader.scan();
|
||||
debugLog("startNfcReader: scan() resolved ✓");
|
||||
ndefReader.addEventListener("reading", onNfcReading);
|
||||
ndefReader.addEventListener("readingerror", (ev) => {
|
||||
debugLog("readingerror event fired");
|
||||
console.warn("[nfc-kiosk] reading error; reader still active");
|
||||
});
|
||||
nfcReady = true;
|
||||
debugLog("startNfcReader: listeners attached, nfcReady=true");
|
||||
}
|
||||
|
||||
function onNfcReading(event) {
|
||||
// event.serialNumber is the card UID — works for raw MIFARE access cards
|
||||
const rawSerial = event.serialNumber || "";
|
||||
const uid = rawSerial.toUpperCase();
|
||||
const recCount = (event.message && event.message.records) ? event.message.records.length : 0;
|
||||
debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState);
|
||||
if (!uid) {
|
||||
debugLog(" → IGNORED: empty serialNumber");
|
||||
return;
|
||||
}
|
||||
if (currentState === STATE.ENROLL) {
|
||||
debugLog(" → routing to _onEnrollTap");
|
||||
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
|
||||
return;
|
||||
}
|
||||
if (currentState !== STATE.IDLE) {
|
||||
debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")");
|
||||
return;
|
||||
}
|
||||
debugLog(" → calling handleTap(" + uid + ")");
|
||||
handleTap(uid);
|
||||
}
|
||||
|
||||
async function handleTap(uid) {
|
||||
debugLog("handleTap: uid=" + uid);
|
||||
setState(STATE.PROCESSING);
|
||||
let photoB64 = "";
|
||||
try {
|
||||
photoB64 = await capturePhoto();
|
||||
debugLog("handleTap: photo captured, size=" + photoB64.length);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: photo capture failed: " + e.message);
|
||||
console.warn("[nfc-kiosk] camera capture failed", e);
|
||||
}
|
||||
try {
|
||||
debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap...");
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
|
||||
debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200));
|
||||
if (result.error === "debounce") {
|
||||
setState(STATE.IDLE);
|
||||
return;
|
||||
}
|
||||
setState(STATE.RESULT, result);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: POST failed: " + e.message);
|
||||
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url, params) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
|
||||
});
|
||||
const json = await res.json();
|
||||
return json.result || {};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Camera
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let cameraStream = null;
|
||||
const videoEl = document.getElementById("nfc_camera_feed");
|
||||
const canvasEl = document.getElementById("nfc_camera_canvas");
|
||||
|
||||
async function startCamera() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera not supported on this browser/device.");
|
||||
}
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
|
||||
audio: false,
|
||||
});
|
||||
videoEl.srcObject = cameraStream;
|
||||
await videoEl.play();
|
||||
}
|
||||
|
||||
async function capturePhoto() {
|
||||
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
|
||||
const w = videoEl.videoWidth;
|
||||
const h = videoEl.videoHeight;
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
const ctx = canvasEl.getContext("2d");
|
||||
ctx.drawImage(videoEl, 0, 0, w, h);
|
||||
return canvasEl.toDataURL("image/jpeg", 0.7);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Wake Lock — keeps the screen on while the kiosk page is active.
|
||||
// Released automatically on tab close/navigation; re-acquired on
|
||||
// visibilitychange when the page comes back to the foreground.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let wakeLock = null;
|
||||
|
||||
async function acquireWakeLock() {
|
||||
if (!("wakeLock" in navigator)) {
|
||||
debugLog("wakeLock: API not supported on this browser");
|
||||
return;
|
||||
}
|
||||
if (wakeLock) {
|
||||
debugLog("wakeLock: already held, skipping");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
debugLog("wakeLock: acquired ✓ (screen will stay on)");
|
||||
wakeLock.addEventListener("release", () => {
|
||||
debugLog("wakeLock: released by browser/OS");
|
||||
wakeLock = null;
|
||||
});
|
||||
} catch (e) {
|
||||
debugLog("wakeLock: request failed: " + (e && e.message));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
debugLog("visibility: visible — re-acquiring wakeLock");
|
||||
await acquireWakeLock();
|
||||
} else {
|
||||
debugLog("visibility: " + document.visibilityState);
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard activation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const setupBtn = document.getElementById("nfc_setup_start");
|
||||
if (setupBtn) {
|
||||
setupBtn.addEventListener("click", async () => {
|
||||
debugLog("setup button clicked");
|
||||
try {
|
||||
await startNfcReader();
|
||||
debugLog("setup: NFC ready, starting camera...");
|
||||
try {
|
||||
await startCamera();
|
||||
debugLog("setup: camera ready ✓");
|
||||
} catch (camErr) {
|
||||
debugLog("setup: camera failed: " + camErr.message);
|
||||
if (photoRequired) throw camErr;
|
||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||
}
|
||||
await acquireWakeLock();
|
||||
setState(STATE.IDLE);
|
||||
} catch (e) {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__setup">
|
||||
<h2 style="color:#d9374e">Setup failed</h2>
|
||||
<p>${escapeHtml(e.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
if (debugEnabled) {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
|
||||
e.preventDefault();
|
||||
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
|
||||
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
|
||||
if (!uid) return;
|
||||
localStorage.setItem("nfc_mock_uid", uid);
|
||||
if (currentState === STATE.ENROLL) {
|
||||
_onEnrollTap(uid.toUpperCase());
|
||||
} else if (currentState === STATE.IDLE) {
|
||||
handleTap(uid.toUpperCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
|
||||
}
|
||||
|
||||
window.__nfcKiosk = {
|
||||
setState, STATE, photoRequired, debugEnabled, locationConfigured,
|
||||
handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
|
||||
};
|
||||
})();
|
||||
593
fusion_clock/static/src/scss/nfc_kiosk.scss
Normal file
593
fusion_clock/static/src/scss/nfc_kiosk.scss
Normal file
@@ -0,0 +1,593 @@
|
||||
// NFC Clock Kiosk — premium glass + animated mesh, always-dark.
|
||||
//
|
||||
// CRITICAL: All styles in this file are scoped under `:has(#nfc_kiosk_root)`
|
||||
// to prevent leaking into other frontend pages. The previous version applied
|
||||
// `html,body { overflow:hidden; height:100vh }` and `header,footer{display:none}`
|
||||
// globally, which broke website scrolling and chrome on every frontend page.
|
||||
//
|
||||
// The single CSS custom property `--nfc-h` (hue, 0–360) is set by JS after
|
||||
// extracting the dominant color from the company logo. All colors interpolate
|
||||
// from that hue via HSL, so the entire palette adapts to the customer brand.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Defaults (overridden by JS once logo dominant-hue is extracted)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
:root {
|
||||
--nfc-h: 220; // fallback aurora-blue hue
|
||||
--nfc-bg: #0b0d10;
|
||||
--nfc-text: #ffffff;
|
||||
--nfc-text-muted: #9ba3ad;
|
||||
--nfc-success: #18a957;
|
||||
--nfc-error: #d9374e;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Page-level styling — ONLY when the kiosk is on the page
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
html:has(#nfc_kiosk_root) {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--nfc-bg) !important;
|
||||
color: var(--nfc-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
// Hide site chrome on the kiosk page only
|
||||
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Kiosk root container with animated mesh gradient background
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
background: var(--nfc-bg);
|
||||
|
||||
// Animated mesh gradient (drifts behind everything)
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -15%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, hsla(var(--nfc-h), 75%, 40%, 0.55) 0%, transparent 45%),
|
||||
radial-gradient(circle at 80% 20%, hsla(calc(var(--nfc-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 75%, hsla(calc(var(--nfc-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
|
||||
radial-gradient(circle at 15% 85%, hsla(calc(var(--nfc-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
|
||||
filter: blur(60px) saturate(140%);
|
||||
animation: nfc-mesh-drift 28s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
// Subtle vignette on top so edges don't feel washed out
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.45) 100%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> * { position: relative; z-index: 2; }
|
||||
}
|
||||
|
||||
@keyframes nfc-mesh-drift {
|
||||
0% { transform: translate(0%, 0%) rotate(0deg) scale(1); }
|
||||
50% { transform: translate(3%, -2%) rotate(2deg) scale(1.05); }
|
||||
100% { transform: translate(-3%, 3%) rotate(-1deg) scale(0.98); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Header chrome — logo, time, date, location, settings
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Logo centered at the top, on a subtle frosted-glass pill — just enough lift
|
||||
// to keep dark logos readable on the dark gradient.
|
||||
.nfc-kiosk__logo {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
box-sizing: content-box;
|
||||
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nfc-logo-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-12px) scale(0.94); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__company {
|
||||
position: absolute;
|
||||
top: 5.75rem;
|
||||
left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// When a logo is present, the company-name text is redundant — hide it.
|
||||
.nfc-kiosk__logo ~ .nfc-kiosk__company { display: none; }
|
||||
|
||||
// Clock + date stack vertically below the logo, all centered.
|
||||
.nfc-kiosk__time {
|
||||
position: absolute;
|
||||
top: 6.25rem; // sits below the logo (logo bottom ≈ 90px)
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
color: var(--nfc-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
|
||||
.ampm {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
}
|
||||
|
||||
.nfc-kiosk__date {
|
||||
position: absolute;
|
||||
top: 9.75rem; // sits below the time
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nfc-kiosk__location {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nfc-kiosk__settings {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: var(--nfc-text-muted);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 200ms ease, border-color 200ms ease, transform 200ms ease;
|
||||
|
||||
&:hover, &:active {
|
||||
color: var(--nfc-text);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reusable glass panel
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
%nfc-glass {
|
||||
background: rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// State container — base fade-in for whatever child renders
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
#nfc_state_container > * {
|
||||
animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes nfc-state-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// IDLE — large NFC icon + prompt
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__idle {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nfc-kiosk__icon-svg {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
overflow: visible; // let waves expand past viewBox without clipping
|
||||
color: hsl(var(--nfc-h), 80%, 65%);
|
||||
filter: drop-shadow(0 0 30px hsla(var(--nfc-h), 80%, 55%, 0.6));
|
||||
|
||||
.nfc-chip {
|
||||
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.nfc-wave {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box; // scale around the wave's own center, not viewBox origin
|
||||
opacity: 0;
|
||||
animation: nfc-wave-emit 2.5s ease-out infinite;
|
||||
}
|
||||
.nfc-wave-1 { animation-delay: 0s; }
|
||||
.nfc-wave-2 { animation-delay: 0.6s; }
|
||||
.nfc-wave-3 { animation-delay: 1.2s; }
|
||||
}
|
||||
|
||||
@keyframes nfc-chip-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes nfc-wave-emit {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
25% { opacity: 0.85; }
|
||||
80% { opacity: 0.25; }
|
||||
100% { transform: scale(1.35); opacity: 0; }
|
||||
}
|
||||
|
||||
.nfc-kiosk__prompt {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--nfc-text);
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// PROCESSING — pulsing dots
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__processing {
|
||||
@extend %nfc-glass;
|
||||
padding: 2.5rem 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--nfc-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
span {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--nfc-h), 80%, 65%);
|
||||
animation: nfc-dot-bounce 1.2s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||||
40% { transform: scale(1.0); opacity: 1.0; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// RESULT — glass card with avatar + name + action
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__result {
|
||||
@extend %nfc-glass;
|
||||
width: 80vw;
|
||||
max-width: 720px;
|
||||
padding: 2.5rem 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
|
||||
&--success {
|
||||
border-color: rgba(24,169,87,0.55);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 80px rgba(24,169,87,0.35),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: rgba(217,55,78,0.55);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 60px rgba(217,55,78,0.3),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-success-burst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 0 rgba(24,169,87,0),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 140px rgba(24,169,87,0.7),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 80px rgba(24,169,87,0.35),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-10px); }
|
||||
40% { transform: translateX(10px); }
|
||||
60% { transform: translateX(-6px); }
|
||||
80% { transform: translateX(6px); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__avatar {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: rgba(255,255,255,0.15);
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nfc-avatar-in {
|
||||
from { opacity: 0; transform: scale(0.4); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__result-text {
|
||||
flex: 1;
|
||||
|
||||
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; }
|
||||
.hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// SETUP wizard
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__setup {
|
||||
@extend %nfc-glass;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 3.5rem 3rem;
|
||||
|
||||
h2 { font-size: 2rem; margin-bottom: 1rem; font-weight: 300; letter-spacing: -0.01em; }
|
||||
p { color: var(--nfc-text-muted); margin-bottom: 2rem; line-height: 1.5; }
|
||||
|
||||
button {
|
||||
font-size: 1.25rem;
|
||||
padding: 1rem 2.5rem;
|
||||
background: hsl(var(--nfc-h), 80%, 55%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px hsla(var(--nfc-h), 80%, 50%, 0.4);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
|
||||
&:hover, &:active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 36px hsla(var(--nfc-h), 80%, 50%, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// ENROLL Mode overlay — glass panel with numpad / search / tap-prompt
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__enroll-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: nfc-overlay-in 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes nfc-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.nfc-kiosk__enroll-panel {
|
||||
@extend %nfc-glass;
|
||||
padding: 2.5rem;
|
||||
width: 80vw;
|
||||
max-width: 720px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--nfc-text);
|
||||
}
|
||||
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
|
||||
button {
|
||||
font-size: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
font-weight: 300;
|
||||
|
||||
&:hover { background: rgba(255,255,255,0.1); }
|
||||
&:active { background: rgba(255,255,255,0.15); transform: scale(0.96); }
|
||||
}
|
||||
}
|
||||
|
||||
.pin-display {
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
margin: 1rem 0 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 3rem;
|
||||
color: hsl(var(--nfc-h), 80%, 70%);
|
||||
}
|
||||
|
||||
.employee-search {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1.15rem;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: hsl(var(--nfc-h), 80%, 55%); }
|
||||
&::placeholder { color: var(--nfc-text-muted); }
|
||||
}
|
||||
|
||||
.employee-list {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.employee-row {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 150ms ease;
|
||||
|
||||
&:hover, &:active { background: rgba(255,255,255,0.06); }
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: transform 150ms ease, opacity 150ms ease;
|
||||
|
||||
&:hover, &:active { transform: scale(0.97); }
|
||||
}
|
||||
.cancel { background: rgba(255,255,255,0.08); color: var(--nfc-text); }
|
||||
.confirm {
|
||||
background: hsl(var(--nfc-h), 80%, 55%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px hsla(var(--nfc-h), 80%, 50%, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reduced-motion fallback — respect users who prefer no animation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nfc-kiosk::before { animation: none; }
|
||||
.nfc-kiosk__icon-svg .nfc-wave,
|
||||
.nfc-kiosk__icon-svg .nfc-chip,
|
||||
#nfc_state_container > *,
|
||||
.nfc-kiosk__logo,
|
||||
.nfc-kiosk__result--success,
|
||||
.nfc-kiosk__result--error,
|
||||
.nfc-kiosk__avatar { animation: none; }
|
||||
}
|
||||
4
fusion_clock/tests/__init__.py
Normal file
4
fusion_clock/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_nfc_models
|
||||
from . import test_clock_nfc_kiosk
|
||||
413
fusion_clock/tests/test_clock_nfc_kiosk.py
Normal file
413
fusion_clock/tests/test_clock_nfc_kiosk.py
Normal file
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcKioskController(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Test Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'NFC Kiosk User',
|
||||
'login': 'nfc-kiosk-test',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
|
||||
def test_kiosk_page_redirects_when_disabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
|
||||
self.authenticate('nfc-kiosk-test', 'kioskpass123')
|
||||
response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False)
|
||||
self.assertIn(response.status_code, (301, 302, 303))
|
||||
|
||||
def test_kiosk_page_renders_when_enabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
self.authenticate('nfc-kiosk-test', 'kioskpass123')
|
||||
response = self.url_open('/fusion_clock/kiosk/nfc')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('nfc_kiosk_root', response.text)
|
||||
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestUidNormalization(TransactionCase):
|
||||
|
||||
def test_lowercase_input_uppercased(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_no_separator_input_gets_colons(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04A2B562C180'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_dash_separator_replaced(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid(' 04:A2:B5:62:C1:80 '),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_empty_input_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(''))
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None))
|
||||
|
||||
def test_invalid_chars_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid'))
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80'))
|
||||
|
||||
def test_odd_length_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18'))
|
||||
|
||||
|
||||
import json
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestEnrollEndpoint(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Enroll Kiosk User',
|
||||
'login': 'nfc-kiosk-enroll',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True})
|
||||
cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True})
|
||||
|
||||
def _call(self, payload):
|
||||
self.authenticate('nfc-kiosk-enroll', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/enroll',
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_enroll_success(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': '04:a2:b5:62:c1:80',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80')
|
||||
self.alice.invalidate_recordset()
|
||||
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
|
||||
|
||||
def test_enroll_wrong_password(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': '04:A2:B5:62:C1:81',
|
||||
'enroll_password': 'wrong',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'invalid_password')
|
||||
self.alice.invalidate_recordset()
|
||||
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
|
||||
|
||||
def test_enroll_card_already_assigned(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82'
|
||||
result = self._call({
|
||||
'employee_id': self.bob.id,
|
||||
'card_uid': '04:A2:B5:62:C1:82',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'card_already_assigned')
|
||||
self.assertEqual(result.get('existing_employee'), 'Alice E')
|
||||
self.bob.invalidate_recordset()
|
||||
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
|
||||
|
||||
def test_enroll_invalid_uid(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': 'not-a-uid',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'invalid_uid')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapEndpointHappyPath(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Tap Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Tap Kiosk User',
|
||||
'login': 'nfc-kiosk-tap',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.alice = cls.env['hr.employee'].create({
|
||||
'name': 'Alice T',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Clear module-level debounce cache so tests don't inherit state from other classes
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
|
||||
def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''):
|
||||
self.authenticate('nfc-kiosk-tap', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'params': {'card_uid': card_uid, 'photo_b64': photo_b64},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_first_tap_clocks_in(self):
|
||||
result = self._tap()
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('action'), 'clock_in')
|
||||
self.assertEqual(result.get('employee_name'), 'Alice T')
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.alice.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance)
|
||||
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
|
||||
self.assertEqual(attendance.x_fclk_location_id, self.location)
|
||||
self.assertFalse(attendance.check_out)
|
||||
|
||||
def test_second_tap_clocks_out(self):
|
||||
self._tap()
|
||||
# Wait for debounce window (5s) to elapse
|
||||
import time
|
||||
time.sleep(6)
|
||||
result = self._tap()
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('action'), 'clock_out')
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.alice.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance.check_out)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapEndpointErrors(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Err Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Err Kiosk User',
|
||||
'login': 'nfc-kiosk-err',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.disabled_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Disabled E',
|
||||
'x_fclk_enable_clock': False,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD',
|
||||
})
|
||||
cls.active_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Active E',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Clear module-level debounce cache so tests don't bleed into each other
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
# Reset ICP to known-good defaults before each test
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
self.env.company.x_fclk_nfc_kiosk_location_id = self.location.id
|
||||
|
||||
def _tap(self, card_uid):
|
||||
self.authenticate('nfc-kiosk-err', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'card_uid': card_uid, 'photo_b64': ''},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_unknown_card(self):
|
||||
result = self._tap('04:00:00:00:00:00')
|
||||
self.assertEqual(result.get('error'), 'card_unknown')
|
||||
|
||||
def test_disabled_employee(self):
|
||||
result = self._tap('04:A2:B5:62:DE:AD')
|
||||
self.assertEqual(result.get('error'), 'clock_disabled')
|
||||
|
||||
def test_no_location_configured(self):
|
||||
self.env.company.x_fclk_nfc_kiosk_location_id = False
|
||||
result = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(result.get('error'), 'no_location_configured')
|
||||
|
||||
def test_kiosk_disabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
|
||||
result = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(result.get('error'), 'kiosk_disabled')
|
||||
|
||||
def test_invalid_uid(self):
|
||||
result = self._tap('not-a-uid')
|
||||
self.assertEqual(result.get('error'), 'invalid_uid')
|
||||
|
||||
def test_debounce_silent_second_tap(self):
|
||||
first = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertTrue(first.get('success'))
|
||||
second = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(second.get('error'), 'debounce')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapPhotoHandling(HttpCase):
|
||||
|
||||
SAMPLE_PNG_DATAURL = (
|
||||
'data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
|
||||
'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Photo Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Photo Kiosk User',
|
||||
'login': 'nfc-kiosk-photo',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.emp = cls.env['hr.employee'].create({
|
||||
'name': 'Photo Emp',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Avoid debounce contamination from other test classes
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
|
||||
def _tap(self, photo_b64=''):
|
||||
self.authenticate('nfc-kiosk-photo', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_photo_saved_on_clock_in(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
|
||||
result = self._tap(self.SAMPLE_PNG_DATAURL)
|
||||
self.assertTrue(result.get('success'))
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance.x_fclk_check_in_photo)
|
||||
|
||||
def test_photo_required_rejects_when_missing(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
|
||||
result = self._tap(photo_b64='')
|
||||
self.assertEqual(result.get('error'), 'photo_required')
|
||||
|
||||
def test_photo_optional_succeeds_without_photo(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
result = self._tap(photo_b64='')
|
||||
self.assertTrue(result.get('success'))
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestEmployeeSearch(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Search Kiosk User',
|
||||
'login': 'nfc-kiosk-search',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True})
|
||||
|
||||
def test_search_returns_matching_employees(self):
|
||||
self.authenticate('nfc-kiosk-search', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/employee_search',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'query': 'Steve'},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
result = response.json().get('result', {})
|
||||
self.assertIn('employees', result)
|
||||
names = [e['name'] for e in result['employees']]
|
||||
self.assertIn('Searchable Steve', names)
|
||||
103
fusion_clock/tests/test_nfc_models.py
Normal file
103
fusion_clock/tests/test_nfc_models.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from psycopg2 import IntegrityError
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcModels(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Employee = cls.env['hr.employee']
|
||||
cls.alice = cls.Employee.create({'name': 'Alice NFC', 'x_fclk_enable_clock': True})
|
||||
cls.bob = cls.Employee.create({'name': 'Bob NFC', 'x_fclk_enable_clock': True})
|
||||
|
||||
def test_card_uid_is_writable(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
|
||||
|
||||
def test_card_uid_is_unique_when_set(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self.bob.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
self.bob.flush_recordset(['x_fclk_nfc_card_uid'])
|
||||
|
||||
def test_card_uid_can_be_null_for_multiple_employees(self):
|
||||
self.alice.x_fclk_nfc_card_uid = False
|
||||
self.bob.x_fclk_nfc_card_uid = False
|
||||
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
|
||||
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcAttendanceFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'NFC Test Employee',
|
||||
'x_fclk_enable_clock': True,
|
||||
})
|
||||
|
||||
def test_clock_source_includes_nfc_kiosk(self):
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': '2026-05-13 08:00:00',
|
||||
'x_fclk_clock_source': 'nfc_kiosk',
|
||||
})
|
||||
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
|
||||
|
||||
def test_photo_fields_accept_binary(self):
|
||||
# 1x1 transparent PNG as base64
|
||||
png_b64 = (
|
||||
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
|
||||
b'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
)
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': '2026-05-13 08:00:00',
|
||||
'x_fclk_check_in_photo': png_b64,
|
||||
})
|
||||
self.assertTrue(attendance.x_fclk_check_in_photo)
|
||||
|
||||
def test_activity_log_accepts_new_selections(self):
|
||||
log = self.env['fusion.clock.activity.log'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'source': 'nfc_kiosk',
|
||||
'description': 'Test enrollment log',
|
||||
})
|
||||
self.assertEqual(log.log_type, 'card_enrollment')
|
||||
self.assertEqual(log.source, 'nfc_kiosk')
|
||||
|
||||
log2 = self.env['fusion.clock.activity.log'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'log_type': 'unknown_card_tap',
|
||||
'source': 'nfc_kiosk',
|
||||
'description': 'Test unknown card log',
|
||||
})
|
||||
self.assertEqual(log2.log_type, 'unknown_card_tap')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcKioskCompanyField(TransactionCase):
|
||||
|
||||
def test_company_has_nfc_kiosk_location(self):
|
||||
company = self.env['res.company'].create({'name': 'NFC Test Co Plant'})
|
||||
location = self.env['fusion.clock.location'].create({
|
||||
'name': 'Plant 1',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
company.x_fclk_nfc_kiosk_location_id = location.id
|
||||
self.assertEqual(company.x_fclk_nfc_kiosk_location_id, location)
|
||||
|
||||
def test_company_field_defaults_to_false(self):
|
||||
new_company = self.env['res.company'].create({'name': 'Test Co NFC'})
|
||||
self.assertFalse(new_company.x_fclk_nfc_kiosk_location_id)
|
||||
54
fusion_clock/views/kiosk_nfc_templates.xml
Normal file
54
fusion_clock/views/kiosk_nfc_templates.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||
</t>
|
||||
|
||||
<div id="nfc_kiosk_root" class="nfc-kiosk"
|
||||
t-att-data-photo-required="'1' if photo_required else '0'"
|
||||
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
|
||||
t-att-data-location-configured="'1' if location_configured else '0'"
|
||||
t-att-data-company-logo-url="company_logo_url or ''">
|
||||
|
||||
<!-- Company logo (also drives the dominant-hue palette via JS) -->
|
||||
<img t-if="company_logo_url"
|
||||
class="nfc-kiosk__logo"
|
||||
id="nfc_company_logo"
|
||||
t-att-src="company_logo_url"
|
||||
crossorigin="anonymous"
|
||||
alt="Company logo"/>
|
||||
|
||||
<!-- Static chrome (always visible) -->
|
||||
<div class="nfc-kiosk__company" t-esc="company_name"/>
|
||||
<div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
|
||||
<div class="nfc-kiosk__date" id="nfc_clock_date">—</div>
|
||||
<div class="nfc-kiosk__location">
|
||||
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
|
||||
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
|
||||
</div>
|
||||
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
|
||||
|
||||
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
|
||||
<div id="nfc_state_container">
|
||||
<!-- Initial: One-time setup wizard -->
|
||||
<div class="nfc-kiosk__setup">
|
||||
<h2>Welcome to Fusion Clock NFC Kiosk</h2>
|
||||
<p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
|
||||
<button id="nfc_setup_start">Tap to enable NFC reader</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden video + canvas for camera capture -->
|
||||
<video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
|
||||
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
|
||||
<canvas id="nfc_camera_canvas" style="display:none;"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -242,6 +242,34 @@
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- NFC Clock Kiosk -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
|
||||
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
|
||||
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
|
||||
<field name="fclk_enable_nfc_kiosk"/>
|
||||
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_kiosk_location_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_photo_required"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_enroll_password" password="True"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_kiosk_debug" string="Debug Overlay" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_kiosk_debug"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
# Parent Number Hierarchy — Design
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Draft — pending user review
|
||||
**Author:** Brainstormed with Gurpreet
|
||||
**Scope:** Replace divergent sequences (`S00xxx` / `WH/JOB/01xxx` / `INV/2026/xxxx` / `CERT-` / `DLV/` / `RCV-` / etc.) with a single shared parent-number scheme tied to the sale order. Every document that 1:1 links to an SO derives its name from the SO's parent number.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **One source of truth.** When anyone sees a number, they immediately know which SO it belongs to. No mental lookup needed.
|
||||
2. **Compliance-grade traceability.** Numbers are immutable post-issuance. Cancellation leaves gaps; gaps are part of the audit trail. Hard-deletion is blocked on every customer-shared and compliance-relevant model.
|
||||
3. **Forward-only.** Existing records keep their current names. New records start fresh from `30000`.
|
||||
4. **Block off-flow invoice creation.** Invoices may only be created via the sale-order workflow — no direct creation, no group-based bypass.
|
||||
|
||||
## 2. Non-Goals
|
||||
|
||||
- Renumbering or migrating existing records (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, etc.). They keep their existing names until they close out naturally.
|
||||
- Touching docs that physically span multiple SOs: **batches** (rack/barrel — a single rack can hold parts from three different customers), **bake windows** (per-batch, same issue), **move log** (per-event audit row, too granular), **equipment / maintenance / calibration / NADCAP audits** (equipment-bound). These keep their existing sequences.
|
||||
- Multi-company numbering segregation. (One company in scope.)
|
||||
|
||||
## 3. Naming Rules
|
||||
|
||||
### 3.1 Quote name
|
||||
|
||||
While the `sale.order` is in `state == 'draft'` (a quotation), the name uses a non-resetting per-month counter:
|
||||
|
||||
```
|
||||
Q + YYYY + MM + - + N
|
||||
e.g. Q202605-200, Q202605-201, Q202606-202
|
||||
```
|
||||
|
||||
The `N` counter never resets — only the year/month prefix rolls. New `ir.sequence` `fp.quote.number` handles this with prefix `Q%(year)s%(month)s-` and `padding=0`.
|
||||
|
||||
### 3.2 Parent number
|
||||
|
||||
When the SO is confirmed (`action_confirm`), a new integer is drawn from `ir.sequence` `fp.parent.number` (starts at `30000`, increments by 1, never resets). Stored on the SO as `x_fc_parent_number`. The pre-confirm quote name is preserved in `x_fc_quote_ref`.
|
||||
|
||||
### 3.3 Child names
|
||||
|
||||
Every child document linked to an SO is named as:
|
||||
|
||||
```
|
||||
<PREFIX>-<parent> ← first / only child (bare)
|
||||
<PREFIX>-<parent>-NN ← 2nd through 99th (zero-padded 2-digit)
|
||||
<PREFIX>-<parent>-NNN ← 100th and beyond (unpadded — practically unreachable)
|
||||
```
|
||||
|
||||
| Model | Prefix | Example |
|
||||
|------------------------------------|---------|--------------------------|
|
||||
| `sale.order` (confirmed) | `SO` | `SO-30000` |
|
||||
| `fp.job` | `WO` | `WO-30000`, `WO-30000-02`|
|
||||
| `account.move` (customer invoice) | `IN` | `IN-30000`, `IN-30000-02`|
|
||||
| `account.move` (customer refund) | `CN` | `CN-30000-02` |
|
||||
| `fp.certificate` | `CoC` | `CoC-30000` |
|
||||
| `fusion.plating.delivery` | `DLV` | `DLV-30000` |
|
||||
| `fp.receiving` | `RCV` | `RCV-30000` |
|
||||
| `fusion.plating.pickup.request` | `PU` | `PU-30000` |
|
||||
| `fusion.plating.ncr` | `NCR` | `NCR-30000-02` |
|
||||
| `fusion.plating.capa` | `CAPA` | `CAPA-30000` |
|
||||
| `fusion.plating.quality.hold` | `HOLD` | `HOLD-30000` |
|
||||
| `fusion.plating.rma` | `RMA` | `RMA-30000` |
|
||||
|
||||
### 3.4 WO suffix at SO confirm — special case
|
||||
|
||||
WOs are unique in that **the full set is materialized at SO-confirm time** (one WO per recipe group). All other docs are created on demand later. So WO suffixing at confirm:
|
||||
|
||||
- 1 recipe group → 1 WO named bare (`WO-30000`).
|
||||
- N recipe groups → N WOs named `WO-30000-01`, `WO-30000-02`, ..., `WO-30000-N` (zero-padded). Suffix matches creation order (group sorted by `min(line.sequence)`).
|
||||
|
||||
If a user **later** manually adds an extra WO to the SO:
|
||||
|
||||
- If the original was bare (1 group originally) → new WO is `WO-30000-02`. Bare one stays bare. (Bare implicitly carries index `1`.)
|
||||
- If the originals were suffixed → new WO continues the count (`WO-30000-N+1`).
|
||||
|
||||
This is the only model where the bare-vs-suffix decision happens at create-time-of-the-set rather than create-time-of-the-individual. All other models follow the simple rule: first = bare, subsequent = suffixed.
|
||||
|
||||
### 3.5 Existing records
|
||||
|
||||
**Untouched.** Records with old-format names (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, `CERT-00001`, `DLV/2026/0001`, `RCV-00001`, …) keep their existing names forever. They age out as jobs close. Sequences are reset to start producing the new format from `30000`.
|
||||
|
||||
## 4. Data Model
|
||||
|
||||
### 4.1 New fields on `sale.order`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------------------------|---------|-----------------------------------------------------------------------|
|
||||
| `x_fc_quote_ref` | Char | The original quote-stage name (`Q202605-200`). Preserved after confirm. |
|
||||
| `x_fc_parent_number` | Integer | Assigned on `action_confirm`. Drives child naming. Indexed. |
|
||||
| `x_fc_wo_count` | Integer | Cached number of WOs issued. Monotonic. |
|
||||
| `x_fc_invoice_count` | Integer | Cached. Monotonic. |
|
||||
| `x_fc_cn_count` | Integer | Customer credit notes. Monotonic. |
|
||||
| `x_fc_cert_count` | Integer | CoCs issued. Monotonic. |
|
||||
| `x_fc_delivery_count` | Integer | Deliveries. Monotonic. |
|
||||
| `x_fc_receiving_count` | Integer | Receivings. Monotonic. |
|
||||
| `x_fc_pickup_count` | Integer | Pickup requests. Monotonic. |
|
||||
| `x_fc_ncr_count` | Integer | NCRs raised against this SO. Monotonic. |
|
||||
| `x_fc_capa_count` | Integer | CAPAs. Monotonic. |
|
||||
| `x_fc_hold_count` | Integer | Quality holds. Monotonic. |
|
||||
| `x_fc_rma_count` | Integer | RMAs. Monotonic. |
|
||||
|
||||
Counters are **monotonic and never decrement**, even on cancellation/unlink (which itself is blocked — see §6).
|
||||
|
||||
### 4.2 New field on every child model
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------------------|---------|--------------------------------------------------------------------|
|
||||
| `x_fc_doc_index` | Integer | The index this child was assigned (1, 2, 3, …). `readonly=True` after create. Indexed jointly with the link to SO. |
|
||||
|
||||
### 4.3 New abstract model: `fp.parent.numbered.mixin`
|
||||
|
||||
```python
|
||||
class FpParentNumberedMixin(models.AbstractModel):
|
||||
_name = 'fp.parent.numbered.mixin'
|
||||
_description = 'Fusion Plating — Parent-Number-Derived Naming'
|
||||
|
||||
x_fc_doc_index = fields.Integer(
|
||||
string='Parent Doc Index',
|
||||
readonly=True, copy=False, index=True,
|
||||
help='Sequential index within this parent SO (1 = first child).',
|
||||
)
|
||||
|
||||
# ---- Hooks subclasses override --------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
"""Return the linked sale.order record or self.env['sale.order']."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
"""Return the 2-4 letter prefix for this model (e.g. 'WO', 'IN')."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
"""Return the field name on sale.order that counts THIS model's children."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- Core (sealed) --------------------------------------------
|
||||
def _fp_assign_parent_name(self):
|
||||
"""Atomically: lock the parent SO, read+bump the counter, assign
|
||||
x_fc_doc_index and name. Used by subclass create() hooks."""
|
||||
# implementation in §5.3
|
||||
```
|
||||
|
||||
Subclasses register by:
|
||||
|
||||
```python
|
||||
class FpJob(models.Model):
|
||||
_name = 'fp.job'
|
||||
_inherit = ['fp.job', 'fp.parent.numbered.mixin'] # multi-inherit pattern
|
||||
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'WO'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_wo_count'
|
||||
```
|
||||
|
||||
## 5. Behaviour
|
||||
|
||||
### 5.1 Quote creation (`sale.order.create`)
|
||||
|
||||
Override existing `create()` so that when a new sale.order is created and no `name` is provided (or `name == 'New'`), it pulls from the `fp.quote.number` sequence rather than Odoo's default. The resulting name (`Q202605-200`) is also stored in `x_fc_quote_ref` so it's preserved verbatim after confirm.
|
||||
|
||||
### 5.2 SO confirm (`sale.order.action_confirm`)
|
||||
|
||||
In the existing confirm flow, AFTER any existing checks but BEFORE `_fp_native_jobs_for_so()` (the WO creation):
|
||||
|
||||
1. If `x_fc_parent_number` is unset:
|
||||
1. Draw next from `fp.parent.number` (starts at 30000).
|
||||
2. Write `x_fc_parent_number` and rename `name` from `Q...` to `SO-<parent>`.
|
||||
3. Post chatter: *"Confirmed quote Q202605-200 as SO-30000."*
|
||||
2. Proceed with WO creation (§5.4 below).
|
||||
|
||||
### 5.3 Atomic counter increment (mixin core)
|
||||
|
||||
`_fp_assign_parent_name()` does, in order, in a single transaction:
|
||||
|
||||
1. `cr.execute("SELECT <counter_field> FROM sale_order WHERE id = %s FOR UPDATE", [so.id])` — acquires a row-level lock on the parent SO until commit.
|
||||
2. Reads the current count.
|
||||
3. Computes the new index `= current + 1`.
|
||||
4. `UPDATE sale_order SET <counter_field> = <new index> WHERE id = %s`.
|
||||
5. Sets `self.x_fc_doc_index = new_index` and `self.name = self._fp_compose_name(new_index)`.
|
||||
6. Posts chatter on the parent SO: *"Issued WO-30000-02 to fp.job #1234."*
|
||||
|
||||
Composition rule:
|
||||
|
||||
```python
|
||||
def _fp_compose_name(self, index):
|
||||
so = self._fp_parent_sale_order()
|
||||
parent = so.x_fc_parent_number
|
||||
prefix = self._fp_name_prefix()
|
||||
if index <= 1:
|
||||
return f'{prefix}-{parent}'
|
||||
if index <= 99:
|
||||
return f'{prefix}-{parent}-{index:02d}'
|
||||
return f'{prefix}-{parent}-{index}'
|
||||
```
|
||||
|
||||
### 5.4 WO creation at SO confirm — special bulk path
|
||||
|
||||
`_fp_native_jobs_for_so()` (in `fusion_plating_jobs/models/sale_order.py`) is rewritten to:
|
||||
|
||||
1. Group lines by **resolved recipe id** (the 4-tier priority resolution we just shipped — `line.x_fc_process_variant_id` → `part.default_process_id` → `coating.recipe_id` → `part.recipe_id`). `x_fc_wo_group_tag` is dropped as an override mechanism (recipe-driven grouping replaces it).
|
||||
2. Count the resulting groups.
|
||||
3. If 1 group → create 1 job with `vals['name'] = f"WO-{parent}"` and `x_fc_doc_index = 1`. Bump SO's `x_fc_wo_count` to 1.
|
||||
4. If N > 1 groups → create N jobs ordered by `min(line.sequence)`. For each, `vals['name'] = f"WO-{parent}-{i:02d}"` and `x_fc_doc_index = i`. Bump `x_fc_wo_count` to N.
|
||||
5. All assignments happen inside a single `for_update` lock on the SO.
|
||||
|
||||
If a user later manually creates an extra `fp.job` for this SO (via the form, not the SO-confirm flow), the mixin's standard path runs: lock SO → bump `x_fc_wo_count` → assign next index → compose name.
|
||||
|
||||
### 5.5 Invoice creation flow
|
||||
|
||||
When `sale.order._create_invoices()` runs (deposit, progress, partial, or final invoicing):
|
||||
|
||||
1. The standard Odoo flow proceeds as-is for line aggregation / tax / journal selection.
|
||||
2. Before `account.move.create()` is called, the SO's `_create_invoices` override sets `self = self.with_context(fp_from_so_invoice=True)`.
|
||||
3. Our `account.move.create()` override:
|
||||
- For `move_type in ('out_invoice', 'out_refund')`:
|
||||
- If `not self.env.context.get('fp_from_so_invoice')` AND `not vals.get('invoice_origin')` matching an SO name → **raise `UserError`** ("Customer invoices must be created from a Sale Order. Open the SO and use Create Invoice."). No group bypass; applies to admins.
|
||||
- Else proceed.
|
||||
- Post-create, immediately invoke `_fp_assign_parent_name()` (mixin pulls SO via `invoice_origin` lookup) — which assigns `x_fc_doc_index` and overrides `name` from the journal-default `INV/2026/xxxx` to `IN-<parent>` or `IN-<parent>-NN`.
|
||||
|
||||
Credit notes (`out_refund`) use prefix `CN` and counter `x_fc_cn_count`.
|
||||
|
||||
### 5.6 Other child models — uniform path
|
||||
|
||||
For `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`:
|
||||
|
||||
- Each model's existing `create()` override (which currently pulls from its own `ir.sequence`) is rewritten to resolve the parent SO and call `_fp_assign_parent_name()`.
|
||||
- If the record has **no** linked SO (e.g. a standalone NCR raised from a calibration finding, an RMA from a generic customer complaint with no SO), the mixin falls back to the model's old sequence (e.g. `NCR-2026-NNN`).
|
||||
- The old sequences stay in place as the standalone fallback. They're not removed.
|
||||
|
||||
### 5.7 Direct-creation block on invoices
|
||||
|
||||
Implementation in §5.5. Concrete error message:
|
||||
|
||||
> *"Customer invoices and credit notes must be created from a Sale Order. Open the originating SO and use the Create Invoice / Add Credit Note action. This rule applies to all users including administrators — it is enforced to keep the parent-number audit trail intact."*
|
||||
|
||||
The check is in `account.move._create_invoice_check_so()` (new helper), called from the create override. The helper:
|
||||
|
||||
1. Reads `move_type`.
|
||||
2. If not customer-facing (`out_invoice`/`out_refund`) → pass.
|
||||
3. Else, look for `fp_from_so_invoice=True` in context OR `invoice_origin` resolving to an existing `sale.order`.
|
||||
4. If neither → raise.
|
||||
|
||||
## 6. Immutability and Deletion
|
||||
|
||||
### 6.1 `name` and `x_fc_doc_index` are immutable
|
||||
|
||||
The mixin sets `name` and `x_fc_doc_index` with `readonly=True`. Additionally, a `write()` override raises `UserError` if either field is in the values dict and the record already has a non-empty name. No code path can rename a record post-creation.
|
||||
|
||||
### 6.2 `unlink()` blocked on compliance models
|
||||
|
||||
For: `sale.order`, `account.move` (customer invoices and credit notes), `fp.certificate`, `fp.job`, `fusion.plating.delivery`, `fp.receiving`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`.
|
||||
|
||||
`unlink()` raises `UserError` for **every** user (no group bypass) when the record has a name AND its state is not `draft`. The error message:
|
||||
|
||||
> *"Document `<name>` cannot be deleted — it is part of the compliance audit trail. Cancel it instead (state machine handles cancellation). This rule applies to all users including administrators."*
|
||||
|
||||
Draft records (no name assigned yet, never issued) can be deleted normally — they're not yet part of the audit trail.
|
||||
|
||||
### 6.3 Cancellation leaves gaps
|
||||
|
||||
When `IN-30000-02` is cancelled, the counter `x_fc_invoice_count` is NOT decremented. The next invoice for SO-30000 is `IN-30000-03`. The audit chatter and the `x_fc_doc_index` field both record `IN-30000-02` as issued + cancelled.
|
||||
|
||||
## 7. Spanning Documents — Exception List
|
||||
|
||||
The following keep their existing per-model sequences (NOT touched by this design):
|
||||
|
||||
| Model | Reason |
|
||||
|--------------------------------|-----------------------------------------------------------------------------|
|
||||
| `fusion.plating.batch` | A rack/barrel can hold parts from multiple SOs simultaneously. |
|
||||
| `fusion.plating.bake.window` | Per-batch; same reasoning. |
|
||||
| `fp.job.step.move` | Per-event audit row; too granular and per-step, not per-SO. |
|
||||
| `maintenance.equipment` / plans| Equipment-bound, not order-bound. |
|
||||
| Compliance docs (Nadcap, ITP, CFT, RISK, SPILL, INC, etc.) | Audit / event-driven, not SO-driven. |
|
||||
|
||||
This list is exhaustive — every other linked document gets the parent-number treatment.
|
||||
|
||||
## 8. Reports + Views
|
||||
|
||||
### 8.1 Reports to verify show parent-derived names
|
||||
|
||||
All these read `record.name` directly, so they "just work" once the data flow is right. Verification checklist:
|
||||
|
||||
- [ ] Quote PDF (standard Odoo sale report — uses `name`)
|
||||
- [ ] Sale Order confirmation PDF
|
||||
- [ ] Invoice PDF (standard Odoo)
|
||||
- [ ] WO Detail PDF — **bug to fix**: current `short_wo` derivation uses `(job.name or '').split('/')[-1]` which assumed `WH/JOB/` prefix. Update to either strip the new `WO-` prefix or simply show full `job.name`.
|
||||
- [ ] CoC EN / FR PDFs
|
||||
- [ ] Chronological CoC PDF
|
||||
- [ ] Traveller PDF
|
||||
- [ ] Delivery / Packing Slip / BoL PDFs
|
||||
- [ ] Job Sticker
|
||||
- [ ] Rack Travel Ticket
|
||||
- [ ] WO Margin report
|
||||
|
||||
### 8.2 Form views
|
||||
|
||||
- `sale.order` form: after confirm, show both `x_fc_quote_ref` (small grey "Originally quoted as Q202605-200") and `name` (big SO-30000 heading).
|
||||
- All child forms: show `name` (no change to layout).
|
||||
- All search views referencing `WH/JOB/` or `INV/` prefixes in `decoration-info` style hooks should be neutral — they don't typically depend on the prefix.
|
||||
|
||||
### 8.3 List/kanban views
|
||||
|
||||
No structural changes. The sort order by `name` works fine since all new names follow `<PREFIX>-30000`, `<PREFIX>-30000-02`, ..., which sort alphabetically as expected.
|
||||
|
||||
## 9. Sequence Definitions
|
||||
|
||||
New sequences (XML data):
|
||||
|
||||
```xml
|
||||
<record id="seq_fp_quote_number" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Quote Number</field>
|
||||
<field name="code">fp.quote.number</field>
|
||||
<field name="prefix">Q%(year)s%(month)s-</field>
|
||||
<field name="padding">0</field> <!-- non-padding sequential -->
|
||||
<field name="use_date_range" eval="False"/> <!-- counter never resets -->
|
||||
<field name="number_next_actual">200</field> <!-- start from current quote count -->
|
||||
</record>
|
||||
|
||||
<record id="seq_fp_parent_number" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Parent Number</field>
|
||||
<field name="code">fp.parent.number</field>
|
||||
<field name="prefix"/> <!-- no prefix, just the integer -->
|
||||
<field name="padding">0</field>
|
||||
<field name="number_next_actual">30000</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
Existing sequences (`fp.job`, `account.move` journal, `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fp.rma`) stay defined and are used as fallbacks for standalone-no-SO cases (§5.6).
|
||||
|
||||
## 10. Migration
|
||||
|
||||
- New sequences added with `number_next_actual` at the target starting values (30000 for parent, 200 for quote).
|
||||
- No data backfill on existing records.
|
||||
- Module upgrade rolls out:
|
||||
- Mixin abstract model
|
||||
- Field additions on `sale.order` and on each child
|
||||
- Create/write/unlink overrides
|
||||
- View tweaks
|
||||
- Rollback path: re-installing the prior version restores the old `create()` flows. The new fields on existing records would become unused but harmless.
|
||||
|
||||
## 11. Open Items / Edge Cases
|
||||
|
||||
1. **Pickup request before SO exists.** Pickup requests can be raised before an SO is confirmed (or even created). The mixin's standalone fallback covers this. If a pickup is later linked to a confirmed SO, the name is NOT retroactively changed (immutability rule). A separate `x_fc_so_id` link records the relationship; the original name stays.
|
||||
2. **Quote sequence migration.** The `number_next_actual=200` is illustrative. Confirmed value from the user before the spec is implemented (he stated "Q202605-200" as the format with `200` as the example counter, so we start there or at any agreed value).
|
||||
3. **Reports not updating display label.** The WO Detail's `short_wo` derivation is the one known concrete report-side break. The rest read `name` raw and don't need template changes.
|
||||
4. **Manual job creation outside the SO flow.** Users may manually create an `fp.job` from the form (rare). The mixin's standard path handles this: requires a linked SO, locks it, bumps counter, assigns name. If no SO is linked, raise UserError.
|
||||
|
||||
## 12. Implementation Order (high-level)
|
||||
|
||||
Detailed step-by-step plan to be produced by `writing-plans` skill. High-level sequence:
|
||||
|
||||
1. Add `fp.parent.numbered.mixin` abstract model + sequence data.
|
||||
2. Add SO fields (`x_fc_quote_ref`, `x_fc_parent_number`, counters) and `x_fc_doc_index` on each child.
|
||||
3. Quote/SO rename in `sale.order.create` and `action_confirm`.
|
||||
4. Block direct invoice creation (override on `account.move.create`).
|
||||
5. Wire each child model into the mixin: `fp.job` first (most critical), then `account.move` (invoice/credit note), then `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, then quality models (NCR, CAPA, Hold, RMA).
|
||||
6. Add `unlink()` and `write()` overrides for immutability.
|
||||
7. WO recipe-group rewrite of `_fp_native_jobs_for_so` (replaces existing `x_fc_wo_group_tag` grouping).
|
||||
8. View tweaks: SO form quote ref display.
|
||||
9. Fix WO Detail report's `short_wo` derivation.
|
||||
10. Full audit walkthrough on a fresh DB: create quote → confirm → ship → invoice → CoC → verify every doc shows parent-derived name.
|
||||
|
||||
---
|
||||
|
||||
**End of design.**
|
||||
@@ -0,0 +1,439 @@
|
||||
# Sticker — Multi-part, Per-box, Internal/External Variants
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module(s):** `fusion_plating_jobs`, `fusion_plating_reports`
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Approved — ready for implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
The box sticker (printed at SO level and at fp.job level) currently
|
||||
mishandles three real-world scenarios on multi-line orders:
|
||||
|
||||
1. **Silent thickness/SN merge bug.** When two SO lines share
|
||||
`(recipe, part, coating)` but differ in thickness or serial,
|
||||
the current `_create_fp_jobs` grouping collapses them into one
|
||||
`fp.job`. The job inherits the FIRST line's thickness/SN — the
|
||||
other line's values are silently dropped from the sticker (and
|
||||
eventually from the CoC).
|
||||
2. **No per-box stickers.** A line with `qty = 5` prints one
|
||||
sticker showing `Qty: 5`. Operators want one physical label per
|
||||
box, with a `1 / 5`, `2 / 5`, ... indicator.
|
||||
3. **No Internal variant.** The sticker always prints the
|
||||
customer-facing description (`_line.name`) in the Notes column.
|
||||
The shop floor wants a parallel variant that shows the
|
||||
internal ops description (`_line.x_fc_internal_description`,
|
||||
from Sub 2) instead.
|
||||
|
||||
This spec covers all three as a single piece of work — they touch
|
||||
the same files and ship together.
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Multi-thickness / multi-SN lines split into separate `fp.job`
|
||||
records with correct WO-XXXXX-NN naming.
|
||||
- SO sticker and Job sticker render one page per physical box,
|
||||
with a `Box X / N` indicator replacing the current `Qty: N`.
|
||||
- New "Internal" variant for each sticker that prints the internal
|
||||
description in the Notes column. Existing variant becomes
|
||||
"External".
|
||||
- Both variants share the same inner template — only the Notes
|
||||
source differs.
|
||||
- Existing action XML IDs unchanged so bookmarks and binding
|
||||
records keep working.
|
||||
|
||||
**Non-goals**
|
||||
|
||||
- Per-physical-box serial number tracking (today's `x_fc_serial_id`
|
||||
is one per line, shared across all boxes in that line — that's
|
||||
fine).
|
||||
- Box-count override (today: 1 sticker per qty unit; if the shop
|
||||
packs 5 parts into 1 box, that's an operational choice the
|
||||
sticker doesn't try to encode).
|
||||
- Migration of pre-existing single-line, single-thickness jobs —
|
||||
they remain as-is.
|
||||
|
||||
## Current state (post Sub 11)
|
||||
|
||||
### Backend — `fusion_plating_jobs/models/sale_order.py`
|
||||
|
||||
```python
|
||||
# Inside _create_fp_jobs(), the grouping key:
|
||||
key = (recipe.id, part_id, coating_id)
|
||||
groups[key] = groups.get(key, ...) | line
|
||||
```
|
||||
|
||||
Lines that share ALL THREE collapse into one `fp.job`. Sub 11's
|
||||
comment explicitly calls out the part_id+coating_id check ("sharing
|
||||
only the recipe is not enough — would put Part A's number on a cert
|
||||
covering both") but doesn't extend the same reasoning to thickness
|
||||
or SN. The thickness Many2one (`x_fc_thickness_id`) and serial
|
||||
Many2one (`x_fc_serial_id`) were added in Sub 5, after the grouping
|
||||
logic was last touched.
|
||||
|
||||
### Sticker — `fusion_plating_reports/report/report_fp_wo_sticker.xml`
|
||||
|
||||
Two outer templates wrap a shared inner:
|
||||
|
||||
- `report_fp_so_sticker` (bound to `sale.order` via
|
||||
`action_report_fp_so_sticker`) — iterates
|
||||
`so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)`,
|
||||
renders one inner per line.
|
||||
- `report_fp_job_sticker_template` (in
|
||||
`fusion_plating_jobs/report/report_fp_job_sticker.xml`, bound to
|
||||
`fp.job` via `action_report_fp_job_sticker`) — iterates `docs`,
|
||||
renders one inner per job.
|
||||
|
||||
Neither outer accounts for `qty > 1` — each line/job produces
|
||||
exactly one inner render.
|
||||
|
||||
The inner template `report_fp_wo_sticker_inner` sets variables and
|
||||
renders one page. The Notes content is fixed:
|
||||
|
||||
```xml
|
||||
<t t-set="_notes_content" t-value="(_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
```
|
||||
|
||||
There is no way for an outer to override this — it's a hard read of
|
||||
`_line.name`.
|
||||
|
||||
## Architecture — the three changes
|
||||
|
||||
### Change 1 — Backend split: extend grouping key
|
||||
|
||||
In `fusion_plating_jobs/models/sale_order.py`, in the method that
|
||||
builds the `groups` dict (currently `_create_fp_jobs` around line
|
||||
424–441), extend the key tuple:
|
||||
|
||||
```python
|
||||
# Before
|
||||
key = (recipe.id, part_id, coating_id)
|
||||
|
||||
# After
|
||||
thickness_id = (
|
||||
'x_fc_thickness_id' in line._fields
|
||||
and line.x_fc_thickness_id.id
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
||||
```
|
||||
|
||||
**Effect:** Lines that previously merged silently across different
|
||||
thicknesses or SNs now split into separate fp.jobs. WO-XXXXX-NN
|
||||
suffixes apply normally (driven by the existing
|
||||
`ordered_keys = sorted(...)` block — no change needed there).
|
||||
|
||||
**Backwards compat:** Single-line SOs and same-(thickness, SN)
|
||||
multi-line SOs collapse identically to before. No data migration
|
||||
required.
|
||||
|
||||
### Change 2 — Per-box render in the inner template
|
||||
|
||||
`fusion_plating_reports/report/report_fp_wo_sticker.xml`, in the
|
||||
`report_fp_wo_sticker_inner` template:
|
||||
|
||||
1. Move the variable-resolution + style block OUT of the per-page
|
||||
render (these don't change per box, so they don't need to repeat).
|
||||
2. Wrap the `<div class="fp-sticker">` body in a box loop:
|
||||
|
||||
```xml
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
... existing structure ...
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
3. Change the Qty row's value column to show `X / N` when
|
||||
`_qty_total > 1`:
|
||||
|
||||
```xml
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<t t-if="_qty_total and _qty_total > 1">
|
||||
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
**Outer templates supply `_qty_total`:**
|
||||
|
||||
- SO outer: `_qty_total = line.product_uom_qty`
|
||||
- Job outer: `_qty_total = job.qty`
|
||||
|
||||
If `_qty_total` is missing/zero, fall back to `1` so single-box
|
||||
behavior is unchanged.
|
||||
|
||||
### Change 3 — Internal/External variants
|
||||
|
||||
#### 3a. Inner template: override-or-fallback on `_notes_content`
|
||||
|
||||
In `report_fp_wo_sticker_inner`, change the `_notes_content` set
|
||||
from a hard read to override-or-fallback (matches the existing
|
||||
pattern for `_so`, `_part`, etc.):
|
||||
|
||||
```xml
|
||||
<!-- Was: -->
|
||||
<t t-set="_notes_content" t-value="(_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
|
||||
<!-- After: -->
|
||||
<t t-set="_notes_content" t-value="_notes_content
|
||||
or (_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
```
|
||||
|
||||
External outer templates don't set `_notes_content` → falls through
|
||||
to `_line.name` (unchanged External behavior).
|
||||
|
||||
Internal outer templates pre-set `_notes_content` before
|
||||
t-calling the inner:
|
||||
|
||||
```xml
|
||||
<t t-set="_notes_content" t-value="(_line and 'x_fc_internal_description' in _line._fields
|
||||
and _line.x_fc_internal_description) or '-'"/>
|
||||
```
|
||||
|
||||
#### 3b. New outer templates + action records
|
||||
|
||||
**SO Internal** — in `fusion_plating_reports/report/report_fp_wo_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<template id="report_fp_so_sticker_internal">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_so" t-value="so"/>
|
||||
<t t-set="_line" t-value="line"/>
|
||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Override: read internal description instead of line.name -->
|
||||
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
|
||||
and line.x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
```
|
||||
|
||||
**SO External** — existing `report_fp_so_sticker` template gets one
|
||||
addition: `<t t-set="_qty_total" t-value="line.product_uom_qty"/>`.
|
||||
No other logic change (no `_notes_content` set = External default).
|
||||
|
||||
**Job Internal** — in `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Override: read internal description from first linked SO line -->
|
||||
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
|
||||
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
|
||||
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Job External** — existing `report_fp_job_sticker_template`
|
||||
template gets one addition: `<t t-set="_qty_total" t-value="job.qty"/>`.
|
||||
|
||||
**Action records — labels + new XML IDs**
|
||||
|
||||
In `fusion_plating_reports/report/report_actions.xml`:
|
||||
|
||||
```xml
|
||||
<!-- Existing record — rename label only -->
|
||||
<record id="action_report_fp_so_sticker" model="ir.actions.report">
|
||||
<field name="name">External Sticker</field> <!-- was: "WO Box Sticker" -->
|
||||
...
|
||||
</record>
|
||||
|
||||
<!-- New record -->
|
||||
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
In `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<!-- Existing record — rename label only -->
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
<field name="name">External Job Sticker</field> <!-- was: "Job Sticker" -->
|
||||
...
|
||||
</record>
|
||||
|
||||
<!-- New record -->
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_jobs/models/sale_order.py` | Extend grouping key in `_create_fp_jobs` (+5 lines) |
|
||||
| 2 | `fusion_plating_reports/report/report_fp_wo_sticker.xml` | Inner template: box loop, Qty row logic, `_notes_content` fallback chain. SO outer: add `_qty_total`. NEW: SO Internal outer template. |
|
||||
| 3 | `fusion_plating_reports/report/report_actions.xml` | Rename existing SO action label. NEW: SO Internal action record. |
|
||||
| 4 | `fusion_plating_jobs/report/report_fp_job_sticker.xml` | Job outer: add `_qty_total`. Rename existing job action label. NEW: Job Internal outer template + action record. |
|
||||
| 5 | `fusion_plating_jobs/__manifest__.py` | Version bump |
|
||||
| 6 | `fusion_plating_reports/__manifest__.py` | Version bump |
|
||||
|
||||
## Migration
|
||||
|
||||
None required.
|
||||
|
||||
- **New grouping key (`_create_fp_jobs`)** is purely additive —
|
||||
existing jobs are protected by the existing
|
||||
`if existing: return` idempotency guard. Single-line and
|
||||
same-(thickness, SN) multi-line SOs collapse identically to
|
||||
before.
|
||||
- **Existing XML IDs unchanged** — bookmarks / `binding_model_id`
|
||||
records keep working. Only the visible label flips.
|
||||
- **New variants** appear in the Print menu on next module
|
||||
upgrade with no data work.
|
||||
|
||||
## Testing
|
||||
|
||||
### Scenario 1 — Multi-thickness split (new fp.jobs)
|
||||
|
||||
Create a new SO with two lines:
|
||||
- Line 10: Part A, Coating X, Thickness 0.3-0.5 mils, qty 2
|
||||
- Line 20: Part A, Coating X, Thickness 0.5-1.0 mils, qty 1
|
||||
|
||||
Confirm SO → 2 fp.jobs are created:
|
||||
- `WO-XXXXX-01`: qty 2, thickness 0.3-0.5
|
||||
- `WO-XXXXX-02`: qty 1, thickness 0.5-1.0
|
||||
|
||||
Print each job's External sticker → confirm correct thickness on each.
|
||||
|
||||
### Scenario 2 — Per-box rendering
|
||||
|
||||
Take Scenario 1's SO, click "Print → External Sticker" on the SO.
|
||||
|
||||
Confirm: 3-page PDF.
|
||||
- Page 1: Line 10 box 1 → Qty row shows `1 / 2`
|
||||
- Page 2: Line 10 box 2 → Qty row shows `2 / 2`
|
||||
- Page 3: Line 20 box 1 → Qty row shows `1`
|
||||
|
||||
### Scenario 3 — Internal variant
|
||||
|
||||
On the same SO, click "Print → Internal Sticker".
|
||||
|
||||
Confirm: same 3 pages, same WO#/PO#/Customer/Part#/SN/Thickness/Qty,
|
||||
but the Notes column shows `x_fc_internal_description` from each
|
||||
line instead of `name`.
|
||||
|
||||
If `x_fc_internal_description` is blank on a line, Notes shows `-`.
|
||||
|
||||
### Scenario 4 — Regression check (existing single-line)
|
||||
|
||||
Re-print SO-30019 (1 line, qty 1) → External sticker prints
|
||||
single-page, no `X / N` indicator, Notes shows `_line.name` as
|
||||
before. Internal variant: single-page, Notes shows `x_fc_internal_description`
|
||||
or `-`.
|
||||
|
||||
### Scenario 5 — Job-level multi-box
|
||||
|
||||
Take any existing fp.job with `qty = 3`. Print External Job Sticker.
|
||||
|
||||
Confirm: 3 pages, `1/3`, `2/3`, `3/3`. Internal Job Sticker also 3
|
||||
pages with the line's internal description in Notes.
|
||||
|
||||
### Scenario 6 — Action menu visibility
|
||||
|
||||
On a sale order Print menu: both "External Sticker" and
|
||||
"Internal Sticker" appear. On an fp.job Print menu: both
|
||||
"External Job Sticker" and "Internal Job Sticker" appear.
|
||||
|
||||
## Out-of-scope items (deferred)
|
||||
|
||||
- **Per-box SN registry.** Today `x_fc_serial_id` is one per line.
|
||||
If the customer needs unique SNs per physical box (5 parts =
|
||||
5 SNs), build out an `fp.box.serial` registry that links to the
|
||||
line. Out of scope for this spec — would need workflow design
|
||||
(UI for assigning, where SNs print, etc.).
|
||||
- **Box count ≠ qty.** Some shops pack multiple parts per box.
|
||||
Today this spec assumes 1 sticker per qty unit. If needed,
|
||||
add an `x_fc_box_count` field on the line that defaults to qty
|
||||
but can be overridden, and the sticker loops over box_count
|
||||
instead. Defer until requested.
|
||||
- **Sticker preview UI in the form view.** No live preview today;
|
||||
operators print + visually verify. Defer.
|
||||
|
||||
## Open questions
|
||||
|
||||
None — all decisions locked at spec time:
|
||||
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| Add SN to grouping key? | **Yes.** Same reasoning as thickness — silent merge of different SNs is a compliance hole. |
|
||||
| Per-box indicator location? | **Replace Qty row value.** Operator's confirmation: "we can use the quantity field portion for the box, there is room we can use rather than creating another line below and making everything smaller." |
|
||||
| Box indicator format? | **`1 / 5`** (slash, spaces around for legibility at 50pt). When qty=1, show plain `1` (no slash) — matches current behavior. |
|
||||
| Label naming convention? | **Prefix.** `External Sticker` / `Internal Sticker` (SO Print menu), `External Job Sticker` / `Internal Job Sticker` (fp.job Print menu). |
|
||||
| Migration for existing jobs? | **None.** Idempotency guard in `_create_fp_jobs` protects them. |
|
||||
| Existing action XML IDs? | **Unchanged.** Only labels rename — bookmarks/binding records survive. |
|
||||
| Fractional qty? | Cast to `int(qty)` — current behavior preserved. |
|
||||
| Qty=0 line? | Already filtered out by `lambda l: l.x_fc_part_catalog_id` (no part → no sticker). |
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.15.10',
|
||||
'version': '19.0.18.15.16',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -84,6 +84,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'data/fp_landing_data.xml',
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_job_sequences.xml',
|
||||
'data/fp_numbering_sequences.xml',
|
||||
'data/fp_process_category_data.xml',
|
||||
# fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config,
|
||||
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!--
|
||||
Parent-number sequence: drives the integer at the heart of every
|
||||
linked document's name (SO-30000, WO-30000, IN-30000, CoC-30000, ...).
|
||||
Starts at 30000 per the 2026-05-12 parent-number design.
|
||||
noupdate=1 so a module upgrade never resets the counter.
|
||||
-->
|
||||
<record id="seq_fp_parent_number" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Parent Number</field>
|
||||
<field name="code">fp.parent.number</field>
|
||||
<field name="prefix"/>
|
||||
<field name="padding">0</field>
|
||||
<field name="number_next_actual">30000</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Quote sequence: Q + YYYY + MM + '-' + non-resetting counter.
|
||||
The counter is global (never resets when year/month rolls).
|
||||
Padding 0 because the counter naturally grows past 4 digits
|
||||
over time.
|
||||
-->
|
||||
<record id="seq_fp_quote_number" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Quote Number</field>
|
||||
<field name="code">fp.quote.number</field>
|
||||
<field name="prefix">Q%(year)s%(month)s-</field>
|
||||
<field name="padding">0</field>
|
||||
<field name="number_next_actual">200</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -3,6 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_parent_numbered_mixin
|
||||
from . import fp_process_category
|
||||
from . import fp_process_type
|
||||
from . import fp_facility
|
||||
|
||||
@@ -51,7 +51,7 @@ class FpJob(models.Model):
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(tz).strftime(fmt)
|
||||
_description = 'Work Order'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
# Sub 12d — state-aware sort. Active work bubbles to the top
|
||||
# (in_progress → confirmed/draft → on_hold → done → cancelled),
|
||||
# then high-priority first within each state, then nearest deadline.
|
||||
@@ -389,12 +389,50 @@ class FpJob(models.Model):
|
||||
continue
|
||||
job.current_step_id = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'WO'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_wo_count'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""fp.job naming priority:
|
||||
1. Caller-provided name (bulk SO-confirm path sets these explicitly).
|
||||
2. Mixin parent-derived name (manual WO add to an existing SO).
|
||||
3. Legacy fp.job sequence (standalone job, no SO link).
|
||||
"""
|
||||
# Pass A: fall back to legacy 'New' sentinel for records that
|
||||
# don't get a parent-derived name. The mixin's post-create
|
||||
# _fp_assign_parent_name() will override 'New' once the record
|
||||
# exists if a parent SO is reachable.
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
||||
return super().create(vals_list)
|
||||
if not vals.get('name'):
|
||||
vals['name'] = _('New')
|
||||
records = super().create(vals_list)
|
||||
# Pass B: any record that came through with 'New' (no explicit
|
||||
# name from the bulk SO path) tries the parent-derived path,
|
||||
# falling back to the legacy sequence if there's no parent SO.
|
||||
for rec in records:
|
||||
if rec.name and rec.name != _('New') and rec.name != 'New':
|
||||
continue # caller set an explicit name (e.g. bulk SO confirm)
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
||||
# Raw SQL — fp.job has no immutability guard yet in this
|
||||
# task, but Task 11 will add one. Using SQL here keeps the
|
||||
# fallback path consistent across all child models.
|
||||
self.env.cr.execute(
|
||||
"UPDATE fp_job SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State machine — actions
|
||||
|
||||
178
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal file
178
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Abstract mixin: derive a record's name from its parent sale order.
|
||||
|
||||
Every model that 1:1 links to an SO inherits this mixin. The mixin
|
||||
owns the atomic counter logic so race conditions and counter drift
|
||||
are impossible. Subclasses implement three small hooks and call
|
||||
``self._fp_assign_parent_name()`` from their ``create()`` override.
|
||||
|
||||
See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
|
||||
for the design rationale.
|
||||
"""
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
# Whitelist regex for counter-field names. The mixin interpolates the
|
||||
# returned name into raw SQL, so a future subclass that read this from
|
||||
# a context value or Selection field would otherwise open a SQL-injection
|
||||
# surface. Enforce: must look like one of our x_fc_pn_*_count counters
|
||||
# (lowercase letters / underscores only).
|
||||
_FP_COUNTER_FIELD_RE = re.compile(r'^x_fc_pn_[a-z_]+_count$')
|
||||
|
||||
|
||||
class FpParentNumberedMixin(models.AbstractModel):
|
||||
_name = 'fp.parent.numbered.mixin'
|
||||
_description = 'Fusion Plating - Parent-Number-Derived Naming'
|
||||
|
||||
x_fc_doc_index = fields.Integer(
|
||||
string='Parent Doc Index',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
help='1-based position within this parent SO. 1 = the first '
|
||||
'child of this type for the SO; subsequent siblings get 2, '
|
||||
'3, etc. The first sibling renders its name bare; later '
|
||||
'siblings get a zero-padded "-NN" suffix.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hooks subclasses must override
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
"""Return the linked sale.order recordset (or empty)."""
|
||||
return self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
"""Return the model's prefix (e.g. 'WO', 'IN', 'CoC')."""
|
||||
raise NotImplementedError(
|
||||
'Subclass must define _fp_name_prefix()'
|
||||
)
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
"""Return the counter field on sale.order for THIS model."""
|
||||
raise NotImplementedError(
|
||||
'Subclass must define _fp_parent_counter_field()'
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core: atomic counter + name composition
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_compose_name(self, parent_number, index):
|
||||
"""Pure helper: compose the name string per the design's rules."""
|
||||
prefix = self._fp_name_prefix()
|
||||
if index <= 1:
|
||||
return f'{prefix}-{parent_number}'
|
||||
if index <= 99:
|
||||
return f'{prefix}-{parent_number}-{index:02d}'
|
||||
return f'{prefix}-{parent_number}-{index}'
|
||||
|
||||
def _fp_assign_parent_name(self):
|
||||
"""Lock the parent SO, bump the counter, set name + doc index.
|
||||
|
||||
Returns True if assignment succeeded; False if no parent SO is
|
||||
linked (caller falls back to the model's own legacy sequence).
|
||||
"""
|
||||
self.ensure_one()
|
||||
so = self._fp_parent_sale_order()
|
||||
if not so or not so.x_fc_parent_number:
|
||||
return False
|
||||
counter_field = self._fp_parent_counter_field()
|
||||
# Whitelist check — the field name is interpolated directly into
|
||||
# SQL below, so we never trust an arbitrary string. All current
|
||||
# subclasses return a literal; this guard exists so a future
|
||||
# subclass that reads the field name from context / Selection /
|
||||
# user input can't smuggle a SQL fragment in.
|
||||
if not _FP_COUNTER_FIELD_RE.match(counter_field or ''):
|
||||
raise UserError(_(
|
||||
'Invalid parent-counter field name %r — must match '
|
||||
'pattern x_fc_pn_*_count.'
|
||||
) % counter_field)
|
||||
# SELECT FOR UPDATE - locks the SO row until commit, so a
|
||||
# concurrent create on the same SO blocks here and reads the
|
||||
# updated counter after we release. No race, no drift.
|
||||
self.env.cr.execute(
|
||||
f'SELECT {counter_field} FROM sale_order WHERE id = %s FOR UPDATE',
|
||||
(so.id,),
|
||||
)
|
||||
row = self.env.cr.fetchone()
|
||||
current = (row and row[0]) or 0
|
||||
new_index = current + 1
|
||||
self.env.cr.execute(
|
||||
f'UPDATE sale_order SET {counter_field} = %s WHERE id = %s',
|
||||
(new_index, so.id),
|
||||
)
|
||||
so.invalidate_recordset([counter_field])
|
||||
new_name = self._fp_compose_name(so.x_fc_parent_number, new_index)
|
||||
# Raw SQL update bypasses the immutability write() guard added
|
||||
# in Task 11 (since this IS the legitimate assignment path).
|
||||
self.env.cr.execute(
|
||||
f'UPDATE {self._table} SET name = %s, x_fc_doc_index = %s WHERE id = %s',
|
||||
(new_name, new_index, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['name', 'x_fc_doc_index'])
|
||||
so.message_post(body=Markup(_(
|
||||
'Issued <strong>%s</strong> to %s #%s.'
|
||||
)) % (new_name, self._name, self.id))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Immutability: name + x_fc_doc_index can't change post-issuance.
|
||||
# Bypass: context flag fp_allow_name_rename=True. Used ONLY by:
|
||||
# 1. sale.order.action_confirm (Q -> SO rename, one-time)
|
||||
# 2. Bulk WO creation mid-create (sets names explicitly)
|
||||
# 3. Legacy-sequence fallback path in child create() overrides
|
||||
# Compliance: once issued, an audit-trail number can never change.
|
||||
# ------------------------------------------------------------------
|
||||
FP_IMMUTABLE_FIELDS = ('name', 'x_fc_doc_index')
|
||||
|
||||
def write(self, vals):
|
||||
if not self.env.context.get('fp_allow_name_rename'):
|
||||
for f in self.FP_IMMUTABLE_FIELDS:
|
||||
if f in vals:
|
||||
for rec in self:
|
||||
current = rec[f]
|
||||
if current and current != vals[f]:
|
||||
raise UserError(_(
|
||||
'Field "%(field)s" on %(model)s "%(name)s" '
|
||||
'is immutable. Once issued, it cannot be '
|
||||
'changed - this preserves the compliance '
|
||||
'audit trail. (Attempted: %(old)r -> %(new)r)'
|
||||
) % {
|
||||
'field': f, 'model': self._description,
|
||||
'name': rec.display_name,
|
||||
'old': current, 'new': vals[f],
|
||||
})
|
||||
return super().write(vals)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Unlink block: issued documents can't be hard-deleted.
|
||||
# Cancellation must go through the state machine so the audit trail
|
||||
# keeps the issued number tied to its cancellation reason. Hard
|
||||
# delete would leave a phantom gap in the counter. Applies to ALL
|
||||
# users including admins — no group bypass.
|
||||
# ------------------------------------------------------------------
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
# Records still in their initial 'New' state (no number
|
||||
# ever issued) are fine to delete — they're not yet in
|
||||
# the audit trail. Once x_fc_doc_index is non-zero OR
|
||||
# name is something other than 'New' / '/', the record
|
||||
# has been issued and is permanent.
|
||||
issued = rec.x_fc_doc_index or (
|
||||
rec.name and rec.name not in (False, '', 'New', '/')
|
||||
)
|
||||
if issued:
|
||||
raise UserError(_(
|
||||
'Document "%(name)s" cannot be deleted - it is '
|
||||
'part of the compliance audit trail. Cancel it '
|
||||
'instead (use the state machine\'s Cancel action). '
|
||||
'This rule applies to all users including '
|
||||
'administrators.'
|
||||
) % {'name': rec.display_name})
|
||||
return super().unlink()
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.5.5.0',
|
||||
'version': '19.0.5.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -20,7 +20,7 @@ class FpCertificate(models.Model):
|
||||
"""
|
||||
_name = 'fp.certificate'
|
||||
_description = 'Fusion Plating — Certificate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'issue_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
|
||||
@@ -271,14 +271,22 @@ class FpCertificate(models.Model):
|
||||
rec.trend_alert = alert
|
||||
rec.trend_explanation = explanation
|
||||
|
||||
# ----- Sequence + spec-limit auto-fill ---------------------------------
|
||||
# ----- Parent-numbered mixin hooks -------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'CoC'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_cert_count'
|
||||
|
||||
# ----- Create: parent-derived name (fallback to legacy sequence) -------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
SaleOrder = self.env['sale.order']
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
|
||||
# Pull thickness spec limits from coating config if not set
|
||||
# Spec-limit auto-fill (existing behaviour, preserved).
|
||||
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
|
||||
if not already_set and vals.get('sale_order_id'):
|
||||
so = SaleOrder.browse(vals['sale_order_id'])
|
||||
@@ -286,7 +294,23 @@ class FpCertificate(models.Model):
|
||||
if cfg and cfg.thickness_uom == 'mils':
|
||||
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
|
||||
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
|
||||
return super().create(vals_list)
|
||||
# Defer naming: let the record exist so the mixin can write
|
||||
# name via raw SQL, then fall back to the legacy sequence if
|
||||
# no parent SO is reachable.
|
||||
if not vals.get('name'):
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fp_certificate SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.18.10.3',
|
||||
'version': '19.0.18.10.4',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -124,6 +124,15 @@ def _clone_subtree(env, source, part, parent):
|
||||
|
||||
new_node = Node.create(vals)
|
||||
|
||||
# Copy operator-input prompts (temperature reading, visual inspection,
|
||||
# etc.) onto the cloned node. Without this, "Load Template" copies the
|
||||
# step structure but loses every custom prompt the recipe author set up
|
||||
# — operators end up with empty data-capture screens. .copy() handles
|
||||
# every field on the input model (kind, target_min/max/unit,
|
||||
# compliance_tag, sequence, hint, …) and rebinds node_id via override.
|
||||
for src_input in source.input_ids:
|
||||
src_input.copy({'node_id': new_node.id})
|
||||
|
||||
# Recurse into children in deterministic sequence order.
|
||||
for child in source.child_ids.sorted('sequence'):
|
||||
_clone_subtree(env, child, part, new_node)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.21.5',
|
||||
'version': '19.0.8.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -88,16 +88,30 @@ class FpRecordInputsController(http.Controller):
|
||||
if node and node.description:
|
||||
instructions_html = node.description
|
||||
|
||||
# Recipe root id — surfaced so the dialog's "Edit Recipe" shortcut
|
||||
# opens the Simple Editor on the EXACT recipe variant this job is
|
||||
# reading from. Avoids the trap where the operator edits a sibling
|
||||
# variant (e.g. the template, while the job runs the part-specific
|
||||
# clone) and wonders why their min/max never appears.
|
||||
recipe_root_id = False
|
||||
if node:
|
||||
root = node
|
||||
while root.parent_id:
|
||||
root = root.parent_id
|
||||
recipe_root_id = root.id
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'step': {
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
'recipe_node_id': node.id if node else False,
|
||||
},
|
||||
'job': {
|
||||
'id': step.job_id.id,
|
||||
'name': step.job_id.name,
|
||||
},
|
||||
'recipe_root_id': recipe_root_id,
|
||||
'prompts': prompts,
|
||||
'user_initials': user_initials or '',
|
||||
'instructions_html': instructions_html or '',
|
||||
|
||||
@@ -1,24 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# When an invoice is posted, find the linked fp.job (via origin) and
|
||||
# update the portal job state to 'complete' + stamp invoice_ref.
|
||||
"""account.move overrides for Fusion Plating:
|
||||
|
||||
1. Block direct creation of out_invoice / out_refund for ALL users
|
||||
including administrators. The only legal entry points are:
|
||||
* sale.order._create_invoices() — sets context fp_from_so_invoice=True
|
||||
* manual create() with invoice_origin matching an existing sale.order.name
|
||||
|
||||
2. Once a customer move is created via a legitimate path, derive its
|
||||
name from the SO's parent number (IN-30000 / IN-30000-02 for
|
||||
invoices, CN-30000 / CN-30000-02 for credit notes). Per the
|
||||
2026-05-12 parent-number hierarchy design.
|
||||
|
||||
3. On post, link the invoice back to its fp.job's portal job (mark
|
||||
complete, stamp invoice_ref). Pre-existing behaviour, preserved.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
from odoo import api, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CUSTOMER_TYPES = ('out_invoice', 'out_refund', 'out_receipt')
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
_inherit = ['account.move', 'fp.parent.numbered.mixin']
|
||||
|
||||
# =================================================================
|
||||
# Parent-numbered mixin hooks
|
||||
# =================================================================
|
||||
def _fp_parent_sale_order(self):
|
||||
"""Find linked SO via SO context flag (set by _create_invoices),
|
||||
or fall back to invoice_origin name match, then to the reversed
|
||||
entry's SO (for the Add Credit Note path where invoice_origin
|
||||
has copy=False and doesn't survive the move.copy())."""
|
||||
so_id = self.env.context.get('fp_invoice_source_so_id')
|
||||
if so_id:
|
||||
so = self.env['sale.order'].browse(so_id).exists()
|
||||
if so:
|
||||
return so
|
||||
if self.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', self.invoice_origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
return so
|
||||
# Reversal path: read the parent move's SO link so the credit
|
||||
# note's name flows from the same parent number as the invoice
|
||||
# it's reversing.
|
||||
if self.reversed_entry_id:
|
||||
parent_so = self.reversed_entry_id._fp_parent_sale_order()
|
||||
if parent_so:
|
||||
return parent_so
|
||||
return self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'CN' if self.move_type == 'out_refund' else 'IN'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_invoice_count'
|
||||
|
||||
# =================================================================
|
||||
# Create override: block off-flow + assign parent-derived name
|
||||
# =================================================================
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
self._fp_validate_customer_invoice(vals)
|
||||
moves = super().create(vals_list)
|
||||
for mv in moves:
|
||||
if mv.move_type in CUSTOMER_TYPES:
|
||||
mv._fp_assign_parent_name()
|
||||
return moves
|
||||
|
||||
@api.model
|
||||
def _fp_validate_customer_invoice(self, vals):
|
||||
"""Refuse out_invoice / out_refund / out_receipt creation that
|
||||
didn't come through the SO workflow. Applies to ALL users
|
||||
including admins."""
|
||||
mtype = vals.get('move_type', 'entry')
|
||||
if mtype not in CUSTOMER_TYPES:
|
||||
return
|
||||
if self.env.context.get('fp_from_so_invoice'):
|
||||
return
|
||||
origin = (vals.get('invoice_origin') or '').strip()
|
||||
if origin and self.env['sale.order'].sudo().search_count(
|
||||
[('name', '=', origin)]
|
||||
):
|
||||
return
|
||||
# Credit-note / reversal path: Odoo's "Add Credit Note" wizard
|
||||
# calls move.copy() with reversed_entry_id set in the defaults,
|
||||
# but invoice_origin has copy=False on the standard field so
|
||||
# it doesn't survive the copy. Allow reversals through as long
|
||||
# as the reversed entry is itself a customer-facing move (which
|
||||
# means it already went through this validator at original
|
||||
# creation time — the audit trail is intact).
|
||||
reversed_id = vals.get('reversed_entry_id')
|
||||
if reversed_id:
|
||||
parent = self.env['account.move'].sudo().browse(reversed_id)
|
||||
if parent.exists() and parent.move_type in CUSTOMER_TYPES:
|
||||
return
|
||||
raise UserError(_(
|
||||
'Customer invoices, credit notes, and receipts must be '
|
||||
'created from a Sale Order. Open the originating SO and '
|
||||
'use the Create Invoice / Add Credit Note action.\n\n'
|
||||
'This rule applies to all users including administrators. '
|
||||
'It is enforced to keep the parent-number audit trail '
|
||||
'intact (see fusion_plating numbering policy).'
|
||||
))
|
||||
|
||||
# =================================================================
|
||||
# Post hook: link the invoice to its fp.job's portal job
|
||||
# =================================================================
|
||||
def action_post(self):
|
||||
result = super().action_post()
|
||||
for invoice in self.filtered(
|
||||
lambda m: m.move_type in ('out_invoice', 'out_refund')
|
||||
lambda m: m.move_type in CUSTOMER_TYPES
|
||||
):
|
||||
invoice._fp_link_to_job()
|
||||
return result
|
||||
@@ -28,7 +129,6 @@ class AccountMove(models.Model):
|
||||
if not self.invoice_origin:
|
||||
return
|
||||
Job = self.env['fp.job'].sudo()
|
||||
# Walk SO -> fp.job
|
||||
SO = self.env['sale.order'].sudo()
|
||||
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
|
||||
if not so:
|
||||
|
||||
@@ -10,7 +10,15 @@ from odoo import api, models
|
||||
|
||||
|
||||
class ReportFpJobMargin(models.AbstractModel):
|
||||
_name = 'report.fusion_plating_jobs.report_fp_job_margin'
|
||||
# Odoo looks up the report's data model via report.<report_name>.
|
||||
# The action's report_name is `fusion_plating_jobs.report_fp_job_margin_template`,
|
||||
# so this MUST be `report.fusion_plating_jobs.report_fp_job_margin_template`.
|
||||
# Pre-2026-05-12 the model name was missing the `_template` suffix,
|
||||
# which silently caused _get_report_values to never fire and the
|
||||
# template rendered with no `rows` -> blank PDF. The t-field error
|
||||
# was masking this because it crashed earlier; once t-field was
|
||||
# swapped to t-esc the blank-render surfaced.
|
||||
_name = 'report.fusion_plating_jobs.report_fp_job_margin_template'
|
||||
_description = 'Work Order Margin Report'
|
||||
|
||||
@api.model
|
||||
|
||||
@@ -17,6 +17,15 @@ class ResUsers(models.Model):
|
||||
'a different value and saves, it persists here for every '
|
||||
'future job and step.',
|
||||
)
|
||||
x_fc_signature_image = fields.Binary(
|
||||
string='Plating Signature',
|
||||
attachment=True,
|
||||
help='Drawn or uploaded signature image. Used in WO detail and '
|
||||
'certificate reports for any signature-type prompt this user '
|
||||
'signed off on; falls back to typed initials when blank. '
|
||||
'Capture it once in user preferences; it stamps every '
|
||||
'future sign-off automatically.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _fp_default_initials(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +33,47 @@ class SaleOrder(models.Model):
|
||||
'to drill through the linked Plating Job first.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-number hierarchy (2026-05-12 design)
|
||||
# See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_parent_number = fields.Integer(
|
||||
string='Parent Number',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
help='Set on confirm. Drives every linked document\'s name '
|
||||
'(WO-NNN, IN-NNN, CoC-NNN, ...). Immutable post-assignment.',
|
||||
)
|
||||
x_fc_quote_ref = fields.Char(
|
||||
string='Originally Quoted As',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='The quote-stage name (e.g. Q202605-200). Preserved when '
|
||||
'the SO is renamed on confirm.',
|
||||
)
|
||||
# Per-model counters — monotonic, never decrement. Source of truth
|
||||
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
|
||||
# in fp.parent.numbered.mixin so concurrent creates can't drift.
|
||||
#
|
||||
# Naming: `x_fc_pn_*_count` — the `pn_` infix distinguishes our
|
||||
# storage counters from pre-existing compute fields (e.g. the
|
||||
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
|
||||
# in configurator, `x_fc_receiving_count` in fp_receiving) which
|
||||
# are surface counters for smart buttons. Distinct names avoid
|
||||
# the silent compute-override that made Tasks 3+9 fail until 9.5.
|
||||
x_fc_pn_wo_count = fields.Integer(string='Parent: WO Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_invoice_count = fields.Integer(string='Parent: Invoice Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_cn_count = fields.Integer(string='Parent: Credit Note Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_cert_count = fields.Integer(string='Parent: Certificate Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_delivery_count = fields.Integer(string='Parent: Delivery Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_receiving_count = fields.Integer(string='Parent: Receiving Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_pickup_count = fields.Integer(string='Parent: Pickup Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_ncr_count = fields.Integer(string='Parent: NCR Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_capa_count = fields.Integer(string='Parent: CAPA Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_hold_count = fields.Integer(string='Parent: Hold Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
|
||||
# relocated from fusion_plating_bridge_mrp. Field re-declared with
|
||||
@@ -186,7 +228,60 @@ class SaleOrder(models.Model):
|
||||
action.update({'view_mode': 'form', 'res_id': certs.id})
|
||||
return action
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-number hierarchy — quote naming on create
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Draw Q-YYYYMM-N from fp.quote.number when no explicit name.
|
||||
|
||||
The drawn name is also stashed in x_fc_quote_ref so it survives
|
||||
the confirm-time rename to SO-<parent_number>. If the caller
|
||||
passed an explicit name we preserve that AND mirror it into
|
||||
x_fc_quote_ref (covers data migration, restore, etc.).
|
||||
"""
|
||||
Seq = self.env['ir.sequence']
|
||||
for vals in vals_list:
|
||||
existing = vals.get('name')
|
||||
if not existing or existing == _('New') or existing == 'New':
|
||||
quote_name = Seq.next_by_code('fp.quote.number')
|
||||
if quote_name:
|
||||
vals['name'] = quote_name
|
||||
vals.setdefault('x_fc_quote_ref', quote_name)
|
||||
elif not vals.get('x_fc_quote_ref'):
|
||||
vals['x_fc_quote_ref'] = existing
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Assign parent number + rename Q-…-N to SO-<parent>, then run
|
||||
the standard confirm (which kicks off WO creation).
|
||||
|
||||
Parent number is drawn from fp.parent.number; the quote name
|
||||
was already saved to x_fc_quote_ref on create() so it survives
|
||||
the rename. Idempotent — if x_fc_parent_number is already set,
|
||||
the rename is skipped (re-confirm scenarios)."""
|
||||
Seq = self.env['ir.sequence']
|
||||
for so in self:
|
||||
if so.x_fc_parent_number:
|
||||
continue
|
||||
parent = Seq.next_by_code('fp.parent.number')
|
||||
if not parent:
|
||||
raise UserError(_(
|
||||
'Sequence fp.parent.number is missing. Reinstall '
|
||||
'fusion_plating to restore it.'
|
||||
))
|
||||
parent_int = int(parent)
|
||||
old_name = so.name
|
||||
# fp_allow_name_rename whitelists this single legitimate
|
||||
# rename path through the immutability write() guard
|
||||
# (added in Task 11).
|
||||
so.with_context(fp_allow_name_rename=True).write({
|
||||
'name': f'SO-{parent_int}',
|
||||
'x_fc_parent_number': parent_int,
|
||||
})
|
||||
so.message_post(body=Markup(_(
|
||||
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
|
||||
)) % (old_name, so.name))
|
||||
result = super().action_confirm()
|
||||
for so in self:
|
||||
so._fp_auto_create_job()
|
||||
@@ -208,12 +303,83 @@ class SaleOrder(models.Model):
|
||||
) % {'job': job.name, 'err': exc})
|
||||
return result
|
||||
|
||||
def _create_invoices(self, grouped=False, final=False, date=None):
|
||||
"""Set fp_from_so_invoice=True so account.move.create() allows
|
||||
the customer-invoice creation (the direct-creation block is
|
||||
bypassed via this context flag). Also lets the parent-numbered
|
||||
mixin find the originating SO without depending on invoice_origin.
|
||||
"""
|
||||
return super(SaleOrder, self.with_context(
|
||||
fp_from_so_invoice=True,
|
||||
fp_invoice_source_so_id=self.id if len(self) == 1 else False,
|
||||
))._create_invoices(grouped=grouped, final=final, date=date)
|
||||
|
||||
def unlink(self):
|
||||
"""Spec §6.2 — confirmed SOs are part of the compliance audit
|
||||
trail and cannot be deleted. Cancellation must go through the
|
||||
state machine instead. Draft SOs (no parent_number assigned
|
||||
yet) remain freely deletable per Odoo standard. Applies to
|
||||
all users including administrators."""
|
||||
for so in self:
|
||||
if so.x_fc_parent_number:
|
||||
raise UserError(_(
|
||||
'Sale Order "%(name)s" cannot be deleted — it has '
|
||||
'been confirmed (parent number %(parent)s issued) '
|
||||
'and is part of the compliance audit trail. Cancel '
|
||||
'it instead. This rule applies to all users '
|
||||
'including administrators.'
|
||||
) % {'name': so.display_name, 'parent': so.x_fc_parent_number})
|
||||
return super().unlink()
|
||||
|
||||
def _fp_resolve_recipe_for_line(self, line):
|
||||
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
|
||||
recipe-driven WO splits) AND for the per-job vals construction.
|
||||
|
||||
Priority (most-specific first):
|
||||
1. line.x_fc_process_variant_id — Sarah explicitly picked a
|
||||
part-scoped variant on this order line. Always wins.
|
||||
2. part.default_process_id — part's flagged default
|
||||
variant. Customer-and-part-tuned recipe; must beat any
|
||||
generic coating template.
|
||||
3. coating.recipe_id — coating-config recipe
|
||||
(generic template fallback).
|
||||
4. part.recipe_id — legacy fallback.
|
||||
Returns the recipe record or an empty recordset.
|
||||
"""
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
part = (
|
||||
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
|
||||
) or False
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
coating = (
|
||||
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
|
||||
) or False
|
||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||
coating = self.x_fc_coating_config_id or False
|
||||
picked = (
|
||||
'x_fc_process_variant_id' in line._fields
|
||||
and line.x_fc_process_variant_id
|
||||
) or False
|
||||
if picked:
|
||||
return picked
|
||||
if part and 'default_process_id' in part._fields and part.default_process_id:
|
||||
return part.default_process_id
|
||||
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
|
||||
return coating.recipe_id
|
||||
if part and 'recipe_id' in part._fields and part.recipe_id:
|
||||
return part.recipe_id
|
||||
return Node
|
||||
|
||||
def _fp_auto_create_job(self):
|
||||
"""Create fp.job(s) from the SO's plating lines.
|
||||
|
||||
Lines that share a `x_fc_wo_group_tag` collapse into one job;
|
||||
untagged lines get one job per line. Mirrors bridge_mrp's
|
||||
_fp_auto_create_mo grouping logic.
|
||||
2026-05-12 parent-number rewrite: lines are grouped by resolved
|
||||
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
|
||||
WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
|
||||
WO-<parent>-02, ..., ordered by min line sequence so suffixes
|
||||
mirror SO display order. WO names are then immutable; later
|
||||
manual additions to the SO get the next index via the mixin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Job = self.env['fp.job'].sudo()
|
||||
@@ -246,20 +412,52 @@ class SaleOrder(models.Model):
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
|
||||
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
|
||||
groups = {} # tag → recordset of lines
|
||||
untagged_idx = 0
|
||||
# Group by (recipe, part, coating, thickness, serial). Lines that
|
||||
# share ALL FIVE collapse into one WO. Same compliance reasoning
|
||||
# as part_id + coating_id: bundling lines with different thicknesses
|
||||
# or different serials under one WO would carry the first line's
|
||||
# values onto the cert + sticker — silent mis-attestation. Sub 5
|
||||
# added thickness_id + serial_id; this extends the grouping logic
|
||||
# to honour them. No-recipe lines still get their own group each.
|
||||
groups = {}
|
||||
unrecipe_idx = 0
|
||||
for line in plating_lines:
|
||||
tag = (
|
||||
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
part_id = (
|
||||
'x_fc_part_catalog_id' in line._fields
|
||||
and line.x_fc_part_catalog_id.id
|
||||
) or False
|
||||
if not tag:
|
||||
untagged_idx += 1
|
||||
tag = '__untagged_%d' % untagged_idx
|
||||
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
|
||||
coating_id = (
|
||||
'x_fc_coating_config_id' in line._fields
|
||||
and line.x_fc_coating_config_id.id
|
||||
) or False
|
||||
thickness_id = (
|
||||
'x_fc_thickness_id' in line._fields
|
||||
and line.x_fc_thickness_id.id
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
if recipe:
|
||||
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
||||
else:
|
||||
unrecipe_idx += 1
|
||||
key = ('no_recipe', unrecipe_idx)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
|
||||
# Order groups by min line sequence so dash-suffixes mirror SO
|
||||
# display order. Deterministic regardless of dict iteration order.
|
||||
ordered_keys = sorted(
|
||||
groups.keys(),
|
||||
key=lambda k: min(groups[k].mapped('sequence') or [0]),
|
||||
)
|
||||
n_groups = len(ordered_keys)
|
||||
parent = self.x_fc_parent_number # set by action_confirm earlier
|
||||
|
||||
# Create a job per group
|
||||
for tag, lines in groups.items():
|
||||
for idx, key in enumerate(ordered_keys, start=1):
|
||||
lines = groups[key]
|
||||
first_line = lines[0]
|
||||
qty = sum(lines.mapped('product_uom_qty'))
|
||||
part = (
|
||||
@@ -272,39 +470,11 @@ class SaleOrder(models.Model):
|
||||
and first_line.x_fc_coating_config_id
|
||||
or False
|
||||
)
|
||||
# Header fallback for legacy/configurator SOs that put part +
|
||||
# coating on the SO header instead of the line.
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||
coating = self.x_fc_coating_config_id or False
|
||||
# Recipe lookup priority:
|
||||
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
|
||||
# a part-scoped variant on this order line. Always wins.
|
||||
# 2. coating.recipe_id — coating-config recipe.
|
||||
# 3. part.default_process_id — part's flagged default.
|
||||
# 4. part.recipe_id — legacy fallback.
|
||||
#
|
||||
# If multiple lines in the same WO group have different
|
||||
# variants we use the FIRST line's variant (consistent with
|
||||
# everything else in this loop using `first_line`).
|
||||
recipe = False
|
||||
picked_variant = (
|
||||
'x_fc_process_variant_id' in first_line._fields
|
||||
and first_line.x_fc_process_variant_id
|
||||
or False
|
||||
)
|
||||
if picked_variant:
|
||||
recipe = picked_variant
|
||||
if not recipe and coating and 'recipe_id' in coating._fields \
|
||||
and coating.recipe_id:
|
||||
recipe = coating.recipe_id
|
||||
if not recipe and part and 'default_process_id' in part._fields \
|
||||
and part.default_process_id:
|
||||
recipe = part.default_process_id
|
||||
if not recipe and part and 'recipe_id' in part._fields \
|
||||
and part.recipe_id:
|
||||
recipe = part.recipe_id
|
||||
recipe = self._fp_resolve_recipe_for_line(first_line)
|
||||
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
@@ -359,11 +529,32 @@ class SaleOrder(models.Model):
|
||||
# Quoted revenue: sum line totals
|
||||
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
|
||||
|
||||
# Parent-number naming (2026-05-12). Bare for the single-group
|
||||
# case; zero-padded -NN suffix when multiple recipes split the
|
||||
# SO into multiple WOs. Set explicitly so fp.job.create() skips
|
||||
# its own naming fallback.
|
||||
if parent:
|
||||
if n_groups == 1:
|
||||
vals['name'] = f'WO-{parent}'
|
||||
vals['x_fc_doc_index'] = 1
|
||||
else:
|
||||
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
|
||||
vals['x_fc_doc_index'] = idx
|
||||
|
||||
job = Job.create(vals)
|
||||
_logger.info(
|
||||
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
|
||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
||||
)
|
||||
|
||||
# Bump SO counter to reflect the bulk creation. Future manual
|
||||
# WO additions pick up from here via the mixin standard path.
|
||||
if parent and n_groups:
|
||||
self.env.cr.execute(
|
||||
"UPDATE sale_order SET x_fc_pn_wo_count = %s WHERE id = %s",
|
||||
(n_groups, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['x_fc_pn_wo_count'])
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<td><span t-esc="step['work_centre']"/></td>
|
||||
<td class="text-end"><span t-esc="step['duration_expected']"/></td>
|
||||
<td class="text-end"><span t-esc="step['duration_actual']"/></td>
|
||||
<td class="text-end"><span t-field="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
<td class="text-end"><span t-field="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
<td class="text-end"><span t-esc="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
<td class="text-end"><span t-esc="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr style="font-weight: bold; background: #f3f3f3;">
|
||||
@@ -54,16 +54,16 @@
|
||||
<td></td>
|
||||
<td class="text-end"><span t-esc="row['total_minutes']"/></td>
|
||||
<td></td>
|
||||
<td class="text-end"><span t-field="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
<td class="text-end"><span t-esc="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin-top: 1.5em;">Margin Summary</h3>
|
||||
<table class="table table-sm" style="max-width: 400px;">
|
||||
<tr><th>Quoted Revenue</th><td class="text-end"><span t-field="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Actual Cost</th><td class="text-end"><span t-field="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-field="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Quoted Revenue</th><td class="text-end"><span t-esc="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Actual Cost</th><td class="text-end"><span t-esc="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-esc="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Margin %</th><td class="text-end"><span t-esc="round(row['margin_pct'], 1)"/>%</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
<field name="name">Job Sticker</field>
|
||||
<field name="name">External Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="print_report_name">'Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="print_report_name">'External Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
@@ -60,6 +60,7 @@
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<!-- The fp.job's own name (WH/JOB/00033) is already
|
||||
printed in the header as "WO #...", so suppress
|
||||
@@ -70,4 +71,48 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Internal Job sticker — same fields as External, but the Notes
|
||||
column reads x_fc_internal_description from the first linked
|
||||
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
|
||||
share a job, so first-line is representative). -->
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description from
|
||||
the first linked SO line. -->
|
||||
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
|
||||
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
|
||||
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -74,6 +74,35 @@
|
||||
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
|
||||
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
|
||||
or ''"/>
|
||||
<!-- Customer-facing WO id: strip the sequence prefix
|
||||
("WH/JOB/01373" → "01373"). Keeps the column / cert
|
||||
reference compact; full job.name is still used
|
||||
internally and on the print_report_name. -->
|
||||
<!-- WO display: strip the model prefix so the Work
|
||||
Order column shows "30000" or "30000-02" instead
|
||||
of "WO-30000". Handles both naming schemes:
|
||||
- new "WO-NNNNN[-NN]" (post 2026-05-12 numbering)
|
||||
- legacy "WH/JOB/NNNNN" (pre-2026-05-12 jobs) -->
|
||||
<t t-set="short_wo" t-value="(
|
||||
job.name and job.name.startswith('WO-') and job.name[3:]
|
||||
or (job.name or '').split('/')[-1]
|
||||
)"/>
|
||||
|
||||
<!-- Photo evidence — collect every captured-input value
|
||||
that has an attachment, in step / time order. We
|
||||
number them globally (Photo 1..N) and use those
|
||||
numbers both in the per-step measurement tables
|
||||
(so the customer can see at a glance "this prompt
|
||||
has a photo, see #3 below") and as the gallery
|
||||
titles at the end of the report. The dict carries
|
||||
the cv record, its 1-based index, and pre-computed
|
||||
caption fields so the gallery loop stays clean. -->
|
||||
<t t-set="all_photo_values"
|
||||
t-value="job.move_ids
|
||||
.sorted('move_datetime')
|
||||
.mapped('transition_input_value_ids')
|
||||
.filtered(lambda v: v.value_attachment_id)"/>
|
||||
<t t-set="photo_index_by_id" t-value="{cv.id: idx + 1 for idx, cv in enumerate(all_photo_values)}"/>
|
||||
<!-- Walk EVERY step in sequence, not just moves. The
|
||||
old report only rendered moves so steps without
|
||||
recorded measurements (just Finish & Next) never
|
||||
@@ -99,6 +128,52 @@
|
||||
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
|
||||
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
|
||||
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
|
||||
/* Photo gallery — bordered tile per attachment.
|
||||
flex-wrap so wkhtmltopdf lays out two per row
|
||||
on A4 portrait; page-break-inside on the tile
|
||||
keeps captions glued to their image. */
|
||||
.fp-wo-detail .fp-photo-section { margin-top: 18px; }
|
||||
.fp-wo-detail .fp-photo-section h2 {
|
||||
font-size: 13pt; font-weight: bold; color: #1a4d80;
|
||||
margin: 0 0 8px 0; border-bottom: 2px solid #1a4d80;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile {
|
||||
border: 1px solid #000; padding: 6px;
|
||||
width: 86mm; box-sizing: border-box;
|
||||
page-break-inside: avoid; background: #fff;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap {
|
||||
width: 100%; height: 70mm;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #f5f5f5; border: 1px solid #d0d0d0;
|
||||
overflow: hidden; margin-bottom: 4px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap img {
|
||||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-title {
|
||||
font-size: 9pt; font-weight: bold; margin: 2px 0;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-desc {
|
||||
font-size: 8pt; color: #444; line-height: 1.25;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-ref {
|
||||
font-size: 8pt; color: #1a4d80; font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Inline signature image inside the step
|
||||
measurement Value cell — rendered when a
|
||||
`signature` prompt has a recorder with a
|
||||
Plating Signature on file. Sized to fit the
|
||||
table row without blowing it up. */
|
||||
.fp-wo-detail img.fp-sig-inline {
|
||||
max-height: 14mm; max-width: 50mm;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Work Order Detail</h1>
|
||||
@@ -152,7 +227,7 @@
|
||||
<span t-esc="job.qty"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="job.name"/>
|
||||
<span t-esc="short_wo"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="po_number or '—'"/>
|
||||
@@ -272,6 +347,17 @@
|
||||
<t t-set="actual_str"
|
||||
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
|
||||
</t>
|
||||
<!-- Signature-type prompts: show the
|
||||
recorder's Plating Signature image in
|
||||
the Value cell when available, with
|
||||
typed initials as caption beneath.
|
||||
Falls back to plain initials when the
|
||||
user hasn't uploaded a signature yet. -->
|
||||
<t t-set="is_sig_prompt"
|
||||
t-value="inp and 'input_type' in inp._fields and inp.input_type == 'signature'"/>
|
||||
<t t-set="sig_recorder" t-value="cv.move_id.moved_by_user_id"/>
|
||||
<t t-set="sig_img"
|
||||
t-value="(is_sig_prompt and sig_recorder and 'x_fc_signature_image' in sig_recorder._fields and sig_recorder.x_fc_signature_image) or False"/>
|
||||
<tr>
|
||||
<td><span t-esc="prompt_name"/></td>
|
||||
<td>
|
||||
@@ -280,7 +366,30 @@
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<strong t-esc="actual_str"/>
|
||||
<t t-if="sig_img">
|
||||
<img class="fp-sig-inline"
|
||||
t-att-src="'data:image/png;base64,%s' % sig_img.decode()"
|
||||
t-att-alt="actual_str"/>
|
||||
<t t-if="actual_str">
|
||||
<br/>
|
||||
<span style="font-size: 7.5pt; color: #555;" t-esc="actual_str"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<strong t-esc="actual_str"/>
|
||||
</t>
|
||||
<!-- Photo cross-reference. Operators
|
||||
attached a photo for this prompt;
|
||||
point the reader to the gallery
|
||||
at the end of the doc. -->
|
||||
<t t-if="cv.value_attachment_id">
|
||||
<t t-set="_pidx" t-value="photo_index_by_id.get(cv.id)"/>
|
||||
<br t-if="actual_str or sig_img"/>
|
||||
<span class="fp-photo-ref">
|
||||
<i class="fa fa-camera"/>
|
||||
See Photo #<span t-esc="_pidx"/>
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
|
||||
@@ -301,6 +410,68 @@
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<!-- ===== PHOTO EVIDENCE GALLERY ===== -->
|
||||
<!-- Renders every photo-type captured value as a
|
||||
bordered tile with title (prompt + step) and
|
||||
description (operator + timestamp + any
|
||||
text caption they typed alongside the photo).
|
||||
Numbered to match the "See Photo #N" inline
|
||||
references above. Forced to its own page so
|
||||
the tiles don't get split mid-step. -->
|
||||
<t t-if="all_photo_values">
|
||||
<div style="page-break-before: always;"/>
|
||||
<div style="height: 6mm;"/>
|
||||
<div class="fp-photo-section">
|
||||
<h2>Photo Evidence (<span t-esc="len(all_photo_values)"/>)</h2>
|
||||
<div class="fp-photo-grid">
|
||||
<t t-foreach="all_photo_values" t-as="pv">
|
||||
<t t-set="pidx" t-value="photo_index_by_id.get(pv.id)"/>
|
||||
<t t-set="att" t-value="pv.value_attachment_id"/>
|
||||
<t t-set="ptitle"
|
||||
t-value="(pv.node_input_id and pv.node_input_id.name) or (pv.value_text and pv.value_text.split(':')[0]) or att.name or 'Photo'"/>
|
||||
<t t-set="pstep"
|
||||
t-value="(pv.move_id and ((pv.move_id.to_step_id and pv.move_id.to_step_id.name) or (pv.move_id.from_step_id and pv.move_id.from_step_id.name))) or ''"/>
|
||||
<t t-set="puser"
|
||||
t-value="(pv.move_id and pv.move_id.moved_by_user_id and pv.move_id.moved_by_user_id.name) or ''"/>
|
||||
<t t-set="pdt"
|
||||
t-value="pv.move_id and pv.move_id.move_datetime"/>
|
||||
<!-- Caption: strip the leading "Prompt:"
|
||||
prefix that ad-hoc rows store so we
|
||||
don't print the prompt name twice. -->
|
||||
<t t-set="pcaption" t-value="pv.value_text or ''"/>
|
||||
<t t-if="pv.node_input_id and pv.node_input_id.name and pcaption.startswith(pv.node_input_id.name + ':')">
|
||||
<t t-set="pcaption" t-value="pcaption[len(pv.node_input_id.name)+1:].strip()"/>
|
||||
</t>
|
||||
<div class="fp-photo-tile">
|
||||
<div class="fp-photo-imgwrap">
|
||||
<img t-att-src="'/web/image/%s' % att.id"
|
||||
t-att-alt="att.name"/>
|
||||
</div>
|
||||
<div class="fp-photo-title">
|
||||
Photo #<span t-esc="pidx"/> — <span t-esc="ptitle"/>
|
||||
</div>
|
||||
<div class="fp-photo-desc">
|
||||
<t t-if="pstep">
|
||||
<strong>Step:</strong> <span t-esc="pstep"/><br/>
|
||||
</t>
|
||||
<t t-if="puser or pdt">
|
||||
<strong>Captured by:</strong>
|
||||
<span t-esc="puser or '—'"/>
|
||||
<t t-if="pdt">
|
||||
on <span t-esc="job.fp_format_local(pdt, '%b %d, %Y %I:%M %p')"/>
|
||||
</t>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-if="pcaption">
|
||||
<strong>Note:</strong> <span t-esc="pcaption"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
|
||||
<!-- page-break-before is honoured by wkhtmltopdf
|
||||
but the new page starts flush against the
|
||||
@@ -310,21 +481,35 @@
|
||||
<div style="page-break-before: always;"/>
|
||||
<div style="height: 8mm;"/>
|
||||
|
||||
<t t-set="owner_sig" t-value="False"/>
|
||||
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
|
||||
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
|
||||
<t t-if="_emp and 'signature' in _emp._fields">
|
||||
<t t-set="owner_sig" t-value="_emp['signature']"/>
|
||||
</t>
|
||||
<!-- Certifier = the job's plating manager. Pulls
|
||||
their Plating Signature (`x_fc_signature_image`)
|
||||
from Preferences → My Profile. Falls back to
|
||||
the company owner's signature, then to the
|
||||
settings override only if no user has one. -->
|
||||
<t t-set="certifier_user" t-value="job.manager_id or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/>
|
||||
<t t-set="signature_img" t-value="False"/>
|
||||
<t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image">
|
||||
<t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/>
|
||||
</t>
|
||||
<t t-set="sig_override" t-value="('x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override) or False"/>
|
||||
<t t-set="signature_img" t-value="sig_override or owner_sig"/>
|
||||
<t t-set="signer_name" t-value="(job.manager_id and job.manager_id.name) or ('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
|
||||
<!-- Final fallback: company-level override for sites
|
||||
whose certifier hasn't uploaded their signature yet. -->
|
||||
<t t-if="not signature_img and 'x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override">
|
||||
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override"/>
|
||||
</t>
|
||||
<t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/>
|
||||
|
||||
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
|
||||
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
|
||||
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
|
||||
|
||||
<!-- External note auto-fills the Other Comments box so
|
||||
anything the manager typed on the job ("subbed
|
||||
out for fluoride dip", "customer pickup at 4pm")
|
||||
prints on the customer-facing cert. Manager can
|
||||
still scribble on the printed copy if nothing
|
||||
was typed. -->
|
||||
<t t-set="other_comments" t-value="('x_fc_external_note' in job._fields and job.x_fc_external_note) or ''"/>
|
||||
|
||||
<table class="bordered">
|
||||
<tr>
|
||||
<td style="width: 50%; vertical-align: top; height: 40mm;">
|
||||
@@ -338,7 +523,7 @@
|
||||
<td style="width: 50%; vertical-align: top;">
|
||||
<strong>Certification Statement:</strong>
|
||||
<span style="font-size: 8.5pt;">
|
||||
Ref. WO# <span t-esc="job.name"/>
|
||||
Ref. WO# <span t-esc="short_wo"/>
|
||||
</span>
|
||||
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
|
||||
t-esc="cert_statement"/>
|
||||
@@ -347,6 +532,9 @@
|
||||
<tr>
|
||||
<td colspan="2" style="height: 25mm;">
|
||||
<strong>Other Comments:</strong>
|
||||
<p t-if="other_comments"
|
||||
style="font-size: 9pt; margin-top: 4px; white-space: pre-wrap;"
|
||||
t-esc="other_comments"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""End-to-end numbering walkthrough for the 2026-05-12 parent-number
|
||||
hierarchy. Quote -> confirm -> 2 invoices (partial billing) -> CoC ->
|
||||
delivery -> receiving -> NCR (legacy fallback) -> Hold (parent-derived)
|
||||
-> immutability check -> unlink block check -> direct invoice block.
|
||||
|
||||
Asserts every SO-linked doc shares the same parent number. Re-runnable;
|
||||
rolls back at the end so no DB state is left behind.
|
||||
|
||||
Run via odoo-shell:
|
||||
exec(open('/path/to/numbering_e2e_walkthrough.py').read())
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
SO = env['sale.order']
|
||||
AM = env['account.move']
|
||||
journal = env['account.journal'].search([('type', '=', 'sale')], limit=1)
|
||||
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2)
|
||||
assert len(parts) >= 2, 'need at least 2 parts with default recipes'
|
||||
partner = env['res.partner'].search([], limit=1)
|
||||
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||
product = env['product.product'].search([('type', '!=', 'service')], limit=1) or env['product.product'].search([], limit=1)
|
||||
|
||||
print('=' * 60)
|
||||
print('Numbering hierarchy E2E walkthrough')
|
||||
print('=' * 60)
|
||||
|
||||
# === A: Quote -> confirm ===
|
||||
so = SO.create({
|
||||
'partner_id': partner.id,
|
||||
'x_fc_po_override': True,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 5,
|
||||
'x_fc_part_catalog_id': parts[0].id, 'sequence': 10}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 3,
|
||||
'x_fc_part_catalog_id': parts[1].id, 'sequence': 20}),
|
||||
],
|
||||
})
|
||||
quote_name = so.name
|
||||
print(f'A. Quote: {quote_name}')
|
||||
assert quote_name.startswith('Q'), f'expected Q-prefix, got {quote_name}'
|
||||
|
||||
so.action_confirm()
|
||||
parent = so.x_fc_parent_number
|
||||
print(f'A. Confirmed: {so.name} (parent={parent}, quote_ref={so.x_fc_quote_ref})')
|
||||
assert so.name == f'SO-{parent}'
|
||||
assert so.x_fc_quote_ref == quote_name
|
||||
|
||||
# === B: WOs (2 recipes split SO into -01, -02) ===
|
||||
jobs = env['fp.job'].search([('sale_order_id', '=', so.id)], order='x_fc_doc_index')
|
||||
print(f'B. WOs: {jobs.mapped("name")}')
|
||||
assert len(jobs) == 2
|
||||
assert jobs[0].name == f'WO-{parent}-01'
|
||||
assert jobs[1].name == f'WO-{parent}-02'
|
||||
|
||||
# === C: Two invoices (partial billing) ===
|
||||
inv1 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id,
|
||||
'journal_id': journal.id, 'invoice_origin': so.name,
|
||||
})
|
||||
inv2 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id,
|
||||
'journal_id': journal.id, 'invoice_origin': so.name,
|
||||
})
|
||||
print(f'C. Invoices: {inv1.name}, {inv2.name}')
|
||||
assert inv1.name == f'IN-{parent}'
|
||||
assert inv2.name == f'IN-{parent}-02'
|
||||
|
||||
# === D: CoC ===
|
||||
coc = env['fp.certificate'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
||||
print(f'D. CoC: {coc.name}')
|
||||
assert coc.name == f'CoC-{parent}'
|
||||
|
||||
# === E: Delivery (linked via job_ref) ===
|
||||
dlv = env['fusion.plating.delivery'].create({'partner_id': partner.id, 'job_ref': jobs[0].name})
|
||||
print(f'E. Delivery: {dlv.name}')
|
||||
assert dlv.name == f'DLV-{parent}'
|
||||
|
||||
# === F: Receiving (already auto-created at confirm; manual is -02) ===
|
||||
existing_rcv = env['fp.receiving'].search([('sale_order_id', '=', so.id)])
|
||||
print(f'F. Receivings (incl. auto-created): {existing_rcv.mapped("name")}')
|
||||
assert any(r.name == f'RCV-{parent}' for r in existing_rcv)
|
||||
|
||||
# === G: Hold (via job_id) ===
|
||||
hold = env['fusion.plating.quality.hold'].create({
|
||||
'job_id': jobs[0].id, 'hold_reason': 'qc_failure', 'qty_on_hold': 1,
|
||||
'description': 'E2E test hold',
|
||||
})
|
||||
print(f'G. Hold: {hold.name}')
|
||||
assert hold.name == f'HOLD-{parent}'
|
||||
|
||||
# === H: RMA (via sale_order_id directly) ===
|
||||
rma = env['fusion.plating.rma'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
||||
print(f'H. RMA: {rma.name}')
|
||||
assert rma.name == f'RMA-{parent}'
|
||||
|
||||
# === I: NCR + CAPA (no SO link in core -> legacy seq) ===
|
||||
ncr = env['fusion.plating.ncr'].create({
|
||||
'description': 'E2E test', 'customer_partner_id': partner.id, 'facility_id': facility.id,
|
||||
})
|
||||
print(f'I. NCR (no SO link): {ncr.name}')
|
||||
assert not ncr.name.startswith('NCR-3'), f'expected legacy seq, got {ncr.name}'
|
||||
|
||||
capa = env['fusion.plating.capa'].create({
|
||||
'description': 'E2E test capa', 'ncr_id': ncr.id, 'facility_id': facility.id,
|
||||
})
|
||||
print(f'I. CAPA (no SO link): {capa.name}')
|
||||
assert not capa.name.startswith('CAPA-3'), f'expected legacy seq, got {capa.name}'
|
||||
|
||||
# === J: Immutability ===
|
||||
try:
|
||||
jobs[0].name = 'HACKED'
|
||||
print('FAIL J: name mutation succeeded')
|
||||
except UserError:
|
||||
print('J. OK: WO name immutable')
|
||||
|
||||
# === K: Unlink block ===
|
||||
try:
|
||||
coc.unlink()
|
||||
print('FAIL K: unlink succeeded')
|
||||
except UserError:
|
||||
print('K. OK: CoC unlink blocked')
|
||||
|
||||
# === L: Direct invoice creation block ===
|
||||
try:
|
||||
AM.create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id,
|
||||
})
|
||||
print('FAIL L: direct invoice succeeded')
|
||||
except UserError:
|
||||
print('L. OK: direct invoice blocked')
|
||||
|
||||
print('=' * 60)
|
||||
print(f'PASS: every doc tied to parent {parent}')
|
||||
print('=' * 60)
|
||||
env.cr.rollback()
|
||||
print('(rolled back — DB unchanged)')
|
||||
@@ -69,6 +69,7 @@ export class FpRecordInputsDialog extends Component {
|
||||
saving: false,
|
||||
stepName: "",
|
||||
jobName: "",
|
||||
recipeRootId: false,
|
||||
rows: [],
|
||||
// Operator's persisted initials — pre-filled into signature
|
||||
// / "Reviewer Initials" prompts on load. When the operator
|
||||
@@ -103,6 +104,7 @@ export class FpRecordInputsDialog extends Component {
|
||||
}
|
||||
this.state.stepName = data.step.name;
|
||||
this.state.jobName = data.job.name;
|
||||
this.state.recipeRootId = data.recipe_root_id || false;
|
||||
this.state.userInitials = data.user_initials || "";
|
||||
this.state.instructionsHtml = data.instructions_html || "";
|
||||
this.state.instructionImages = data.instruction_images || [];
|
||||
@@ -193,13 +195,14 @@ export class FpRecordInputsDialog extends Component {
|
||||
isSelection(row) { return row.input_type === "selection"; }
|
||||
isPassFail(row) { return row.input_type === "pass_fail"; }
|
||||
isSignature(row) { return row.input_type === "signature"; }
|
||||
// Fallback to text for anything else (text, time_hms, ...)
|
||||
isTimeHms(row) { return row.input_type === "time_hms"; }
|
||||
// Fallback to text for anything else
|
||||
isText(row) {
|
||||
return !this.isNumeric(row) && !this.isBoolean(row)
|
||||
&& !this.isDate(row) && !this.isPhoto(row)
|
||||
&& !this.isMulti(row) && !this.isPanel(row)
|
||||
&& !this.isSelection(row) && !this.isPassFail(row)
|
||||
&& !this.isSignature(row);
|
||||
&& !this.isSignature(row) && !this.isTimeHms(row);
|
||||
}
|
||||
|
||||
// Friendly label for the type pill — defaults to the raw key when no
|
||||
@@ -208,6 +211,60 @@ export class FpRecordInputsDialog extends Component {
|
||||
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
|
||||
}
|
||||
|
||||
// Step granularity for <input type="number"> — drives the up/down
|
||||
// arrow increment AND the typed-decimal validity. Defaults of step=1
|
||||
// make tablet entry painful when the spec is 0.03 – 0.05 mil because
|
||||
// every arrow press jumps a full unit. Derive from the recipe-author's
|
||||
// target_min / target_max precision so operator arrow-taps move in the
|
||||
// same decimal magnitude the spec was written in. Falls back to
|
||||
// input-type defaults when no targets are set.
|
||||
stepFor(row) {
|
||||
const decimals = Math.max(
|
||||
this._fpCountDecimals(row.target_min),
|
||||
this._fpCountDecimals(row.target_max),
|
||||
);
|
||||
if (decimals > 0) {
|
||||
return Math.pow(10, -decimals).toFixed(decimals);
|
||||
}
|
||||
const t = row.input_type || "";
|
||||
if (t === "thickness" || t === "multi_point_thickness") return "0.0001";
|
||||
if (t === "ph") return "0.01";
|
||||
if (t === "temperature" || t === "time_seconds") return "1";
|
||||
return "any";
|
||||
}
|
||||
|
||||
_fpCountDecimals(n) {
|
||||
if (n === null || n === undefined || n === "" || n === 0) return 0;
|
||||
const s = String(n);
|
||||
const idx = s.indexOf(".");
|
||||
if (idx < 0) return 0;
|
||||
// Trim trailing zeros so "0.0500" doesn't look like 4-decimals
|
||||
// when the author actually wrote 2-decimal precision.
|
||||
return s.slice(idx + 1).replace(/0+$/, "").length;
|
||||
}
|
||||
|
||||
// Jump from the runtime dialog into the Simple Recipe Editor on the
|
||||
// EXACT recipe variant this job step is bound to. Closes the dialog
|
||||
// (operator returns by re-opening Record Inputs after editing). The
|
||||
// intent is to remove the "I edited the recipe but nothing changed"
|
||||
// confusion — they were editing a sibling variant.
|
||||
async openSimpleEditor() {
|
||||
if (!this.state.recipeRootId) {
|
||||
this.notification.add(
|
||||
_t("No recipe linked to this step yet."),
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.props.close();
|
||||
await this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_simple_recipe_editor",
|
||||
name: _t("Edit Recipe"),
|
||||
context: { recipe_id: this.state.recipeRootId },
|
||||
});
|
||||
}
|
||||
|
||||
// True when the recipe author defined BOTH target_min and target_max
|
||||
// on the prompt — the signal that the operator is expected to capture
|
||||
// a range (multiple readings → record their min and max observation).
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
Job <t t-esc="state.jobName"/>
|
||||
</span>
|
||||
</div>
|
||||
<button t-if="state.recipeRootId"
|
||||
class="btn btn-link o_fp_ri_edit_recipe"
|
||||
title="Edit this step's prompts (target ranges, type, options) in the Simple Recipe Editor."
|
||||
t-on-click="openSimpleEditor">
|
||||
<i class="fa fa-pencil me-1"/> Edit Recipe
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -116,7 +122,7 @@
|
||||
class="o_fp_ri_numeric">
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_number"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<t t-set="hint" t-value="rangeHint(row)"/>
|
||||
@@ -136,7 +142,7 @@
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
</label>
|
||||
@@ -144,7 +150,7 @@
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
</label>
|
||||
@@ -167,7 +173,7 @@
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
</label>
|
||||
@@ -175,7 +181,7 @@
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
</label>
|
||||
@@ -301,19 +307,19 @@
|
||||
<div t-if="isMulti(row)" class="o_fp_ri_multi">
|
||||
<div class="o_fp_ri_multi_grid">
|
||||
<label>R1
|
||||
<input type="number" step="any" t-model.number="row.point_1"/>
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_1"/>
|
||||
</label>
|
||||
<label>R2
|
||||
<input type="number" step="any" t-model.number="row.point_2"/>
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_2"/>
|
||||
</label>
|
||||
<label>R3
|
||||
<input type="number" step="any" t-model.number="row.point_3"/>
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_3"/>
|
||||
</label>
|
||||
<label>R4
|
||||
<input type="number" step="any" t-model.number="row.point_4"/>
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_4"/>
|
||||
</label>
|
||||
<label>R5
|
||||
<input type="number" step="any" t-model.number="row.point_5"/>
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_5"/>
|
||||
</label>
|
||||
<div class="o_fp_ri_multi_avg">
|
||||
<span class="text-muted">Avg</span>
|
||||
@@ -325,20 +331,28 @@
|
||||
<!-- Bath chemistry panel — pH / conc / temp / bath -->
|
||||
<div t-if="isPanel(row)" class="o_fp_ri_panel">
|
||||
<label>pH
|
||||
<input type="number" step="any" t-model.number="row.panel_ph"/>
|
||||
<input type="number" step="0.01" t-model.number="row.panel_ph"/>
|
||||
</label>
|
||||
<label>Concentration
|
||||
<input type="number" step="any" t-model.number="row.panel_concentration"/>
|
||||
<input type="number" step="0.1" t-model.number="row.panel_concentration"/>
|
||||
</label>
|
||||
<label>Temperature
|
||||
<input type="number" step="any" t-model.number="row.panel_temperature"/>
|
||||
<input type="number" step="1" t-model.number="row.panel_temperature"/>
|
||||
</label>
|
||||
<label>Bath ID
|
||||
<input type="text" t-model="row.panel_bath_id"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Text fallback (text, signature, time_hms, anything else) -->
|
||||
<!-- Time (HH:MM:SS) — native time picker with seconds.
|
||||
Mobile/tablet browsers surface the OS time wheel. -->
|
||||
<input t-if="isTimeHms(row)"
|
||||
type="time"
|
||||
step="1"
|
||||
class="o_fp_ri_input o_fp_ri_input_text"
|
||||
t-model="row.value_text"/>
|
||||
|
||||
<!-- Text fallback (text, signature, anything else) -->
|
||||
<input t-if="isText(row)"
|
||||
type="text"
|
||||
class="o_fp_ri_input o_fp_ri_input_text"
|
||||
|
||||
@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
|
||||
else:
|
||||
self.skipTest('x_fc_part_catalog_id field not present')
|
||||
|
||||
def test_so_confirm_splits_by_thickness(self):
|
||||
"""Two lines with same recipe+part+coating but DIFFERENT thicknesses
|
||||
must produce TWO fp.jobs — silent merge was a compliance bug (the
|
||||
second thickness's CoC would carry the first thickness).
|
||||
|
||||
The bug only manifests when lines hit the `if recipe:` branch in
|
||||
_fp_auto_create_job — without a resolved recipe, the no_recipe
|
||||
branch already splits per line. We seed a recipe via
|
||||
part.default_process_id so both lines resolve to the same recipe
|
||||
and reach the buggy grouping path.
|
||||
"""
|
||||
SOL = self.env['sale.order.line']
|
||||
Part = self.env['fp.part.catalog']
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
Thick = self.env['fp.coating.thickness']
|
||||
if 'x_fc_part_catalog_id' not in SOL._fields \
|
||||
or 'x_fc_thickness_id' not in SOL._fields \
|
||||
or 'default_process_id' not in Part._fields:
|
||||
self.skipTest('Sub 5 + recipe-on-part fields not present')
|
||||
|
||||
# Two distinct existing thicknesses. Creating them from scratch
|
||||
# requires a coating_config → process_type chain that's too noisy
|
||||
# for a unit test; reuse what's seeded.
|
||||
thicknesses = Thick.search([], limit=2)
|
||||
if len(thicknesses) < 2:
|
||||
self.skipTest('need >= 2 fp.coating.thickness records seeded')
|
||||
thick_a, thick_b = thicknesses[0], thicknesses[1]
|
||||
|
||||
# Any existing top-level recipe works — the test only needs both
|
||||
# lines to resolve to the SAME recipe so they collide on the key.
|
||||
recipe = Node.search([('parent_id', '=', False)], limit=1)
|
||||
if not recipe:
|
||||
self.skipTest('no fusion.plating.process.node records to anchor a recipe')
|
||||
|
||||
partner_for_part = self.env['res.partner'].create({'name': 'SplitPartner'})
|
||||
part = Part.create({
|
||||
'name': 'SplitPart', 'part_number': 'SP-1',
|
||||
'partner_id': partner_for_part.id,
|
||||
'default_process_id': recipe.id,
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'client_order_ref': 'TEST-PO-SPLIT',
|
||||
})
|
||||
SOL.create({
|
||||
'order_id': so.id, 'product_id': self.product.id,
|
||||
'product_uom_qty': 2.0, 'price_unit': 10.0,
|
||||
'x_fc_part_catalog_id': part.id,
|
||||
'x_fc_thickness_id': thick_a.id,
|
||||
})
|
||||
SOL.create({
|
||||
'order_id': so.id, 'product_id': self.product.id,
|
||||
'product_uom_qty': 1.0, 'price_unit': 10.0,
|
||||
'x_fc_part_catalog_id': part.id,
|
||||
'x_fc_thickness_id': thick_b.id,
|
||||
})
|
||||
so.action_confirm()
|
||||
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(
|
||||
len(jobs), 2,
|
||||
'Lines with different thicknesses must spawn separate fp.jobs '
|
||||
'(both lines share recipe+part+coating, only thickness differs)',
|
||||
)
|
||||
# Each job's linked SO line should carry its own thickness
|
||||
thicknesses_on_jobs = set()
|
||||
for job in jobs:
|
||||
for line in job.sale_order_line_ids:
|
||||
if line.x_fc_thickness_id:
|
||||
thicknesses_on_jobs.add(line.x_fc_thickness_id.id)
|
||||
self.assertEqual(
|
||||
thicknesses_on_jobs, {thick_a.id, thick_b.id},
|
||||
'The two distinct thicknesses must each appear on its own job',
|
||||
)
|
||||
|
||||
|
||||
class TestJobLifecycleHooks(TransactionCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -288,6 +288,25 @@
|
||||
<field name="x_fc_ship_via"/>
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='x_fc_customer_refs']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<!-- Notes group sits awkwardly above the main fields in core; relocate
|
||||
to a notebook tab so the form opens on the operationally relevant
|
||||
fields (customer / part / steps) instead of empty note placeholders. -->
|
||||
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='costs']" position="before">
|
||||
<page string="Notes" name="notes">
|
||||
<group>
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal note (not shown to customer)…"/>
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
placeholder="External note (printed on customer paperwork)…"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -32,6 +32,25 @@
|
||||
string="Certificates"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||
line under the SO name (the big SO-30000 heading). Only
|
||||
renders once the SO has been confirmed (quote_ref is set
|
||||
on create, parent_number is set on confirm — both
|
||||
needed for the line to make sense).
|
||||
NB: Odoo 19 forbids t-if in standard form views — using
|
||||
`invisible` attribute on the wrapper div instead. -->
|
||||
<xpath expr="//div[hasclass('oe_title')]" position="inside">
|
||||
<field name="x_fc_parent_number" invisible="1"/>
|
||||
<div class="text-muted"
|
||||
style="font-size: 0.9em; margin-top: 4px;"
|
||||
invisible="not x_fc_quote_ref or not x_fc_parent_number">
|
||||
Originally quoted as
|
||||
<field name="x_fc_quote_ref"
|
||||
readonly="1" nolabel="1"
|
||||
class="d-inline"/>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.3.5.0',
|
||||
'version': '19.0.3.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
|
||||
@@ -24,14 +24,14 @@ class FpDelivery(models.Model):
|
||||
"""
|
||||
_name = 'fusion.plating.delivery'
|
||||
_description = 'Fusion Plating — Delivery'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'scheduled_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self._default_name(),
|
||||
default='New',
|
||||
tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
@@ -159,8 +159,49 @@ class FpDelivery(models.Model):
|
||||
compute='_compute_custody_count',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
"""No direct sale_order_id on this model — resolve via
|
||||
job_ref → fp.job.name → job.sale_order_id."""
|
||||
if not self.job_ref or 'fp.job' not in self.env:
|
||||
return self.env['sale.order']
|
||||
job = self.env['fp.job'].sudo().search(
|
||||
[('name', '=', self.job_ref)], limit=1,
|
||||
)
|
||||
return job.sale_order_id if job else self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'DLV'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_delivery_count'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence
|
||||
fallback for deliveries that don't link back to an SO."""
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_delivery SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
"""Retained for any legacy caller. New code should rely on
|
||||
create() — the parent-numbered mixin sets the name there."""
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery')
|
||||
return seq or '/'
|
||||
|
||||
|
||||
@@ -21,16 +21,26 @@ class FpPickupRequest(models.Model):
|
||||
"""
|
||||
_name = 'fusion.plating.pickup.request'
|
||||
_description = 'Fusion Plating — Pickup Request'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'requested_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self._default_name(),
|
||||
default='New',
|
||||
tracking=True,
|
||||
)
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Sale order this pickup is associated with. Pickup may be '
|
||||
'created BEFORE the SO exists; in that case the '
|
||||
'parent-number naming falls back to the standalone '
|
||||
'PU/YYYY/NNNN sequence and the link can be set later.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Customer',
|
||||
@@ -126,8 +136,39 @@ class FpPickupRequest(models.Model):
|
||||
compute='_compute_custody_count',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'PU'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_pickup_count'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_pickup_request SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
"""Retained for legacy callers; new flow uses the create() override."""
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request')
|
||||
return seq or '/'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.4.12.2',
|
||||
'version': '19.0.4.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -16,7 +16,7 @@ class FpCapa(models.Model):
|
||||
"""
|
||||
_name = 'fusion.plating.capa'
|
||||
_description = 'Fusion Plating — Corrective / Preventive Action'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'due_date asc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
@@ -100,6 +100,23 @@ class FpCapa(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks
|
||||
# CAPAs reach SO via ncr_id → fp.job link if present (jobless NCRs
|
||||
# fall back to legacy sequence).
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
# CAPA usually flows from an NCR. If the NCR has a job-back-link
|
||||
# (added by future modules), we can reach SO through it. For now
|
||||
# there's no link in core — falls back to legacy seq.
|
||||
return self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'CAPA'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_capa_count'
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.capa')
|
||||
@@ -109,8 +126,19 @@ class FpCapa(models.Model):
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.capa') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_capa SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
@api.depends('due_date', 'state')
|
||||
def _compute_is_overdue(self):
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
|
||||
"""Contract Review (QA-005).
|
||||
|
||||
Per-part, two-section QA review: Section 2.0 Planning / Production
|
||||
Review (signed by a QA Assistant) and Section 3.0 Quality Review
|
||||
(signed by a QA Manager). Both sections must be signed for the
|
||||
review to be complete.
|
||||
Review (Planning Review stage, signed by a Planning Signer) and
|
||||
Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
|
||||
Both sections must be signed for the review to be complete.
|
||||
|
||||
The review is always optional. It never blocks MO/SO/WO progression.
|
||||
Its purpose is an audit artefact and a printable 1:1 of the paper
|
||||
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
|
||||
qty = fields.Integer(string='Qty')
|
||||
due_date = fields.Date(string='Due')
|
||||
|
||||
# ---- Section 2.0 — Planning / Production Review (QA Assistant) ---------
|
||||
# ---- Section 2.0 — Planning / Production Review (Planning Review) ------
|
||||
|
||||
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
|
||||
s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
|
||||
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# ---- Section 3.0 — Quality Review (QA Manager) -------------------------
|
||||
# ---- Section 3.0 — Quality Review (QA Review) --------------------------
|
||||
|
||||
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
|
||||
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
|
||||
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('assistant_review', 'QA Assistant Review'),
|
||||
('manager_review', 'QA Manager Review'),
|
||||
('assistant_review', 'Planning Review'),
|
||||
('manager_review', 'QA Review'),
|
||||
('awaiting_info', 'Awaiting Client Info'),
|
||||
('complete', 'Complete'),
|
||||
('dismissed', 'Dismissed')],
|
||||
default='draft',
|
||||
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
|
||||
# When a QA Signer (Brett or whoever the company has rostered) finds a
|
||||
# client requirement that fails during the QA Review, they mark the
|
||||
# review failed. The state moves to `awaiting_info`, an activity is
|
||||
# scheduled for every QA Signer to follow up, and a smart button on
|
||||
# the form gives them a one-click email composer to ping the client.
|
||||
# When the client replies, the QA Signer captures notes in
|
||||
# `special_instructions` and marks complete — the notes print on the
|
||||
# final QA-005 PDF for the audit trail.
|
||||
qa_failure_reason = fields.Html(
|
||||
string='QA Failure Reason',
|
||||
copy=False,
|
||||
help='What client requirement failed and why we need more info. '
|
||||
'Captured here before flipping the review to '
|
||||
'"Awaiting Client Info" so every QA Signer sees the same '
|
||||
'context. Pre-fills the client email composer.',
|
||||
)
|
||||
info_requested_date = fields.Datetime(
|
||||
string='Info Requested Date',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Stamped automatically the first time the client email '
|
||||
'composer is sent.',
|
||||
)
|
||||
info_received_date = fields.Datetime(
|
||||
string='Info Received Date',
|
||||
copy=False,
|
||||
help='Manually stamped when the QA Signer marks the review '
|
||||
'complete after receiving the client info.',
|
||||
)
|
||||
special_instructions = fields.Html(
|
||||
string='Special Instructions',
|
||||
copy=False,
|
||||
help='Free-form notes captured by the QA Signer when they close '
|
||||
'out the review. Prints at the bottom of the QA-005 PDF '
|
||||
'so the audit record carries the agreed resolution.',
|
||||
)
|
||||
client_email_count = fields.Integer(
|
||||
compute='_compute_client_email_count',
|
||||
help='Smart-button counter — number of emails posted to chatter '
|
||||
'against this review. Always non-zero after the first send.',
|
||||
)
|
||||
|
||||
@api.depends('message_ids', 'message_ids.message_type')
|
||||
def _compute_client_email_count(self):
|
||||
for rec in self:
|
||||
rec.client_email_count = len(rec.message_ids.filtered(
|
||||
lambda m: m.message_type == 'email'
|
||||
))
|
||||
|
||||
# ---- Constraints --------------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
|
||||
'fusion_plating_quality.action_report_contract_review'
|
||||
).report_action(self)
|
||||
|
||||
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
|
||||
def action_mark_qa_failed(self):
|
||||
"""QA Signer marks the review failed because a client requirement
|
||||
is missing or unclear. Captures the reason, flips state to
|
||||
`awaiting_info`, and schedules a follow-up activity for every QA
|
||||
Signer rostered on the company (so the work doesn't fall through
|
||||
the cracks if Brett is on vacation)."""
|
||||
self.ensure_one()
|
||||
if self.state not in ('manager_review', 'assistant_review'):
|
||||
raise UserError(_(
|
||||
'Only a review at the QA Review (or Planning Review) stage '
|
||||
'can be flagged as failed. Current state: %s.'
|
||||
) % dict(self._fields['state'].selection).get(self.state, self.state))
|
||||
# Reuse the section-30 signer roster — the same group of people
|
||||
# who can sign QA can flag a QA failure.
|
||||
self._check_signer(30)
|
||||
if not self.qa_failure_reason or not self.qa_failure_reason.strip():
|
||||
raise UserError(_(
|
||||
'Capture the QA Failure Reason before flagging the '
|
||||
'review failed — the reason pre-fills the client email '
|
||||
'and is required for the audit trail.'
|
||||
))
|
||||
self.write({'state': 'awaiting_info'})
|
||||
self.message_post(body=Markup(_(
|
||||
'<b>QA Review failed</b> by %(user)s. Awaiting client '
|
||||
'information.<br/><b>Reason:</b><br/>%(reason)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'reason': Markup(self.qa_failure_reason or ''),
|
||||
})
|
||||
# Schedule activity for every QA Signer (any of them can pick it up).
|
||||
signers = self.company_id._fp_get_qa_signers(30)
|
||||
if not signers:
|
||||
# Fall back to the user who flagged it, so the activity is
|
||||
# not orphaned on shops that haven't configured a roster.
|
||||
signers = self.env.user
|
||||
try:
|
||||
activity_type = self.env.ref('mail.mail_activity_data_todo')
|
||||
except ValueError:
|
||||
activity_type = self.env['mail.activity.type'].search(
|
||||
[('category', '=', 'default')], limit=1)
|
||||
for user in signers:
|
||||
self.activity_schedule(
|
||||
activity_type_id=activity_type.id if activity_type else False,
|
||||
summary=_('Follow up on QA-005 — client info required'),
|
||||
note=self.qa_failure_reason or '',
|
||||
user_id=user.id,
|
||||
date_deadline=fields.Date.context_today(self) +
|
||||
timedelta(days=2),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_open_client_email_wizard(self):
|
||||
"""Smart-button target — opens the email composer wizard pre-filled
|
||||
with the customer's contact email + a body templated from the
|
||||
QA failure reason. The wizard handles the actual mail.mail send
|
||||
and stamps `info_requested_date` on this review."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Email Client — Request Info'),
|
||||
'res_model': 'fp.contract.review.client.email.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_review_id': self.id,
|
||||
'default_recipient_email':
|
||||
self.customer_id.email or '',
|
||||
'default_recipient_name':
|
||||
self.customer_id.name or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_client_emails(self):
|
||||
"""Drill-down behind the smart button counter — shows the chatter
|
||||
messages of type=email for this review."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Client Emails — %s') % self.name,
|
||||
'res_model': 'mail.message',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('model', '=', 'fp.contract.review'),
|
||||
('res_id', '=', self.id),
|
||||
('message_type', '=', 'email'),
|
||||
],
|
||||
}
|
||||
|
||||
def action_complete_after_info(self):
|
||||
"""Close out a review that was in `awaiting_info` once the client
|
||||
info has been received and `special_instructions` captured. Stamps
|
||||
Section 3.0 sign-off with the current user + timestamp so the QA
|
||||
review is fully closed and the QA-005 PDF carries a complete
|
||||
audit trail."""
|
||||
self.ensure_one()
|
||||
if self.state != 'awaiting_info':
|
||||
raise UserError(_(
|
||||
'Only a review in "Awaiting Client Info" can be marked '
|
||||
'complete via this action.'
|
||||
))
|
||||
self._check_signer(30)
|
||||
now = fields.Datetime.now()
|
||||
vals = {
|
||||
'state': 'complete',
|
||||
'info_received_date': self.info_received_date or now,
|
||||
's30_signed_by': self.env.user.id,
|
||||
's30_signed_date': now,
|
||||
's30_locked': True,
|
||||
}
|
||||
self.write(vals)
|
||||
# Mark the activity as done so the follow-up disappears from
|
||||
# everyone's inbox once the case is closed.
|
||||
self.activity_feedback(
|
||||
['mail.mail_activity_data_todo'],
|
||||
feedback=_('Client info received — review closed.'),
|
||||
)
|
||||
self.message_post(body=Markup(_(
|
||||
'<b>QA Review completed</b> by %(user)s after receiving '
|
||||
'client information.<br/>'
|
||||
'<b>Special Instructions captured:</b><br/>%(notes)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'notes': Markup(self.special_instructions or '') or _('(none)'),
|
||||
})
|
||||
return True
|
||||
|
||||
# ---- Helpers ------------------------------------------------------------
|
||||
|
||||
def _check_signer(self, section):
|
||||
|
||||
@@ -17,7 +17,7 @@ class FpNcr(models.Model):
|
||||
"""
|
||||
_name = 'fusion.plating.ncr'
|
||||
_description = 'Fusion Plating — Non-Conformance Report'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'reported_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
@@ -130,6 +130,22 @@ class FpNcr(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks
|
||||
# NCRs don't have a direct SO/job link in core yet — falls back to
|
||||
# legacy fusion.plating.ncr sequence. When a future module adds a
|
||||
# link, it can override _fp_parent_sale_order to enable parent
|
||||
# naming retroactively without further changes here.
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'NCR'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_ncr_count'
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.ncr')
|
||||
@@ -139,8 +155,19 @@ class FpNcr(models.Model):
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.ncr') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_ncr SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
@api.depends('capa_ids')
|
||||
def _compute_capa_count(self):
|
||||
|
||||
@@ -17,7 +17,7 @@ class FpQualityHold(models.Model):
|
||||
"""
|
||||
_name = 'fusion.plating.quality.hold'
|
||||
_description = 'Fusion Plating — Quality Hold'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(
|
||||
@@ -124,6 +124,17 @@ class FpQualityHold(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# Defaults / create
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks. Holds reach the SO through their
|
||||
# linked fp.job (the standard authoring path on the shop floor).
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.job_id.sale_order_id if self.job_id else self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'HOLD'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_hold_count'
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code(
|
||||
@@ -135,8 +146,19 @@ class FpQualityHold(models.Model):
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.quality.hold') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_quality_hold SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
|
||||
@@ -36,7 +36,7 @@ _logger = logging.getLogger(__name__)
|
||||
class FpRma(models.Model):
|
||||
_name = 'fusion.plating.rma'
|
||||
_description = 'Fusion Plating — Return Material Authorisation'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
@@ -243,6 +243,17 @@ class FpRma(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# Defaults / create / name
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks. RMAs reach the SO directly via
|
||||
# sale_order_id (set at create-time from the original order).
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'RMA'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_rma_count'
|
||||
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma')
|
||||
@@ -252,8 +263,19 @@ class FpRma(models.Model):
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fusion_plating_rma SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.7.3',
|
||||
'version': '19.0.3.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
|
||||
@@ -16,7 +16,7 @@ class FpReceiving(models.Model):
|
||||
"""
|
||||
_name = 'fp.receiving'
|
||||
_description = 'Fusion Plating — Receiving'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'received_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
|
||||
@@ -97,19 +97,38 @@ class FpReceiving(models.Model):
|
||||
rec.unresolved_damage_count = len(rec.damage_ids.filtered(lambda d: not d.resolved))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sequence
|
||||
# Sequence + parent-derived naming
|
||||
# -------------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'RCV'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_receiving_count'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
|
||||
# Prefill received_qty from expected_qty so the operator only
|
||||
# types when the count is wrong (the common case is "all
|
||||
# arrived"). Saves a step on every routine receipt.
|
||||
# types when the count is wrong.
|
||||
if vals.get('expected_qty') and not vals.get('received_qty'):
|
||||
vals['received_qty'] = vals['expected_qty']
|
||||
return super().create(vals_list)
|
||||
if not vals.get('name'):
|
||||
vals['name'] = 'New'
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.name and rec.name != 'New':
|
||||
continue
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
|
||||
self.env.cr.execute(
|
||||
"UPDATE fp_receiving SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sub 8 — box-count-only actions (new primary flow)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.10.3.0',
|
||||
'version': '19.0.10.16.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -324,12 +324,26 @@
|
||||
about page size; the output PDF is multi-page if the SO has
|
||||
multiple plating lines. -->
|
||||
<record id="action_report_fp_so_sticker" model="ir.actions.report">
|
||||
<field name="name">WO Box Sticker</field>
|
||||
<field name="name">External Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker</field>
|
||||
<field name="print_report_name">'WO Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="print_report_name">'External Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
</record>
|
||||
|
||||
<!-- SO Internal sticker — same layout, prints internal description
|
||||
instead of the customer-facing line.name. Shop-floor variant. -->
|
||||
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
|
||||
@@ -60,6 +60,22 @@
|
||||
else (_mo and _mo.product_qty) or 0"/>
|
||||
<t t-set="_po_number" t-value="_po_number or (_so and _so.x_fc_po_number) or '-'"/>
|
||||
<t t-set="_partner_name" t-value="_partner_name or (_so and _so.partner_id.name) or '-'"/>
|
||||
<!-- Customer short-code for shop-floor "secrecy cover" — operators
|
||||
see "ABC-MANU" instead of "ABC Manufacturing Inc", so visiting
|
||||
customers / unauthorised passers-by can't immediately tell whose
|
||||
parts are on which rack. Rule: first 3 chars of word[0] + "-"
|
||||
+ first 4 chars of word[1], all uppercase. Single-word names:
|
||||
just the first 3 chars. Strips non-alphanumeric per word so
|
||||
punctuation in "St. John's Mfg." doesn't poison the slice. -->
|
||||
<t t-set="_partner_words"
|
||||
t-value="[''.join(c for c in w if c.isalnum())
|
||||
for w in (_partner_name or '').split()
|
||||
if ''.join(c for c in w if c.isalnum())]"/>
|
||||
<t t-set="_partner_display" t-value="
|
||||
(_partner_words[0][:3].upper() + '-' + _partner_words[1][:4].upper())
|
||||
if len(_partner_words) >= 2
|
||||
else (_partner_words[0][:3].upper() if _partner_words else (_partner_name or '-'))
|
||||
"/>
|
||||
<!-- _mo_ref controls the muted "(WH/MO/00033)" suffix next to PO.
|
||||
Outer can pass '' to hide it (e.g. fp.job already shows its
|
||||
own name in the header). Defaults to _mo.name. -->
|
||||
@@ -69,10 +85,31 @@
|
||||
or (_so and _so.x_fc_internal_note
|
||||
and _so.x_fc_internal_note.striptags()[:100])
|
||||
or '-'"/>
|
||||
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
|
||||
the SO line. The serial record's `name` is the printable label. -->
|
||||
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
|
||||
<!-- Thickness — Sub 5 added x_fc_thickness_id (M2O fp.coating.thickness)
|
||||
on the SO line. `display_name` is the human-readable range, e.g.
|
||||
"0.3–0.5 mils". The en-dash (U+2013) in display_name mojibakes
|
||||
to "â€"" through wkhtmltopdf's font path on entech, so we
|
||||
swap en-dash + em-dash for a plain hyphen-minus before
|
||||
rendering. ASCII-only printable for any QR-label printer. -->
|
||||
<t t-set="_thickness_dn" t-value="_line and 'x_fc_thickness_id' in _line._fields and _line.x_fc_thickness_id and _line.x_fc_thickness_id.display_name"/>
|
||||
<t t-set="_thickness" t-value="(_thickness_dn and _thickness_dn.replace(u'–', '-').replace(u'—', '-')) or '-'"/>
|
||||
<!-- Notes content — outer can pre-set this (e.g. the Internal
|
||||
variant passes line.x_fc_internal_description). Otherwise
|
||||
falls back to line.name (customer-facing description per
|
||||
Sub 2 Q6), then to part.name. -->
|
||||
<t t-set="_notes_content" t-value="_notes_content
|
||||
or (_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
|
||||
to fetch /report/barcode/ over the network during rendering. -->
|
||||
to fetch /report/barcode/ over the network during rendering.
|
||||
600x600 source at 300dpi print = ~515ppi effective — high-def
|
||||
scan reliability for the 4x6" label. -->
|
||||
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
|
||||
'QR', _scan_url, width=300, height=300)"/>
|
||||
'QR', _scan_url, width=600, height=600)"/>
|
||||
|
||||
<style>
|
||||
@page { margin: 0; size: 152mm 102mm; }
|
||||
@@ -82,10 +119,9 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* Boxy professional layout: thick outer border, horizontal row
|
||||
borders, vertical label/value divider. Absolute positioning +
|
||||
% row heights force the content to fill the full page in
|
||||
wkhtmltopdf (which ignores vh/vw/flex). ------------------- */
|
||||
/* 3-cell header (Logo | WO# | QR) + 2-region body (fields left,
|
||||
Notes column right). Absolute positioning + % heights/widths
|
||||
are mandatory — wkhtmltopdf ignores vh/vw/flex. ----------- */
|
||||
.fp-sticker {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: #000;
|
||||
@@ -97,14 +133,14 @@
|
||||
page-break-after: always;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
/* ---- HEADER band — grew to 40% to fit 2x WO# + logo + bigger QR. */
|
||||
/* ---- HEADER band: 3 horizontal cells, divided by vertical
|
||||
rules. Logo / WO# / QR. 32% to fit the +30% QR. ---- */
|
||||
.fp-sticker-head-wrap {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 40%;
|
||||
height: 32%;
|
||||
border-bottom: 2px solid #000;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
table.fp-sticker-head {
|
||||
width: 100%;
|
||||
@@ -112,82 +148,71 @@
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.fp-sticker-head td { padding: 0; vertical-align: middle; }
|
||||
col.fp-col-head-left { width: 66%; }
|
||||
col.fp-col-head-right { width: 34%; }
|
||||
td.fp-sticker-head-left {
|
||||
overflow: hidden;
|
||||
border-right: 2px solid #000;
|
||||
}
|
||||
td.fp-sticker-head-right {
|
||||
col.fp-col-head-logo { width: 28%; }
|
||||
col.fp-col-head-wo { width: 44%; }
|
||||
col.fp-col-head-qr { width: 28%; }
|
||||
table.fp-sticker-head td {
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Left column nested 2-row table: logo on top, WO# below.
|
||||
Horizontal divider between rows mirrors body row borders. */
|
||||
table.fp-sticker-head-left-stack {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.fp-sticker-head-left-stack tr.fp-row-logo { height: 50%; }
|
||||
table.fp-sticker-head-left-stack tr.fp-row-wo { height: 50%; }
|
||||
table.fp-sticker-head-left-stack td {
|
||||
padding: 0 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Logo cell + WO# cell each get explicit vertical-align so the
|
||||
content sits in the middle of its half of the header band. */
|
||||
table.fp-sticker-head-left-stack tr.fp-row-logo td,
|
||||
table.fp-sticker-head-left-stack tr.fp-row-wo td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.fp-sticker-head-left-stack tr + tr td {
|
||||
border-top: 1px solid #000;
|
||||
}
|
||||
td.fp-sticker-head-logo { border-right: 2px solid #000; padding: 0 6px; }
|
||||
td.fp-sticker-head-wo { border-right: 2px solid #000; }
|
||||
.fp-sticker-logo {
|
||||
/* Logo bumped 40% (116 → 162px height, 520 → 728px width). */
|
||||
max-height: 162px;
|
||||
max-width: 728px;
|
||||
display: block;
|
||||
max-height: 135px;
|
||||
max-width: 95%;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.fp-sticker-wo {
|
||||
font-size: 72pt;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2mm;
|
||||
letter-spacing: 0.1mm;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
/* QR wrapper crops the white quiet-zone around the QR pattern
|
||||
so it doesn't visually float on a white square inside the
|
||||
cell. The PNG from Odoo's barcode generator carries a
|
||||
~12% border (4 modules of quiet-zone) on each side; we
|
||||
render the image larger than the wrapper and offset it so
|
||||
the wrapper clips that border out. ---------------------- */
|
||||
/* QR wrapper crops the ~12% quiet-zone the barcode generator
|
||||
adds around the QR pattern. We render the image larger than
|
||||
the wrapper and offset so the wrapper clips that border out.
|
||||
Wrapper 365px = ~30.9mm at 300dpi (30% larger than the
|
||||
previous 280px). 600x600 source = high-def at print scale. ---- */
|
||||
.fp-sticker-qr-wrap {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
width: 365px;
|
||||
height: 365px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fp-sticker-qr {
|
||||
width: 510px;
|
||||
height: 510px;
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
position: absolute;
|
||||
top: -65px;
|
||||
left: -65px;
|
||||
top: -58px;
|
||||
left: -58px;
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */
|
||||
/* ---- BODY band: left fields region + right Notes region. ---- */
|
||||
.fp-sticker-body-wrap {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
top: 40%; bottom: 0;
|
||||
top: 32%; bottom: 0;
|
||||
}
|
||||
.fp-body-left {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 64%;
|
||||
border-right: 2px solid #000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.fp-body-right {
|
||||
position: absolute;
|
||||
left: 64%; right: 0; top: 0; bottom: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table.fp-sticker-body {
|
||||
width: 100%;
|
||||
@@ -197,18 +222,18 @@
|
||||
}
|
||||
table.fp-sticker-body tr { height: 14.28%; }
|
||||
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
|
||||
col.fp-col-label { width: 32%; }
|
||||
col.fp-col-value { width: 68%; }
|
||||
col.fp-col-label { width: 38%; }
|
||||
col.fp-col-value { width: 62%; }
|
||||
table.fp-sticker-body td {
|
||||
vertical-align: middle;
|
||||
padding: 0 14px;
|
||||
font-size: 38pt;
|
||||
line-height: 1.1;
|
||||
padding: 0 8px;
|
||||
font-size: 50pt;
|
||||
line-height: 1.0;
|
||||
}
|
||||
td.fp-sticker-label {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-right: 2px solid #000;
|
||||
border-right: 1px solid #000;
|
||||
background-color: #f1f2f4;
|
||||
}
|
||||
td.fp-sticker-value {
|
||||
@@ -217,43 +242,55 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fp-sticker-strong { font-weight: 700; }
|
||||
.fp-sticker-muted { color: #555; font-size: 28pt; }
|
||||
.fp-sticker-muted { color: #555; font-size: 30pt; }
|
||||
/* Notes column on the right side of the body. */
|
||||
.fp-notes-label {
|
||||
font-weight: 700;
|
||||
font-size: 48pt;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.fp-notes-content {
|
||||
font-size: 36pt;
|
||||
line-height: 1.1;
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Per-box loop: renders one sticker page per physical box in
|
||||
the line/job qty. When _qty_total is missing/0/1, falls
|
||||
back to a single render (no "X / N" indicator). -->
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
<!-- 3-cell header: Logo | WO# | QR -->
|
||||
<div class="fp-sticker-head-wrap">
|
||||
<table class="fp-sticker-head">
|
||||
<colgroup>
|
||||
<col class="fp-col-head-left"/>
|
||||
<col class="fp-col-head-right"/>
|
||||
<col class="fp-col-head-logo"/>
|
||||
<col class="fp-col-head-wo"/>
|
||||
<col class="fp-col-head-qr"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-head-left">
|
||||
<td class="fp-sticker-head-logo">
|
||||
<!-- env.company.logo is often blank while logo_web
|
||||
is populated from the company partner's image.
|
||||
Fall back across both + partner.image_1920. -->
|
||||
is populated from the partner's image. Fall
|
||||
back across both + partner.image_1920. -->
|
||||
<t t-set="_logo" t-value="env.company.logo
|
||||
or env.company.logo_web
|
||||
or env.company.partner_id.image_1920
|
||||
or False"/>
|
||||
<table class="fp-sticker-head-left-stack">
|
||||
<tr class="fp-row-logo">
|
||||
<td>
|
||||
<img t-if="_logo"
|
||||
class="fp-sticker-logo"
|
||||
t-att-src="image_data_uri(_logo)"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="fp-row-wo">
|
||||
<td>
|
||||
<div class="fp-sticker-wo">
|
||||
WO #<span t-esc="_order_id"/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<img t-if="_logo"
|
||||
class="fp-sticker-logo"
|
||||
t-att-src="image_data_uri(_logo)"/>
|
||||
</td>
|
||||
<td class="fp-sticker-head-right">
|
||||
<td class="fp-sticker-head-wo">
|
||||
<div class="fp-sticker-wo">
|
||||
<span t-esc="_order_id"/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fp-sticker-qr-wrap" t-if="_qr_src">
|
||||
<img class="fp-sticker-qr"
|
||||
t-att-src="_qr_src"/>
|
||||
@@ -263,92 +300,95 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Body: 7-row field table on the left, full-height Notes
|
||||
column on the right showing the customer-facing description. -->
|
||||
<div class="fp-sticker-body-wrap">
|
||||
<table class="fp-sticker-body">
|
||||
<colgroup>
|
||||
<col class="fp-col-label"/>
|
||||
<col class="fp-col-value"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">PO (RO):</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_po_number"/>
|
||||
<t t-if="_mo_ref">
|
||||
<span class="fp-sticker-muted">
|
||||
(<span t-esc="_mo_ref"/>)
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Customer:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_partner_name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Process:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_process">
|
||||
<span t-esc="_process.name"/>
|
||||
</t>
|
||||
<t t-elif="_coating">
|
||||
<span t-esc="_coating.name"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Part Number:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_part">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_part.part_number"/>
|
||||
<t t-if="_part.revision">
|
||||
<!-- Some parts store the revision with a
|
||||
"Rev " prefix already (e.g. "Rev 1"),
|
||||
others store just the value ("1", "A").
|
||||
Strip a leading "Rev " (case insensitive)
|
||||
so we don't print "Rev Rev 1". -->
|
||||
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
|
||||
<t t-if="_rev_clean.lower().startswith('rev ')">
|
||||
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
|
||||
<div class="fp-body-left">
|
||||
<table class="fp-sticker-body">
|
||||
<colgroup>
|
||||
<col class="fp-col-label"/>
|
||||
<col class="fp-col-value"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">PO #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_po_number"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">SN #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_serial_number"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Customer:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_partner_display"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Part #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_part">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_part.part_number"/>
|
||||
<t t-if="_part.revision">
|
||||
<!-- Strip "Rev " prefix if the field
|
||||
value already includes it, so we
|
||||
don't print "Rev Rev 1". -->
|
||||
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
|
||||
<t t-if="_rev_clean.lower().startswith('rev ')">
|
||||
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
|
||||
</t>
|
||||
<span class="fp-sticker-muted">
|
||||
Rev <span t-esc="_rev_clean"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<span class="fp-sticker-muted">
|
||||
Rev <span t-esc="_rev_clean"/>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Due Date:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_due">
|
||||
<span t-esc="_due.strftime('%b %d, %Y')"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Thickness:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_thickness"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<t t-if="_qty_total and int(_qty_total) > 1">
|
||||
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Due Date:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_due">
|
||||
<span t-esc="_due.strftime('%b %d, %Y')"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Notes:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-esc="_internal_note"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="fp-body-right">
|
||||
<div class="fp-notes-label">Notes:</div>
|
||||
<div class="fp-notes-content">
|
||||
<t t-esc="_notes_content"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- =====================================================
|
||||
@@ -370,6 +410,8 @@
|
||||
<t t-set="_mo_ref" t-value="False"/>
|
||||
<t t-set="_internal_note" t-value="False"/>
|
||||
<t t-set="_scan_path" t-value="False"/>
|
||||
<t t-set="_notes_content" t-value="False"/>
|
||||
<t t-set="_qty_total" t-value="False"/>
|
||||
</template>
|
||||
|
||||
<!-- ========== Outer template — mrp.workorder entry ========== -->
|
||||
@@ -407,18 +449,19 @@
|
||||
skipped — they don't go through plating so they don't need a
|
||||
box sticker.
|
||||
|
||||
The "WO #" header shows "<SO>/<line seq>" so the sticker
|
||||
remains identifiable before the fp.job is generated. The QR
|
||||
encodes /fp/so-line/<line.id> — the controller can decide
|
||||
whether to land on the parent SO, the line, or (later) the
|
||||
spawned job. -->
|
||||
The "WO#" header shows the SO name (e.g. SO-30019). The body
|
||||
carries the part-specific fields (Part #, Customer, etc.) which
|
||||
disambiguate multi-line SOs without needing a sequence suffix.
|
||||
The QR encodes /fp/so-line/<line.id> — the controller can
|
||||
decide whether to land on the parent SO, the line, or (later)
|
||||
the spawned job. -->
|
||||
<template id="report_fp_so_sticker">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name + ' / ' + str(line.sequence or line.id)"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
@@ -428,6 +471,7 @@
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
@@ -436,4 +480,37 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ========== Outer template — sale.order Internal variant ==========
|
||||
Same layout + iteration as report_fp_so_sticker, but pre-sets
|
||||
_notes_content from x_fc_internal_description (Sub 2 internal
|
||||
description field) so the Notes column shows the ops-facing
|
||||
description instead of line.name. -->
|
||||
<template id="report_fp_so_sticker_internal">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="so"/>
|
||||
<t t-set="_line" t-value="line"/>
|
||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description -->
|
||||
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
|
||||
and line.x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
258
fusion_whitelabels/data/fusion_whitelabels_mail_templates.xml
Normal file
258
fusion_whitelabels/data/fusion_whitelabels_mail_templates.xml
Normal file
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!--
|
||||
Override auth_signup.set_password_email
|
||||
Original: heavy Odoo branding in body and subject
|
||||
(Welcome to Odoo, "connect to Odoo", "Odoo Tour", "Powered by Odoo", etc.)
|
||||
Whitelabeled: company-branded invite, no Odoo references.
|
||||
-->
|
||||
<record id="auth_signup.set_password_email" model="mail.template">
|
||||
<field name="subject">{{ object.create_uid.name }} from {{ object.company_id.name }} invites you to your account</field>
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle">
|
||||
<span style="font-size: 10px;">Welcome to <t t-out="object.company_id.name or ''">YourCompany</t></span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;">
|
||||
<t t-out="object.name or ''">Marc Demo</t>
|
||||
</span>
|
||||
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
|
||||
</td></tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
You have been invited by <t t-out="object.create_uid.name or ''">Admin</t> at <t t-out="object.company_id.name or ''">YourCompany</t> to access your account.
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
<a t-att-href="object.partner_id._get_signup_url()"
|
||||
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
|
||||
Accept invitation
|
||||
</a>
|
||||
</div>
|
||||
<b>This link will remain valid for <t t-out="int(int(object.env['ir.config_parameter'].sudo().get_param('auth_signup.signup.validity.hours',144))/24)"></t> days.</b><br/>
|
||||
<t t-set="website_url" t-value="object.get_base_url()"></t>
|
||||
Sign-in URL: <b><a t-att-href='website_url' t-out="website_url or ''">https://yourcompany.com</a></b><br/>
|
||||
Your sign-in email: <b><a t-attf-href="/web/login?login={{ object.email }}" target="_blank" t-out="object.email or ''">user@example.com</a></b><br/><br/>
|
||||
Welcome aboard!<br/>
|
||||
--<br/>The <t t-out="object.company_id.name or ''">YourCompany</t> Team
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-att-href="'mailto:%s' % object.company_id.email" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="'%s' % object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Override auth_signup.mail_template_user_signup_account_created
|
||||
(sent to portal users who self-registered)
|
||||
Just removes the "Powered by Odoo" footer block.
|
||||
-->
|
||||
<record id="auth_signup.mail_template_user_signup_account_created" model="mail.template">
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle">
|
||||
<span style="font-size: 10px;">Your Account</span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;">
|
||||
<t t-out="object.name or ''">Marc Demo</t>
|
||||
</span>
|
||||
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
|
||||
</td></tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
Your account has been successfully created!<br/>
|
||||
Your login is <strong><t t-out="object.email or ''">mark.brown23@example.com</t></strong><br/>
|
||||
To gain access to your account, you can use the following link:
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
<a t-attf-href="/web/login?auth_login={{object.email}}"
|
||||
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
|
||||
Go to My Account
|
||||
</a>
|
||||
</div>
|
||||
Thanks,<br/>
|
||||
<t t-if="user.signature">
|
||||
<br/>
|
||||
<div>--<br/><t t-out="user.signature or ''">Mitchell Admin</t></div>
|
||||
</t>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-attf-href="mailto:{{object.company_id.email}}" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Override auth_signup.portal_set_password_email
|
||||
(sent to new portal users when admin invites them)
|
||||
Just removes the "Powered by Odoo" footer block.
|
||||
-->
|
||||
<record id="auth_signup.portal_set_password_email" model="mail.template">
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
|
||||
<tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
<span style="font-size: 10px;">Your Account</span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Marc Demo</span>
|
||||
</td>
|
||||
<td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;"
|
||||
t-att-alt="object.company_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
Welcome to <t t-out="object.company_id.name">YourCompany</t>'s Portal!<br/><br/>
|
||||
An account has been created for you with the following login: <t t-out="object.login">demo</t><br/><br/>
|
||||
Click on the button below to pick a password and activate your account.
|
||||
<div style="margin: 16px 0px 16px 0px; text-align: center;">
|
||||
<a t-att-href="object.partner_id._get_signup_url()"
|
||||
t-attf-style="display: inline-block; padding: 10px; text-decoration: none; font-size: 12px; background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px;">
|
||||
<strong>Activate Account</strong>
|
||||
</a>
|
||||
</div>
|
||||
<t t-out="ctx.get('welcome_message') or ''">Welcome to our company's portal.</t>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-attf-href="mailto:{{ object.company_id.email }}" style="text-decoration: none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="object.company_id.website" style="text-decoration: none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
from odoo.addons.fusion_whitelabels import _apply_mail_overrides
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
_apply_mail_overrides(env)
|
||||
26
nexa_coa_setup/README.md
Normal file
26
nexa_coa_setup/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Nexa Systems — Chart of Accounts Setup
|
||||
|
||||
Custom Odoo 19 module that configures the chart of accounts, taxes,
|
||||
fiscal positions, analytic plans, and partner records for Nexa Systems Inc.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
|
||||
-i nexa_coa_setup --no-http --stop-after-init
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
```
|
||||
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
|
||||
-u nexa_coa_setup --no-http --stop-after-init
|
||||
```
|
||||
|
||||
## Design reference
|
||||
|
||||
See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.
|
||||
|
||||
## Safety
|
||||
|
||||
Always take a pg_dump BEFORE running `-i` or `-u`. See `docs/superpowers/plans/2026-05-12-nexa-coa-setup.md` Phase 0.
|
||||
2
nexa_coa_setup/__init__.py
Normal file
2
nexa_coa_setup/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from .hooks import pre_init_hook, post_init_hook
|
||||
39
nexa_coa_setup/__manifest__.py
Normal file
39
nexa_coa_setup/__manifest__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
{
|
||||
"name": "Nexa Systems — Chart of Accounts Setup",
|
||||
"version": "19.0.1.0.0",
|
||||
"category": "Accounting/Localizations/Chart of Accounts",
|
||||
"summary": "Custom CoA, taxes, fiscal positions, analytic plans, and intercompany partner setup for Nexa Systems Inc.",
|
||||
"author": "Nexa Systems Inc.",
|
||||
"website": "https://nexasystems.ca",
|
||||
"license": "OPL-1",
|
||||
"depends": [
|
||||
"account",
|
||||
"account_accountant",
|
||||
"l10n_ca",
|
||||
"analytic",
|
||||
"sale_management",
|
||||
"purchase",
|
||||
"sale_subscription",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/01_account_account.xml",
|
||||
"data/02_account_journal.xml",
|
||||
"data/03_account_tax.xml",
|
||||
"data/04_account_fiscal_position.xml",
|
||||
"data/05_account_analytic_plan.xml",
|
||||
"data/06_account_analytic_account.xml",
|
||||
"data/07_product_category.xml",
|
||||
"data/08_res_partner_category.xml",
|
||||
"data/09_res_partner.xml",
|
||||
"data/10_account_reconcile_model.xml",
|
||||
],
|
||||
"pre_init_hook": "pre_init_hook",
|
||||
"post_init_hook": "post_init_hook",
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
808
nexa_coa_setup/data/01_account_account.xml
Normal file
808
nexa_coa_setup/data/01_account_account.xml
Normal file
@@ -0,0 +1,808 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 1xxxxx — ASSETS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- NOTE: 115100 is l10n_ca "Customers Account" (240 postings — AR control) — kept as l10n_ca.
|
||||
115110 is l10n_ca "Customers Account (PoS)" — kept.
|
||||
Nexa intercompany receivables live in the 119xxx range to avoid all collisions. -->
|
||||
<record id="acct_119100" model="account.account">
|
||||
<field name="code">119100</field>
|
||||
<field name="name">Due From Shareholder — Gurpreet</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="acct_119900" model="account.account">
|
||||
<field name="code">119900</field>
|
||||
<field name="name">Due From Associated Corporations</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="acct_118100" model="account.account">
|
||||
<field name="code">118100</field>
|
||||
<field name="name">HST/GST Input Tax Credit (ITC) Receivable</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_118200" model="account.account">
|
||||
<field name="code">118200</field>
|
||||
<field name="name">HST/GST Instalments Paid</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_118300" model="account.account">
|
||||
<field name="code">118300</field>
|
||||
<field name="name">QST Input Tax Refund Receivable</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151100" model="account.account">
|
||||
<field name="code">151100</field>
|
||||
<field name="name">Computer Hardware & Equipment (CCA Class 50)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151200" model="account.account">
|
||||
<field name="code">151200</field>
|
||||
<field name="name">Office Furniture & Equipment (CCA Class 8)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151300" model="account.account">
|
||||
<field name="code">151300</field>
|
||||
<field name="name">Vehicles (CCA Class 10/10.1)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151400" model="account.account">
|
||||
<field name="code">151400</field>
|
||||
<field name="name">Leasehold Improvements (CCA Class 13)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151500" model="account.account">
|
||||
<field name="code">151500</field>
|
||||
<field name="name">Acquired Software & Intangibles (CCA Class 14.1)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_151600" model="account.account">
|
||||
<field name="code">151600</field>
|
||||
<field name="name">Tools & Small Equipment <$500 (CCA Class 12)</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_154100" model="account.account">
|
||||
<field name="code">154100</field>
|
||||
<field name="name">Acc. Depreciation — Computer Hardware</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_154200" model="account.account">
|
||||
<field name="code">154200</field>
|
||||
<field name="name">Acc. Depreciation — Office Furniture</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_154300" model="account.account">
|
||||
<field name="code">154300</field>
|
||||
<field name="name">Acc. Depreciation — Vehicles</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_154400" model="account.account">
|
||||
<field name="code">154400</field>
|
||||
<field name="name">Acc. Depreciation — Leasehold Improvements</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_154500" model="account.account">
|
||||
<field name="code">154500</field>
|
||||
<field name="name">Acc. Depreciation — Acquired Software</field>
|
||||
<field name="account_type">asset_fixed</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 2xxxxx — LIABILITIES -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="acct_213100" model="account.account">
|
||||
<field name="code">213100</field>
|
||||
<field name="name">HST/GST Collected on Sales</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_213500" model="account.account">
|
||||
<field name="code">213500</field>
|
||||
<field name="name">QST Collected on Sales</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_214100" model="account.account">
|
||||
<field name="code">214100</field>
|
||||
<field name="name">Net HST/GST Payable</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_215100" model="account.account">
|
||||
<field name="code">215100</field>
|
||||
<field name="name">Source Deductions Payable — Federal Tax</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_215200" model="account.account">
|
||||
<field name="code">215200</field>
|
||||
<field name="name">Source Deductions Payable — CPP</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_215300" model="account.account">
|
||||
<field name="code">215300</field>
|
||||
<field name="name">Source Deductions Payable — EI</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_216100" model="account.account">
|
||||
<field name="code">216100</field>
|
||||
<field name="name">Corporate Income Tax — Federal Payable</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_216200" model="account.account">
|
||||
<field name="code">216200</field>
|
||||
<field name="name">Corporate Income Tax — Provincial Payable</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_216300" model="account.account">
|
||||
<field name="code">216300</field>
|
||||
<field name="name">Corporate Tax Instalments Paid</field>
|
||||
<field name="account_type">asset_current</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_221100" model="account.account">
|
||||
<field name="code">221100</field>
|
||||
<field name="name">Due To Shareholder — Gurpreet (short-term)</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="acct_221200" model="account.account">
|
||||
<field name="code">221200</field>
|
||||
<field name="name">Shareholder Loan — Gurpreet (long-term)</field>
|
||||
<field name="account_type">liability_non_current</field>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="acct_222900" model="account.account">
|
||||
<field name="code">222900</field>
|
||||
<field name="name">Due To Associated Corporations</field>
|
||||
<field name="account_type">liability_current</field>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 3xxxxx — EQUITY -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="acct_311100" model="account.account">
|
||||
<field name="code">311100</field>
|
||||
<field name="name">Share Capital — Common Shares</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_311200" model="account.account">
|
||||
<field name="code">311200</field>
|
||||
<field name="name">Share Capital — Preferred Shares</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_311300" model="account.account">
|
||||
<field name="code">311300</field>
|
||||
<field name="name">Contributed Surplus</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_321100" model="account.account">
|
||||
<field name="code">321100</field>
|
||||
<field name="name">Retained Earnings — Current Year</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_321200" model="account.account">
|
||||
<field name="code">321200</field>
|
||||
<field name="name">Retained Earnings — Prior Years</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_321900" model="account.account">
|
||||
<field name="code">321900</field>
|
||||
<field name="name">Dividends Declared</field>
|
||||
<field name="account_type">equity</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 4xxxxx — REVENUE -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="acct_411100" model="account.account">
|
||||
<field name="code">411100</field>
|
||||
<field name="name">SaaS Subscription Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_411200" model="account.account">
|
||||
<field name="code">411200</field>
|
||||
<field name="name">Hosting & Infrastructure Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_411300" model="account.account">
|
||||
<field name="code">411300</field>
|
||||
<field name="name">Support & Maintenance Contracts Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_411400" model="account.account">
|
||||
<field name="code">411400</field>
|
||||
<field name="name">Domain/SSL/Renewal Pass-through Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_411500" model="account.account">
|
||||
<field name="code">411500</field>
|
||||
<field name="name">Setup / Onboarding Fees Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412100" model="account.account">
|
||||
<field name="code">412100</field>
|
||||
<field name="name">Custom Software Development Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412200" model="account.account">
|
||||
<field name="code">412200</field>
|
||||
<field name="name">Custom Web Application Development Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412300" model="account.account">
|
||||
<field name="code">412300</field>
|
||||
<field name="name">Custom Website Development Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412400" model="account.account">
|
||||
<field name="code">412400</field>
|
||||
<field name="name">ERP Implementation & Customization Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412500" model="account.account">
|
||||
<field name="code">412500</field>
|
||||
<field name="name">Mobile App Development Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_412600" model="account.account">
|
||||
<field name="code">412600</field>
|
||||
<field name="name">Business App / Integration Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_413100" model="account.account">
|
||||
<field name="code">413100</field>
|
||||
<field name="name">Consulting & Advisory Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_413200" model="account.account">
|
||||
<field name="code">413200</field>
|
||||
<field name="name">Training & Workshops Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_413300" model="account.account">
|
||||
<field name="code">413300</field>
|
||||
<field name="name">Technical Support — Per-incident / Hourly Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_414100" model="account.account">
|
||||
<field name="code">414100</field>
|
||||
<field name="name">Third-party Software Resale Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_414200" model="account.account">
|
||||
<field name="code">414200</field>
|
||||
<field name="name">Hardware Resale Revenue</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_419100" model="account.account">
|
||||
<field name="code">419100</field>
|
||||
<field name="name">Sales Discounts</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_419200" model="account.account">
|
||||
<field name="code">419200</field>
|
||||
<field name="name">Sales Returns & Refunds</field>
|
||||
<field name="account_type">income</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_419300" model="account.account">
|
||||
<field name="code">419300</field>
|
||||
<field name="name">Bad Debt Recovery</field>
|
||||
<field name="account_type">income_other</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 5xxxxx — DIRECT COSTS (COGS) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- NOTE: 511100 was "Inside Purchases" in l10n_ca (1 posting from legacy bookkeeping) — kept as l10n_ca.
|
||||
Cloud Infrastructure sits at 511105 to avoid collision. -->
|
||||
<record id="acct_511105" model="account.account">
|
||||
<field name="code">511105</field>
|
||||
<field name="name">Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511110" model="account.account">
|
||||
<field name="code">511110</field>
|
||||
<field name="name">CDN & Edge Services (Cloudflare, Fastly)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511120" model="account.account">
|
||||
<field name="code">511120</field>
|
||||
<field name="name">Backup & Storage Services</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511130" model="account.account">
|
||||
<field name="code">511130</field>
|
||||
<field name="name">Database & Backend Services (Supabase, hosted Postgres, Redis)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511140" model="account.account">
|
||||
<field name="code">511140</field>
|
||||
<field name="name">Monitoring & Observability (customer-facing only)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511150" model="account.account">
|
||||
<field name="code">511150</field>
|
||||
<field name="name">SSL Certificates & Domains (wholesale for resale)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511160" model="account.account">
|
||||
<field name="code">511160</field>
|
||||
<field name="name">DNS & Email Hosting (wholesale for resale)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511200" model="account.account">
|
||||
<field name="code">511200</field>
|
||||
<field name="name">Third-party API Costs (Twilio, SendGrid, OpenAI)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_511210" model="account.account">
|
||||
<field name="code">511210</field>
|
||||
<field name="name">Per-customer Licensing & Royalties</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_512100" model="account.account">
|
||||
<field name="code">512100</field>
|
||||
<field name="name">Subcontracted Labour — Canadian (T4A) — SR&ED-eligible</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_512110" model="account.account">
|
||||
<field name="code">512110</field>
|
||||
<field name="name">Subcontracted Labour — Foreign — NOT SR&ED-eligible</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_512200" model="account.account">
|
||||
<field name="code">512200</field>
|
||||
<field name="name">Project-specific Software & Licenses</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_512300" model="account.account">
|
||||
<field name="code">512300</field>
|
||||
<field name="name">Project Travel & Onsite (rebilled)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_512400" model="account.account">
|
||||
<field name="code">512400</field>
|
||||
<field name="name">Project Hardware (passed through)</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_513100" model="account.account">
|
||||
<field name="code">513100</field>
|
||||
<field name="name">Cost of Software Resold</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_513200" model="account.account">
|
||||
<field name="code">513200</field>
|
||||
<field name="name">Cost of Hardware Resold</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_519100" model="account.account">
|
||||
<field name="code">519100</field>
|
||||
<field name="name">COGS Adjustments / Write-offs</field>
|
||||
<field name="account_type">expense_direct_cost</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 6xxxxx — OPERATING EXPENSES -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="acct_611100" model="account.account">
|
||||
<field name="code">611100</field>
|
||||
<field name="name">Salaries & Wages — Development (SR&ED-eligible)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611200" model="account.account">
|
||||
<field name="code">611200</field>
|
||||
<field name="name">Salaries & Wages — Sales & Marketing</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611300" model="account.account">
|
||||
<field name="code">611300</field>
|
||||
<field name="name">Salaries & Wages — Admin & Operations</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611400" model="account.account">
|
||||
<field name="code">611400</field>
|
||||
<field name="name">Salary — Shareholder/Officer (Gurpreet)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611500" model="account.account">
|
||||
<field name="code">611500</field>
|
||||
<field name="name">Employer CPP / QPP Contributions</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611600" model="account.account">
|
||||
<field name="code">611600</field>
|
||||
<field name="name">Employer EI Premiums</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611700" model="account.account">
|
||||
<field name="code">611700</field>
|
||||
<field name="name">Employer Health Tax (EHT/QHST)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611800" model="account.account">
|
||||
<field name="code">611800</field>
|
||||
<field name="name">WCB / WSIB Premiums</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611900" model="account.account">
|
||||
<field name="code">611900</field>
|
||||
<field name="name">Employee Benefits (health, dental, group)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611950" model="account.account">
|
||||
<field name="code">611950</field>
|
||||
<field name="name">Bonuses & Incentives</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_611960" model="account.account">
|
||||
<field name="code">611960</field>
|
||||
<field name="name">Vacation Pay Accrual</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_612100" model="account.account">
|
||||
<field name="code">612100</field>
|
||||
<field name="name">Contract Labour — Canadian (admin/marketing/freelance)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_612200" model="account.account">
|
||||
<field name="code">612200</field>
|
||||
<field name="name">Contract Labour — Foreign</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621100" model="account.account">
|
||||
<field name="code">621100</field>
|
||||
<field name="name">Rent — Commercial Office</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621200" model="account.account">
|
||||
<field name="code">621200</field>
|
||||
<field name="name">Home Office — Business Portion</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621300" model="account.account">
|
||||
<field name="code">621300</field>
|
||||
<field name="name">Utilities — Commercial</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621400" model="account.account">
|
||||
<field name="code">621400</field>
|
||||
<field name="name">Internet & Phone — Business</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621500" model="account.account">
|
||||
<field name="code">621500</field>
|
||||
<field name="name">Office Supplies & Consumables</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621600" model="account.account">
|
||||
<field name="code">621600</field>
|
||||
<field name="name">Cleaning & Maintenance</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_621700" model="account.account">
|
||||
<field name="code">621700</field>
|
||||
<field name="name">Office Snacks & Refreshments</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_631100" model="account.account">
|
||||
<field name="code">631100</field>
|
||||
<field name="name">Software — Productivity (M365, Slack, Notion, Linear, GitHub)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_631200" model="account.account">
|
||||
<field name="code">631200</field>
|
||||
<field name="name">Software — Development Tools (Cursor, Figma, IDEs)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_631300" model="account.account">
|
||||
<field name="code">631300</field>
|
||||
<field name="name">Software — Internal Infrastructure</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_631400" model="account.account">
|
||||
<field name="code">631400</field>
|
||||
<field name="name">Software — Security & IT</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_631500" model="account.account">
|
||||
<field name="code">631500</field>
|
||||
<field name="name">Software — Sales & Marketing</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_641100" model="account.account">
|
||||
<field name="code">641100</field>
|
||||
<field name="name">Advertising — Digital Ads</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_641200" model="account.account">
|
||||
<field name="code">641200</field>
|
||||
<field name="name">Advertising — Content / SEO</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_641300" model="account.account">
|
||||
<field name="code">641300</field>
|
||||
<field name="name">Trade Shows & Conferences</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_641400" model="account.account">
|
||||
<field name="code">641400</field>
|
||||
<field name="name">Promotional Items / Branded Swag</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_641500" model="account.account">
|
||||
<field name="code">641500</field>
|
||||
<field name="name">Website — Own (nexasystems.ca)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_651100" model="account.account">
|
||||
<field name="code">651100</field>
|
||||
<field name="name">Legal Fees — General</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_651200" model="account.account">
|
||||
<field name="code">651200</field>
|
||||
<field name="name">Accounting & Bookkeeping</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_651300" model="account.account">
|
||||
<field name="code">651300</field>
|
||||
<field name="name">Tax Preparation (T2, T1, GST/HST)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_651400" model="account.account">
|
||||
<field name="code">651400</field>
|
||||
<field name="name">Business Consulting</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_661100" model="account.account">
|
||||
<field name="code">661100</field>
|
||||
<field name="name">Insurance — Commercial General Liability</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_661200" model="account.account">
|
||||
<field name="code">661200</field>
|
||||
<field name="name">Insurance — Professional Liability / E&O</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_661300" model="account.account">
|
||||
<field name="code">661300</field>
|
||||
<field name="name">Insurance — Cyber Liability</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_661400" model="account.account">
|
||||
<field name="code">661400</field>
|
||||
<field name="name">Insurance — Property</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_661500" model="account.account">
|
||||
<field name="code">661500</field>
|
||||
<field name="name">Insurance — Directors & Officers</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_671100" model="account.account">
|
||||
<field name="code">671100</field>
|
||||
<field name="name">Travel — Flights, Hotels, Ground Transport</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_671200" model="account.account">
|
||||
<field name="code">671200</field>
|
||||
<field name="name">Meals & Entertainment — 50% Deductible</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_671300" model="account.account">
|
||||
<field name="code">671300</field>
|
||||
<field name="name">Vehicle — Operating (gas, insurance, repairs, parking)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_671400" model="account.account">
|
||||
<field name="code">671400</field>
|
||||
<field name="name">Mileage Reimbursement — Personal Vehicle</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_681100" model="account.account">
|
||||
<field name="code">681100</field>
|
||||
<field name="name">Conferences & Seminars (registration)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_681200" model="account.account">
|
||||
<field name="code">681200</field>
|
||||
<field name="name">Courses & Certifications</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_681300" model="account.account">
|
||||
<field name="code">681300</field>
|
||||
<field name="name">Books & Publications</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_681400" model="account.account">
|
||||
<field name="code">681400</field>
|
||||
<field name="name">Professional Memberships & Dues</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691100" model="account.account">
|
||||
<field name="code">691100</field>
|
||||
<field name="name">Bank Service Charges</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691200" model="account.account">
|
||||
<field name="code">691200</field>
|
||||
<field name="name">Merchant Processing Fees (Stripe, PayPal, Square)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691300" model="account.account">
|
||||
<field name="code">691300</field>
|
||||
<field name="name">Wire Transfer & FX Fees</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691400" model="account.account">
|
||||
<field name="code">691400</field>
|
||||
<field name="name">Interest Expense — Bank Loans / LOC</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691500" model="account.account">
|
||||
<field name="code">691500</field>
|
||||
<field name="name">Interest Expense — Credit Cards</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_691600" model="account.account">
|
||||
<field name="code">691600</field>
|
||||
<field name="name">Late Payment Penalties — Non-deductible</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_699100" model="account.account">
|
||||
<field name="code">699100</field>
|
||||
<field name="name">Bad Debt Expense</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_699200" model="account.account">
|
||||
<field name="code">699200</field>
|
||||
<field name="name">Donations & Sponsorships (deductible)</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_699300" model="account.account">
|
||||
<field name="code">699300</field>
|
||||
<field name="name">Penalties & Fines — Non-deductible</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_699400" model="account.account">
|
||||
<field name="code">699400</field>
|
||||
<field name="name">Realized FX Losses</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
<record id="acct_699500" model="account.account">
|
||||
<field name="code">699500</field>
|
||||
<field name="name">Depreciation / CCA Expense</field>
|
||||
<field name="account_type">expense</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/02_account_journal.xml
Normal file
5
nexa_coa_setup/data/02_account_journal.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/03_account_tax.xml
Normal file
5
nexa_coa_setup/data/03_account_tax.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/04_account_fiscal_position.xml
Normal file
5
nexa_coa_setup/data/04_account_fiscal_position.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
24
nexa_coa_setup/data/05_account_analytic_plan.xml
Normal file
24
nexa_coa_setup/data/05_account_analytic_plan.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- Named 'Customer Project' to avoid collision with the project module's
|
||||
auto-created 'Project' plan. This is where customer-engagement
|
||||
analytic accounts (PRJ-YYYY-CUST-NAME) live. -->
|
||||
<record id="plan_project" model="account.analytic.plan">
|
||||
<field name="name">Customer Project</field>
|
||||
<field name="default_applicability">mandatory</field>
|
||||
</record>
|
||||
|
||||
<record id="plan_department" model="account.analytic.plan">
|
||||
<field name="name">Department</field>
|
||||
<field name="default_applicability">mandatory</field>
|
||||
</record>
|
||||
|
||||
<record id="plan_sred_tag" model="account.analytic.plan">
|
||||
<field name="name">SR&ED Tag</field>
|
||||
<field name="default_applicability">optional</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
74
nexa_coa_setup/data/06_account_analytic_account.xml
Normal file
74
nexa_coa_setup/data/06_account_analytic_account.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- Department analytic accounts -->
|
||||
<record id="aa_dept_dev" model="account.analytic.account">
|
||||
<field name="name">Development</field>
|
||||
<field name="code">DEPT-DEV</field>
|
||||
<field name="plan_id" ref="plan_department"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_dept_sales" model="account.analytic.account">
|
||||
<field name="name">Sales & Marketing</field>
|
||||
<field name="code">DEPT-SALES</field>
|
||||
<field name="plan_id" ref="plan_department"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_dept_admin" model="account.analytic.account">
|
||||
<field name="name">Admin & Operations</field>
|
||||
<field name="code">DEPT-ADMIN</field>
|
||||
<field name="plan_id" ref="plan_department"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_dept_hosting" model="account.analytic.account">
|
||||
<field name="name">Hosting Operations</field>
|
||||
<field name="code">DEPT-HOSTING</field>
|
||||
<field name="plan_id" ref="plan_department"/>
|
||||
</record>
|
||||
|
||||
<!-- SR&ED Tag analytic accounts -->
|
||||
<record id="aa_sred_t4_dev" model="account.analytic.account">
|
||||
<field name="name">T4 Dev Salary — full proxy</field>
|
||||
<field name="code">SRED-T4-DEV-SALARY</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_specified" model="account.analytic.account">
|
||||
<field name="name">Specified Employee Salary — 75% cap</field>
|
||||
<field name="code">SRED-SPECIFIED-EMPLOYEE</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_contr_ca_arm" model="account.analytic.account">
|
||||
<field name="name">Contractor CA Arm's Length — 80% eligible</field>
|
||||
<field name="code">SRED-CONTRACTOR-CA-ARM-LENGTH</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_contr_ca_naf" model="account.analytic.account">
|
||||
<field name="name">Contractor CA Non-Arm's Length</field>
|
||||
<field name="code">SRED-CONTRACTOR-CA-NON-ARM-LENGTH</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_materials" model="account.analytic.account">
|
||||
<field name="name">Materials Consumed in R&D</field>
|
||||
<field name="code">SRED-MATERIALS-CONSUMED</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_overhead_basis" model="account.analytic.account">
|
||||
<field name="name">Overhead Proxy Basis (direct labour basis)</field>
|
||||
<field name="code">SRED-OVERHEAD-PROXY-BASIS</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
<record id="aa_sred_not_eligible" model="account.analytic.account">
|
||||
<field name="name">Not Eligible (default)</field>
|
||||
<field name="code">NOT-ELIGIBLE</field>
|
||||
<field name="plan_id" ref="plan_sred_tag"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/07_product_category.xml
Normal file
5
nexa_coa_setup/data/07_product_category.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/08_res_partner_category.xml
Normal file
5
nexa_coa_setup/data/08_res_partner_category.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/09_res_partner.xml
Normal file
5
nexa_coa_setup/data/09_res_partner.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
5
nexa_coa_setup/data/10_account_reconcile_model.xml
Normal file
5
nexa_coa_setup/data/10_account_reconcile_model.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
</data>
|
||||
</odoo>
|
||||
134
nexa_coa_setup/hooks.py
Normal file
134
nexa_coa_setup/hooks.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# l10n_ca account codes that collide with the Nexa CoA design and that
|
||||
# l10n_ca pre-loads with 'income_other'/'expense'/etc. types we don't want.
|
||||
# Each of these is checked at pre_init: if it has zero postings we suffix
|
||||
# its code with '.OLD' and archive it so our XML can claim the code.
|
||||
# Codes with postings are LEFT ALONE — we renumbered the Nexa code instead
|
||||
# (115100 stays as l10n_ca 'Customers Account' AR; Nexa shareholder receivable
|
||||
# moved to 119100. 511100 stays as l10n_ca 'Inside Purchases'; Nexa Cloud
|
||||
# Infrastructure moved to 511105).
|
||||
_L10N_CA_COLLISION_CODES = [
|
||||
"118100", "118200", "118300",
|
||||
"213100", "214100",
|
||||
"221200",
|
||||
"311100", "311200", "311300",
|
||||
"411100", "411200", "411300",
|
||||
"413100", "413200", "413300",
|
||||
"511110", "511120", "511130", "511140", "511200", "511210",
|
||||
"512100", "512110", "512200",
|
||||
"611100", "611200", "611300",
|
||||
"612100", "612200",
|
||||
]
|
||||
|
||||
|
||||
def pre_init_hook(env):
|
||||
"""Run BEFORE XML data is loaded. Clear l10n_ca account codes that would
|
||||
collide with Nexa's chart of accounts."""
|
||||
_logger.info("nexa_coa_setup: pre_init_hook starting")
|
||||
_clear_l10n_ca_collisions(env)
|
||||
_logger.info("nexa_coa_setup: pre_init_hook complete")
|
||||
|
||||
|
||||
def _clear_l10n_ca_collisions(env):
|
||||
"""For each colliding code: if it has zero postings, rename to NNNNNN.OLD
|
||||
and set inactive. If it has postings, leave alone (Nexa code was renumbered
|
||||
in the XML to avoid the conflict)."""
|
||||
cleared = 0
|
||||
kept_with_postings = 0
|
||||
not_found = 0
|
||||
for code in _L10N_CA_COLLISION_CODES:
|
||||
acc = env["account.account"].search([("code", "=", code)], limit=1)
|
||||
if not acc:
|
||||
not_found += 1
|
||||
continue
|
||||
usage = env["account.move.line"].search_count([("account_id", "=", acc.id)])
|
||||
if usage > 0:
|
||||
_logger.info(
|
||||
"nexa_coa_setup: keeping l10n_ca account %s (%s) — %d postings exist",
|
||||
code, acc.name, usage,
|
||||
)
|
||||
kept_with_postings += 1
|
||||
continue
|
||||
new_code = f"{code}.OLD"
|
||||
# Skip if already suffixed (idempotency)
|
||||
if acc.code.endswith(".OLD"):
|
||||
continue
|
||||
acc.write({
|
||||
"code": new_code,
|
||||
"name": f"(l10n_ca LEGACY) {acc.name or acc.display_name}",
|
||||
"active": False,
|
||||
})
|
||||
cleared += 1
|
||||
_logger.info(
|
||||
"nexa_coa_setup: collision sweep — cleared %d, kept-with-postings %d, not-found %d",
|
||||
cleared, kept_with_postings, not_found,
|
||||
)
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Imperative one-shot operations after module data is loaded.
|
||||
|
||||
Each helper is idempotent — safe to re-run on -u.
|
||||
"""
|
||||
_logger.info("nexa_coa_setup: post_init_hook starting")
|
||||
_normalize_company_hst_number(env)
|
||||
_archive_unused_l10n_ca_accounts(env)
|
||||
_rename_legacy_accounts(env)
|
||||
_lock_fiscal_year_2025(env)
|
||||
_logger.info("nexa_coa_setup: post_init_hook complete")
|
||||
|
||||
|
||||
def _normalize_company_hst_number(env):
|
||||
"""Convert '741224877' to '741224877 RT0001' if not already in full form."""
|
||||
company = env.ref("base.main_company", raise_if_not_found=False)
|
||||
if not company:
|
||||
return
|
||||
vat = (company.partner_id.vat or "").strip()
|
||||
if vat == "741224877":
|
||||
company.partner_id.vat = "741224877 RT0001"
|
||||
_logger.info("nexa_coa_setup: normalized HST# to '741224877 RT0001'")
|
||||
|
||||
|
||||
def _archive_unused_l10n_ca_accounts(env):
|
||||
"""Stub — filled in Phase 4. Archives ~370 unused accounts."""
|
||||
pass
|
||||
|
||||
|
||||
def _rename_legacy_accounts(env):
|
||||
"""Stub — filled in Phase 4. Renames the 14xx/15xx legacy accounts."""
|
||||
pass
|
||||
|
||||
|
||||
def _lock_fiscal_year_2025(env):
|
||||
"""Try to set fiscalyear_lock_date = 2025-12-31 on main company.
|
||||
|
||||
If Odoo blocks the lock because unreconciled bank statement lines or other
|
||||
open items exist in the period, log a clear warning and continue. The user
|
||||
can set the lock manually via Accounting > Configuration > Settings > Lock
|
||||
Dates once those items are cleaned up.
|
||||
"""
|
||||
from datetime import date
|
||||
from odoo.exceptions import RedirectWarning, UserError, ValidationError
|
||||
company = env.ref("base.main_company", raise_if_not_found=False)
|
||||
if not company:
|
||||
return
|
||||
target = date(2025, 12, 31)
|
||||
if company.fiscalyear_lock_date and company.fiscalyear_lock_date >= target:
|
||||
_logger.info("nexa_coa_setup: fiscalyear_lock_date already at or after 2025-12-31")
|
||||
return
|
||||
try:
|
||||
company.fiscalyear_lock_date = target
|
||||
_logger.info("nexa_coa_setup: fiscalyear_lock_date set to 2025-12-31")
|
||||
except (RedirectWarning, UserError, ValidationError) as exc:
|
||||
_logger.warning(
|
||||
"nexa_coa_setup: could not auto-lock fiscal year 2025-12-31. "
|
||||
"Reason: %s. Set the lock manually via Accounting > Configuration > "
|
||||
"Settings > Lock Dates after the unreconciled items in the period "
|
||||
"are cleaned up.",
|
||||
exc,
|
||||
)
|
||||
1
nexa_coa_setup/models/__init__.py
Normal file
1
nexa_coa_setup/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# no custom models — placeholder for future extensions
|
||||
1
nexa_coa_setup/security/ir.model.access.csv
Normal file
1
nexa_coa_setup/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
Reference in New Issue
Block a user