19 Commits

Author SHA1 Message Date
gsinghpal
5b399fbdda fix(configurator): copy operator-input prompts when cloning recipe to part
_clone_subtree() in fp_part_composer_controller built node vals
manually and never copied source.input_ids — so 'Load Template'
copied the recipe tree structure but dropped every custom prompt,
leaving operators with empty data-capture screens. The fix iterates
input_ids and calls .copy({'node_id': new_node.id}) so kind,
target_min/max/unit, compliance_tag, hint, selection_options,
sequence — every field on the input model — flows through.

Verified on entech: ENP-ALUM-BASIC clone now shows all 105 prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:58:11 -04:00
gsinghpal
b5416d242c test(numbering): E2E walkthrough — quote -> SO -> WO -> IN -> CoC -> DLV -> RCV -> Hold -> RMA
Verified pass on entech (parent=30015): all linked docs share the
parent number, immutability + unlink-block + direct-invoice-block
all enforced. NCR/CAPA fall back to legacy sequences as designed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:29 -04:00
gsinghpal
fdbbd2852a fix(numbering): WO Detail report strips WO- prefix for compact display
short_wo now handles both naming schemes: new WO-NNNNN[-NN] (strips
WO-) and legacy WH/JOB/NNNNN (last slash segment). Customer-facing
Work Order column shows '30000-02' instead of 'WO-30000-02'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:09 -04:00
gsinghpal
be109c9c79 feat(numbering): surface quote ref under SO name on the form
A small grey 'Originally quoted as Q202605-200' line appears below
the SO heading once the order is confirmed. Uses invisible= on the
wrapper div (Odoo 19 forbids t-if in standard form views).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:22 -04:00
gsinghpal
78d633f63f feat(numbering): immutable name/doc_index + unlink block on issued docs
write() override raises UserError if name or x_fc_doc_index is in
vals and differs from the stored value (bypass: context flag
fp_allow_name_rename=True for the SO-confirm rename + bulk WO
creation paths). unlink() override raises UserError for records
that have been issued a name; applies to all users including
admins — cancellation must go through the state machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:39:03 -04:00
gsinghpal
95cb73d91a feat(numbering): wire NCR, CAPA, Hold, RMA into parent-numbered mixin
Hold derives parent via job_id.sale_order_id; RMA via sale_order_id
directly — both get HOLD-<parent> / RMA-<parent> names. NCR and CAPA
have no SO link in core, so they fall back to their legacy sequences
(NCR/YYYY/NNN, CAPA/YYYY/NNN); future modules can override the
_fp_parent_sale_order hook to enable parent naming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:36:29 -04:00
gsinghpal
0d85063b5e feat(numbering): wire CoC/RCV/DLV/PU into parent-numbered mixin + rename counters
Per-model counter fields on sale.order renamed to x_fc_pn_*_count
to avoid collision with pre-existing compute fields of the same
short name in bridge_mrp / receiving / configurator (silent
compute-override was suppressing the storage). 4 child models
(fp.certificate, fp.receiving, fusion.plating.delivery,
fusion.plating.pickup.request) now derive names as PFX-<parent>
with -NN suffix from the 2nd onward.

fusion.plating.pickup.request gains a sale_order_id field
(optional) so pickups created against an SO get parent-derived
names, while standalone pickups (pre-SO) fall back to PU/YYYY/NNNN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:30:37 -04:00
gsinghpal
765a0a4c82 feat(numbering): block direct invoice creation + wire account.move into mixin
Customer invoices (out_invoice / out_refund) can only be created via
sale.order._create_invoices() or with an invoice_origin matching an
existing SO. Applies to ALL users including admins. Once created,
the move's name is derived from the SO's parent number: IN-30000,
IN-30000-02, CN-30000, ... Pre-existing portal-job link on
action_post() preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:21:09 -04:00
gsinghpal
daf1235e20 docs(nexa-coa): annual HST + T2 filing cadence; HST# normalization
Captures user-confirmed CRA registration & filing setup:
- Annual GST/HST filer (return Mar 31, instalments if prior net tax ≥ \$3k)
- Annual T2 filer (return Jun 30, balance due Mar 31 for CCPC)
- HST# 741224877 currently stored as 9-digit BN root only; normalize to
  full 15-char '741224877 RT0001' for tax-report validation
- Quick Method opportunity downgraded — \$400k threshold applies to
  associated-group totals; Nexa+Westin+Divine combined likely exceeds it
