Compare commits
19 Commits
b07f771d98
...
5b399fbdda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b399fbdda | ||
|
|
b5416d242c | ||
|
|
fdbbd2852a | ||
|
|
be109c9c79 | ||
|
|
78d633f63f | ||
|
|
95cb73d91a | ||
|
|
0d85063b5e | ||
|
|
765a0a4c82 | ||
|
|
daf1235e20 | ||
|
|
3d4f003aba | ||
|
|
6c6fb8d2a4 | ||
|
|
1b1bebdcd8 | ||
|
|
e0d1998811 | ||
|
|
bc3f584851 | ||
|
|
105909470f | ||
|
|
6e67fc5ce3 | ||
|
|
fd9d4e775b | ||
|
|
2de5491693 | ||
|
|
671820427a |
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
|
||||
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.**
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.15.10',
|
||||
'version': '19.0.18.15.14',
|
||||
'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
|
||||
|
||||
158
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal file
158
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# -*- 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.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
|
||||
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()
|
||||
# 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=_(
|
||||
'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.22.7',
|
||||
'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,101 @@
|
||||
# -*- 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')
|
||||
|
||||
|
||||
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."""
|
||||
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:
|
||||
return self.env['sale.order'].search(
|
||||
[('name', '=', self.invoice_origin)], limit=1,
|
||||
)
|
||||
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 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
|
||||
raise UserError(_(
|
||||
'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.\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 +105,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:
|
||||
|
||||
@@ -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=_(
|
||||
'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,66 @@ 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 _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 +395,32 @@ 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 resolved recipe id (lines sharing a recipe → one WO).
|
||||
# No-recipe lines get their own group each (preserves the legacy
|
||||
# "one job per line" behaviour for unrecipe'd SOs).
|
||||
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
|
||||
) or False
|
||||
if not tag:
|
||||
untagged_idx += 1
|
||||
tag = '__untagged_%d' % untagged_idx
|
||||
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if recipe:
|
||||
key = recipe.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 +433,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 +492,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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.13.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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user