- Add HST cadence escalation flag (quarterly auto-trigger at \$1.5M)
- Acceptance criteria expanded with HST# format, filer config, and
  intercompany invoice test case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:20:16 -04:00
gsinghpal
3d4f003aba docs(nexa-coa): treat Westin & Divine as associated corps
Restructure Section 9 to handle Westin Healthcare Inc and Divine Mobility
Inc as Gurpreet's associated corporations (ITA s.256):
- Future intercompany flows go through normal AR/AP with partner records
  tagged 'RP-Associated', not slush 'Due to/from' GL buckets
- 'Due to/from Associated Corporations' now reserved only for true
  intercompany loans (no invoice)
- Surface SBD $500k sharing and SR&ED $3M sharing rules; Schedule 23
  allocation drives major annual tax decisions
- Manpreet account archived (employee of another corp, not Nexa-related)
- Add transfer-pricing risk flag (ITA s.247, 10% penalty)
- Add multi-company Odoo as future sub-project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:52 -04:00
gsinghpal
6c6fb8d2a4 feat(numbering): WO grouping by recipe + parent-derived bulk naming
Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping.
Bare WO-<parent> when 1 recipe, WO-<parent>-NN zero-padded for N>1
ordered by min line sequence. fp.job inherits parent-numbered mixin
for the manual-add path; bulk SO-confirm sets names explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:10 -04:00
gsinghpal
1b1bebdcd8 feat(numbering): assign parent_number + rename to SO-<n> on confirm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:14:47 -04:00
gsinghpal
e0d1998811 feat(numbering): draw quote name from fp.quote.number on SO create
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:12:45 -04:00
gsinghpal
bc3f584851 feat(numbering): add parent_number + counters to sale.order
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:10:55 -04:00
gsinghpal
105909470f feat(numbering): add fp.parent.numbered.mixin abstract model
Atomic counter via SELECT FOR UPDATE on the parent SO row. Composes
child names as PREFIX-PARENT (bare for first) or PREFIX-PARENT-NN
(zero-padded 2-digit, then unpadded past 99). Subclasses implement
three hooks: _fp_parent_sale_order, _fp_name_prefix, _fp_parent_counter_field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:09:17 -04:00
gsinghpal
6e67fc5ce3 docs: nexa systems CoA + accounting setup design spec
Comprehensive chart-of-accounts redesign for odoo-nexa nexamain DB:
hybrid approach over l10n_ca, three analytic plans (Project/Department/SR&ED
Tag), fiscal positions for auto tax handling, cleanup plan for the
~370 unused accounts and 49 messy taxes, automation hooks via product
categories and bank reconciliation rules.

Goals: CRA compliance, SR&ED claim infrastructure, zero-rated export
handling, one-click invoicing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:08:40 -04:00
gsinghpal
fd9d4e775b feat(numbering): add fp.parent.number + fp.quote.number sequences
Parent sequence starts at 30000. Quote sequence is Q + YYYYMM + non-resetting
counter starting at 200. Phase 1 Task 1 of the parent-number hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:07:16 -04:00
gsinghpal
2de5491693 plan(numbering): step-by-step implementation plan
15 tasks across 8 phases — foundation (sequences + mixin + SO fields),
quote/SO rename, WO grouping rewrite, invoice block + naming, child
model wiring (CoC/RCV/DLV/PU/NCR/CAPA/Hold/RMA), immutability + unlink
block, view + report fixes, end-to-end walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:38:08 -04:00
gsinghpal
671820427a spec(numbering): parent-number hierarchy design
Quote→SO→WO→IN→CoC→DLV→RCV→… all share a single parent number drawn
from the sale order. New abstract mixin centralises naming with atomic
counter increment, compliance-grade immutability, and a hard block on
direct invoice creation outside the SO workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:28:52 -04:00
33 changed files with 3465 additions and 118 deletions

View 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: 511100511160 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 ~$515k/year in recovered HST.
## 12. Cleanup Plan
### Phase 1 — Archive (~370 accounts)
- Every l10n_ca account NOT in the keep-list (built from Sections 49).
- 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 49.
### 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, 12 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 | $515k 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 | $5002k/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 | 3540% 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

View File

@@ -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.**

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View 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()

View File

@@ -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': """

View File

@@ -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):

View File

@@ -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': """

View File

@@ -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)

View File

@@ -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.',

View File

@@ -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 '',

View File

@@ -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:

View File

@@ -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):

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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>

View File

@@ -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)')

View File

@@ -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).

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 '

View File

@@ -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 '/'

View File

@@ -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 '/'

View File

@@ -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.',

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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': """

View File

@@ -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)