Compare commits
235 Commits
a2fe1fcbcc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091f98e1f9 | ||
|
|
25f568f225 | ||
|
|
4e54ecc32f | ||
|
|
ab7ff3eea5 | ||
|
|
f8fc6be370 | ||
|
|
b27f68b8d5 | ||
|
|
d9bdbd8e18 | ||
|
|
281941c7ee | ||
|
|
7eb9dd02a7 | ||
|
|
3a520564a7 | ||
|
|
6f2bea9773 | ||
|
|
e50631c46a | ||
|
|
76c68e0311 | ||
|
|
04862e8a28 | ||
|
|
cdc47554ed | ||
|
|
77b84ac11b | ||
|
|
b92a396934 | ||
|
|
8225061dfa | ||
|
|
fe4cceeffa | ||
|
|
a99f9aa5ee | ||
|
|
ca60500c07 | ||
|
|
d17cadabf0 | ||
|
|
df74d702af | ||
|
|
ada22a583f | ||
|
|
009562913c | ||
|
|
0593b70354 | ||
|
|
26fe41e7d4 | ||
|
|
2802fcf738 | ||
|
|
153b980e2b | ||
|
|
6cad69cb86 | ||
|
|
27badff570 | ||
|
|
a63fbe1558 | ||
|
|
49013c64fb | ||
|
|
ba6f39375a | ||
|
|
cbed74e5eb | ||
|
|
2730c455f5 | ||
|
|
669ba0fd8a | ||
|
|
8e172132e7 | ||
|
|
d3c5c25865 | ||
|
|
f8586611c9 | ||
|
|
28220f0732 | ||
|
|
edcc325483 | ||
|
|
37f1f7e8a3 | ||
|
|
0f10c490cd | ||
|
|
e166fae57b | ||
|
|
488243cd75 | ||
|
|
6cf826268b | ||
|
|
c8deef1482 | ||
|
|
55ac05667c | ||
|
|
4da123c2d3 | ||
|
|
8c6718e352 | ||
|
|
9d58f5f61e | ||
|
|
06df9745a0 | ||
|
|
3aa11eaffc | ||
|
|
c2590a99ff | ||
|
|
215e393bdb | ||
|
|
1780b383b9 | ||
|
|
a6ff3054bc | ||
|
|
b3a86cd4b9 | ||
|
|
23ac3284cb | ||
|
|
83c2b42aad | ||
|
|
22e217a16c | ||
|
|
3310b12754 | ||
|
|
eac337c058 | ||
|
|
655b767127 | ||
|
|
9ebf89bde2 | ||
|
|
191a9c82be | ||
|
|
00981a502a | ||
|
|
d75198be9f | ||
|
|
d009a1ef50 | ||
|
|
9001b6fc51 | ||
|
|
a24ef15a02 | ||
|
|
7fdab094fc | ||
|
|
c2646f59c4 | ||
|
|
152ed86c3a | ||
|
|
21754c1660 | ||
|
|
145b424760 | ||
|
|
a68bf2eae7 | ||
|
|
bc7c771f20 | ||
|
|
1ed414c6fb | ||
|
|
7d27db69c6 | ||
|
|
d891002c84 | ||
|
|
e0eacc2530 | ||
|
|
c637f82ae2 | ||
|
|
7cafab1b9f | ||
|
|
c96f27b96c | ||
|
|
406cac1362 | ||
|
|
13fd0712d9 | ||
|
|
1414ef2c1c | ||
|
|
42e8fe3d21 | ||
|
|
bad73fcea8 | ||
|
|
94249ba67d | ||
|
|
2abd859a29 | ||
|
|
98cb42d2e5 | ||
|
|
878d05685c | ||
|
|
bd2c037a97 | ||
|
|
44636e47fb | ||
|
|
06c49ecec6 | ||
|
|
37deaedf0d | ||
|
|
30f7f18472 | ||
|
|
66e9749853 | ||
|
|
c9be68a575 | ||
|
|
19d692afe7 | ||
|
|
0351dcd497 | ||
|
|
03fd3d7c1c | ||
|
|
f4c9ed3d24 | ||
|
|
ef885c66dc | ||
|
|
148aa5cba8 | ||
|
|
661c8ae227 | ||
|
|
a24a1ddf1a | ||
|
|
f05cacec22 | ||
|
|
9239ee2822 | ||
|
|
4733885211 | ||
|
|
8e708bf2c4 | ||
|
|
caf240daec | ||
|
|
4bed8ab2c5 | ||
|
|
50c209b8d3 | ||
|
|
65a1c4b17e | ||
|
|
91d3a3f9d1 | ||
|
|
70f855d91b | ||
|
|
85eddba546 | ||
|
|
48d3e48e61 | ||
|
|
f07e1bcce1 | ||
|
|
e7c6960de9 | ||
|
|
ad64b0b4c9 | ||
|
|
cd763fa1d7 | ||
|
|
f40f44aafd | ||
|
|
63bf271725 | ||
|
|
974b8a5152 | ||
|
|
0a32ed2da7 | ||
|
|
e4681a58c6 | ||
|
|
135cbd3a5c | ||
|
|
3182ca3c39 | ||
|
|
677e460438 | ||
|
|
c7b794f604 | ||
|
|
64c61dcca8 | ||
|
|
649b75d4a1 | ||
|
|
8aa817b1a0 | ||
|
|
80d1cc5639 | ||
|
|
2db789d7dd | ||
|
|
7a02382623 | ||
|
|
169e97af02 | ||
|
|
3c959771ae | ||
|
|
449f29fc7f | ||
|
|
3c2fb22346 | ||
|
|
3a41370189 | ||
|
|
d6513ff7ab | ||
|
|
457d9b7dbf | ||
|
|
c85a9bbf82 | ||
|
|
5b399fbdda | ||
|
|
b5416d242c | ||
|
|
fdbbd2852a | ||
|
|
be109c9c79 | ||
|
|
78d633f63f | ||
|
|
95cb73d91a | ||
|
|
0d85063b5e | ||
|
|
765a0a4c82 | ||
|
|
daf1235e20 | ||
|
|
3d4f003aba | ||
|
|
6c6fb8d2a4 | ||
|
|
1b1bebdcd8 | ||
|
|
e0d1998811 | ||
|
|
bc3f584851 | ||
|
|
105909470f | ||
|
|
6e67fc5ce3 | ||
|
|
fd9d4e775b | ||
|
|
2de5491693 | ||
|
|
671820427a | ||
|
|
b07f771d98 | ||
|
|
01a46e33e2 | ||
|
|
2d9779047b | ||
|
|
cba9a6da6b | ||
|
|
15eac309ee | ||
|
|
7d37f5713c | ||
|
|
cd2584d6ee | ||
|
|
dcbe8305d0 | ||
|
|
798458c834 | ||
|
|
30a1141997 | ||
|
|
a0644a7e5c | ||
|
|
8b5472bf4e | ||
|
|
d6bda9740f | ||
|
|
8d082cd9cc | ||
|
|
89dd77aff2 | ||
|
|
1c68fd0555 | ||
|
|
b0070afc1b | ||
|
|
9e39e41b0d | ||
|
|
f4c41de91c | ||
|
|
913311653f | ||
|
|
1c1f517847 | ||
|
|
b2592d70f8 | ||
|
|
03f14c2c40 | ||
|
|
eee2dcd615 | ||
|
|
6b7b44264a | ||
|
|
6c6a59ceef | ||
|
|
b7817b752c | ||
|
|
d5e954d45c | ||
|
|
2ba9b9d03d | ||
|
|
028c71452d | ||
|
|
66e04caf21 | ||
|
|
19c1cbdf15 | ||
|
|
3b7dba32a4 | ||
|
|
f02dc382b7 | ||
|
|
a713ec2fd3 | ||
|
|
586f05d567 | ||
|
|
3cc393454d | ||
|
|
d6bd43b76e | ||
|
|
e54ffe7309 | ||
|
|
28bf6b5071 | ||
|
|
6b4df48090 | ||
|
|
4e0b74d7ae | ||
|
|
4c6bad04c5 | ||
|
|
32d48ea44d | ||
|
|
e37eab9f23 | ||
|
|
2f8db6d592 | ||
|
|
21e42e7b48 | ||
|
|
d53fd53b80 | ||
|
|
328599d539 | ||
|
|
875828c588 | ||
|
|
efef7859cd | ||
|
|
9794a98de9 | ||
|
|
ee80673579 | ||
|
|
1da27ed6bf | ||
|
|
bdcbd86db2 | ||
|
|
4213c44e51 | ||
|
|
b8d064b180 | ||
|
|
c5d21e0519 | ||
|
|
f990f29019 | ||
|
|
f7fcd03bfc | ||
|
|
555dd5421f | ||
|
|
875548c547 | ||
|
|
ec0a07fbe9 | ||
|
|
b187192c58 | ||
|
|
bbf2476f01 | ||
|
|
9401afb21d | ||
|
|
df43737b1b |
94
AGENTS.md
Normal file
94
AGENTS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Odoo Modules — Codex Instructions
|
||||
|
||||
## Project
|
||||
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
|
||||
|
||||
## Critical Rules — Odoo 19
|
||||
1. **NEVER code from memory** — Always read a reference file from Docker first:
|
||||
```bash
|
||||
docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
|
||||
```
|
||||
2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
|
||||
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
7. **Search views**: NO `group expand="0"` syntax.
|
||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
```css
|
||||
background-color: white;
|
||||
border: 1px solid #d8dadd;
|
||||
```
|
||||
For custom OWL dashboards / client actions use the same approach:
|
||||
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
||||
```scss
|
||||
$fp-card: var(--fp-card-bg, #ffffff);
|
||||
$fp-border: var(--fp-border-color, #d8dadd);
|
||||
```
|
||||
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
||||
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
||||
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
||||
|
||||
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
||||
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
||||
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
||||
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
||||
|
||||
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
||||
|
||||
```scss
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_my-page-hex: #f3f4f6;
|
||||
$_my-card-hex: #ffffff;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_my-page-hex: #1a1d21 !global;
|
||||
$_my-card-hex: #22262d !global;
|
||||
}
|
||||
|
||||
$my-page: var(--my-page-bg, $_my-page-hex);
|
||||
$my-card: var(--my-card-bg, $_my-card-hex);
|
||||
```
|
||||
|
||||
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
||||
|
||||
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
||||
```python
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
||||
```
|
||||
|
||||
## Asset Bundle Cache Busting
|
||||
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
||||
1. Bump the module `version` in `__manifest__.py`
|
||||
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
||||
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
||||
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
||||
|
||||
## Naming
|
||||
- New fields: `x_fc_*` prefix
|
||||
- Legacy fields: `x_studio_*`
|
||||
- Canadian English for all user-facing text
|
||||
- Currency: `$` sign with Monetary fields + currency_id
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
- Local URL: http://localhost:8069
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## Supabase Knowledge Base
|
||||
Before starting unfamiliar work, check Supabase for context:
|
||||
```bash
|
||||
PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
|
||||
```
|
||||
- `fusionapps.decisions` — past architecture decisions
|
||||
- `fusionapps.issues` — known issues and fixes
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# Nexa Systems Inc — Chart of Accounts & Accounting Setup Design
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Target**: odoo-nexa production instance, database `nexamain`
|
||||
**Status**: Design — pending implementation plan
|
||||
|
||||
## 1. Context
|
||||
|
||||
Nexa Systems Inc is a Canadian CCPC providing IT services: custom software development, custom ERP, business apps, hosting, custom websites, and custom web apps. Operations are Canada-wide with planned global expansion. Workforce: solo founder today (Gurpreet, Canadian), hiring plan favours Canadian T4/T4A with occasional India contractors for burst capacity. Nexa will pursue SR&ED tax credits.
|
||||
|
||||
**Current state (as of 2026-05-12)**:
|
||||
- Odoo 19 Enterprise, l10n_ca localization loaded
|
||||
- 426 GL accounts (most unused — generic Canadian template bloat)
|
||||
- 49 active taxes with duplicates
|
||||
- 14 journals incl. 7 bank accounts (overprovisioned)
|
||||
- 776 journal entries, 125 invoices, data 2020-01-01 to 2026-05-04
|
||||
- **Historical Odoo data is NOT authoritative** — accountant has filed externally on Excel-based records. Past will be reconciled later.
|
||||
- All prior years filed with CRA. Fiscal year-end Dec 31.
|
||||
|
||||
**CRA registration & filing cadence**:
|
||||
- **Business Number / HST account**: `741224877` (currently stored as 9-digit BN root only on company record; needs to be updated to full 15-char format `741224877 RT0001` for Odoo's Canadian tax reports to validate cleanly).
|
||||
- **GST/HST filing**: annual. Return due **3 months after fiscal year-end** (March 31).
|
||||
- **T2 corporate income tax filing**: annual. Return due **6 months after fiscal year-end** (June 30). Balance owing due 3 months after year-end (March 31) for CCPCs eligible for SBD; 2 months otherwise.
|
||||
- **HST instalments**: annual filers must remit quarterly instalments if their net tax for the prior year was ≥ $3,000. Track via account 118200 GST/HST Instalments Paid.
|
||||
- **T2 instalments**: monthly or quarterly instalments required if Part I tax owing in prior year ≥ $3,000.
|
||||
|
||||
**Goals**:
|
||||
1. **CRA compliance** — clean tax handling, T2 Schedule 125 alignment, audit-ready
|
||||
2. **Tax savings** — SR&ED claim infrastructure from day 1, zero-rated export handling, CCA structure
|
||||
3. **Automation** — fiscal positions, default accounts, bank feeds, subscription billing
|
||||
4. **Ease of use** — invoicing is one-click after customer/product selection
|
||||
|
||||
**Scope**: Chart of accounts structure + tax/fiscal-position setup + analytic plans + automation hooks. **Out of scope**: bank feed onboarding (separate sub-project), CCA custom module (defer until volume warrants), historical data reconciliation (separate sub-project when accountant records arrive).
|
||||
|
||||
## 2. Approach
|
||||
|
||||
**Approach #2 — Hybrid**: keep l10n_ca's 6-digit code scheme (Canadian accountants recognize it), aggressively curate (~370 unused accounts archived, ~20 renamed, ~70 added), supplement with three analytic plans for finer reporting without GL proliferation.
|
||||
|
||||
**Rejected alternatives**:
|
||||
- *Surgical* — keep all 426 accounts unchanged. Rejected: bookkeeping burden, no IT-services shape.
|
||||
- *Clean slate (custom 4-digit)* — toss l10n_ca. Rejected: accountants would have to learn it; loses pre-mapped CRA tax structure.
|
||||
|
||||
## 3. Code Skeleton
|
||||
|
||||
```
|
||||
1xxxxx ASSETS
|
||||
111xxx Cash & cash equivalents
|
||||
112xxx Accounts receivable
|
||||
113xxx Prepaid expenses
|
||||
114xxx Other current assets
|
||||
115xxx Due from shareholder / related parties
|
||||
118xxx Tax assets (HST ITC, instalments)
|
||||
151xxx Capital assets — cost
|
||||
154xxx Accumulated depreciation (contra)
|
||||
|
||||
2xxxxx LIABILITIES
|
||||
211xxx Accounts payable
|
||||
213xxx HST/GST/QST collected
|
||||
214xxx Net tax payable
|
||||
215xxx Source deductions payable
|
||||
216xxx Corporate income tax payable
|
||||
221xxx Due to shareholder
|
||||
222xxx Due to related parties
|
||||
251xxx Long-term debt
|
||||
|
||||
3xxxxx EQUITY
|
||||
311xxx Share capital + contributed surplus
|
||||
321xxx Retained earnings + dividends
|
||||
|
||||
4xxxxx REVENUE (by service line — jurisdiction handled by tax codes, not by account)
|
||||
411xxx Recurring revenue (SaaS, hosting, support)
|
||||
412xxx Project revenue (custom dev, web app, website, ERP)
|
||||
413xxx Services (consulting, training, support hourly)
|
||||
414xxx Reseller revenue (third-party software/hardware)
|
||||
419xxx Sales adjustments (discounts, returns, bad debt recovery)
|
||||
|
||||
5xxxxx DIRECT COSTS (COGS)
|
||||
511xxx Infrastructure & hosting costs
|
||||
512xxx Project direct costs (subcontractors, project software, project travel)
|
||||
513xxx Cost of resold goods
|
||||
519xxx COGS adjustments
|
||||
|
||||
6xxxxx OPERATING EXPENSES
|
||||
611xxx Personnel — internal staff (T4)
|
||||
612xxx Personnel — contract (T4A non-project)
|
||||
621xxx Office & facilities
|
||||
631xxx Technology — operating (internal SaaS subs)
|
||||
641xxx Marketing & sales
|
||||
651xxx Professional fees
|
||||
661xxx Insurance
|
||||
671xxx Travel & entertainment
|
||||
681xxx Training & development
|
||||
691xxx Banking & finance charges
|
||||
699xxx Other (bad debt, donations, fines, FX losses, depreciation)
|
||||
|
||||
7xxxxx Other income (interest, FX gains)
|
||||
8xxxxx Other expenses (rare; mostly absorbed in 691/699)
|
||||
```
|
||||
|
||||
**Three analytic plans** (orthogonal tagging, applied per journal line):
|
||||
|
||||
| Plan | Required On | Purpose |
|
||||
|---|---|---|
|
||||
| **Project** | revenue, COGS, project costs | Project P&L, customer profitability, WIP, billable-hour realization |
|
||||
| **Department** | payroll, OpEx | Departmental P&L, overhead allocation |
|
||||
| **SR&ED Tag** | labour, contractors, materials (R&D) | T661 SR&ED claim — eligibility classification |
|
||||
|
||||
## 4. Revenue Accounts (4xxxxx)
|
||||
|
||||
```
|
||||
Recurring Revenue
|
||||
411100 SaaS Subscription Revenue
|
||||
411200 Hosting & Infrastructure Revenue
|
||||
411300 Support & Maintenance Contracts
|
||||
411400 Domain/SSL/Renewal Pass-through Revenue
|
||||
411500 Setup / Onboarding Fees
|
||||
|
||||
Project Revenue (one-time, milestone-billed)
|
||||
412100 Custom Software Development
|
||||
412200 Custom Web Application Development
|
||||
412300 Custom Website Development
|
||||
412400 ERP Implementation & Customization
|
||||
412500 Mobile App Development ← reserved for future
|
||||
412600 Business App / Integration Work
|
||||
|
||||
Services (hourly, retainer)
|
||||
413100 Consulting & Advisory
|
||||
413200 Training & Workshops
|
||||
413300 Technical Support — Per-incident / Hourly
|
||||
|
||||
Reseller / Pass-through
|
||||
414100 Third-party Software Resale (M365, Adobe)
|
||||
414200 Hardware Resale
|
||||
|
||||
Adjustments (contra-revenue)
|
||||
419100 Sales Discounts
|
||||
419200 Sales Returns & Refunds
|
||||
419300 Bad Debt Recovery
|
||||
```
|
||||
|
||||
**Design rule**: one revenue account per service line. Jurisdiction (ON/Atlantic/QC/export/etc.) tracked entirely through tax codes and fiscal positions, NOT duplicate accounts.
|
||||
|
||||
## 5. Direct Costs / COGS (5xxxxx)
|
||||
|
||||
```
|
||||
Infrastructure & Hosting
|
||||
511100 Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)
|
||||
511110 CDN & Edge Services (Cloudflare, Fastly)
|
||||
511120 Backup & Storage Services
|
||||
511130 Database & Backend Services (Supabase, hosted Postgres, Redis)
|
||||
511140 Monitoring & Observability (customer-facing only)
|
||||
511150 SSL Certificates & Domains (wholesale for resale)
|
||||
511160 DNS & Email Hosting (wholesale)
|
||||
|
||||
Third-party APIs & Per-transaction Costs
|
||||
511200 Third-party API Costs (Twilio, SendGrid, OpenAI)
|
||||
511210 Per-customer Licensing & Royalties
|
||||
|
||||
Note: 511100–511160 are shared between SaaS revenue (411100) and Hosting revenue (411200).
|
||||
Allocation to specific revenue line happens via the Project analytic plan, not separate accounts.
|
||||
|
||||
Project Direct Costs
|
||||
512100 Subcontracted Labour — Canadian (T4A) ← SR&ED-eligible
|
||||
512110 Subcontracted Labour — Foreign ← NOT SR&ED-eligible
|
||||
512200 Project-specific Software & Licenses
|
||||
512300 Project Travel & Onsite (rebilled)
|
||||
512400 Project Hardware (passed through)
|
||||
|
||||
Resold Goods & Services
|
||||
513100 Cost of Software Resold
|
||||
513200 Cost of Hardware Resold
|
||||
|
||||
Adjustments
|
||||
519100 COGS Adjustments / Write-offs
|
||||
```
|
||||
|
||||
**Design choices**:
|
||||
- **Salaries in OpEx, not COGS** — keeps SR&ED tracking clean; allocation to projects via Project analytic plan.
|
||||
- **Stripe/merchant fees in OpEx (691200)** — re-class to COGS later if SaaS revenue dominates.
|
||||
- **Canadian vs Foreign subcontractor split** — critical for SR&ED (80% × 35% = 28% credit on CA arm's length; 0% on foreign).
|
||||
|
||||
## 6. Operating Expenses (6xxxxx)
|
||||
|
||||
```
|
||||
Personnel — Internal Staff (T4)
|
||||
611100 Salaries & Wages — Development ← SR&ED-eligible base
|
||||
611200 Salaries & Wages — Sales & Marketing
|
||||
611300 Salaries & Wages — Admin & Operations
|
||||
611400 Salary — Shareholder/Officer (Gurpreet) ← 75% SR&ED cap (specified employee)
|
||||
611500 Employer CPP / QPP Contributions
|
||||
611600 Employer EI Premiums
|
||||
611700 Employer Health Tax (EHT/QHST)
|
||||
611800 WCB / WSIB Premiums
|
||||
611900 Employee Benefits (health, dental, group)
|
||||
611950 Bonuses & Incentives
|
||||
611960 Vacation Pay Accrual
|
||||
|
||||
Personnel — Contract (non-project)
|
||||
612100 Contract Labour — Canadian (admin/marketing/freelance)
|
||||
612200 Contract Labour — Foreign
|
||||
|
||||
Office & Facilities
|
||||
621100 Rent — Commercial Office
|
||||
621200 Home Office — Business Portion ← own account; allocated %
|
||||
621300 Utilities — Commercial
|
||||
621400 Internet & Phone — Business
|
||||
621500 Office Supplies & Consumables
|
||||
621600 Cleaning & Maintenance
|
||||
621700 Office Snacks & Refreshments
|
||||
|
||||
Technology — Operating
|
||||
631100 Software — Productivity (M365, Slack, Notion, Linear, GitHub)
|
||||
631200 Software — Development Tools (Cursor, Figma, IDEs)
|
||||
631300 Software — Internal Infrastructure
|
||||
631400 Software — Security & IT
|
||||
631500 Software — Sales & Marketing
|
||||
|
||||
Marketing & Sales
|
||||
641100 Advertising — Digital Ads
|
||||
641200 Advertising — Content / SEO
|
||||
641300 Trade Shows & Conferences
|
||||
641400 Promotional Items / Branded Swag
|
||||
641500 Website — Own (nexasystems.ca)
|
||||
|
||||
Professional Fees
|
||||
651100 Legal Fees — General
|
||||
651200 Accounting & Bookkeeping
|
||||
651300 Tax Preparation (T2, T1, GST/HST)
|
||||
651400 Business Consulting
|
||||
|
||||
Insurance
|
||||
661100 Commercial General Liability
|
||||
661200 Professional Liability / E&O
|
||||
661300 Cyber Liability
|
||||
661400 Property Insurance
|
||||
661500 Directors & Officers Insurance
|
||||
|
||||
Travel & Entertainment
|
||||
671100 Travel — Flights, Hotels, Ground Transport
|
||||
671200 Meals & Entertainment — 50% Deductible ← own account; 50% adjustment at year-end
|
||||
671300 Vehicle — Operating
|
||||
671400 Mileage Reimbursement — Personal Vehicle
|
||||
|
||||
Training & Development
|
||||
681100 Conferences & Seminars
|
||||
681200 Courses & Certifications
|
||||
681300 Books & Publications
|
||||
681400 Professional Memberships & Dues
|
||||
|
||||
Banking & Finance
|
||||
691100 Bank Service Charges
|
||||
691200 Merchant Processing Fees (Stripe, PayPal, Square)
|
||||
691300 Wire Transfer & FX Fees
|
||||
691400 Interest Expense — Bank Loans / LOC
|
||||
691500 Interest Expense — Credit Cards
|
||||
691600 Late Payment Penalties — Non-deductible
|
||||
|
||||
Other
|
||||
699100 Bad Debt Expense
|
||||
699200 Donations & Sponsorships
|
||||
699300 Penalties & Fines — Non-deductible
|
||||
699400 Realized FX Losses
|
||||
699500 Depreciation / CCA Expense
|
||||
```
|
||||
|
||||
**Notable design decisions**:
|
||||
- Salaries split by function (dev/sales/admin) — so SR&ED proxy applies cleanly to dev only.
|
||||
- Owner/Shareholder salary isolated (611400) — for T2 Schedule 11 (Compensation of Officers) and CRA reasonableness defence.
|
||||
- Non-deductible items isolated (691600, 699300) — prevents accidental deduction.
|
||||
- Meals & Entertainment own account (671200) — accountant applies the 50% adjustment cleanly.
|
||||
- Home office own account (621200) — business-use % applied to the whole account.
|
||||
|
||||
## 7. Capital Assets & CCA (1xxxxx + asset module)
|
||||
|
||||
```
|
||||
Capital Assets — Cost
|
||||
151100 Computer Hardware & Equipment (CCA Class 50, 55% DB)
|
||||
151200 Office Furniture & Equipment (CCA Class 8, 20% DB)
|
||||
151300 Vehicles (CCA Class 10 / 10.1)
|
||||
151400 Leasehold Improvements (CCA Class 13, SL)
|
||||
151500 Acquired Software/Intangibles (CCA Class 14.1, 5% DB)
|
||||
151600 Tools & Small Equipment <$500 (CCA Class 12, 100% Y1)
|
||||
|
||||
Accumulated Depreciation (contra)
|
||||
154100 Acc. Dep — Computer Hardware
|
||||
154200 Acc. Dep — Office Furniture
|
||||
154300 Acc. Dep — Vehicles
|
||||
154400 Acc. Dep — Leasehold Improvements
|
||||
154500 Acc. Dep — Acquired Software
|
||||
```
|
||||
|
||||
**Asset model approach**: book straight-line depreciation in Odoo for financial reporting (clean monthly journal); maintain CCA schedule separately for T2 filing. CCA rates: Class 50 effective 82.5% Y1 (with AccII through 2027); Class 14.1 software 100% Y1; Class 12 small tools 100% Y1.
|
||||
|
||||
## 8. Tax Accounts (1xxxxx + 2xxxxx)
|
||||
|
||||
```
|
||||
Tax Assets
|
||||
118100 HST/GST Input Tax Credit (ITC) Receivable
|
||||
118200 HST/GST Instalments Paid
|
||||
118300 QST Input Tax Refund Receivable
|
||||
|
||||
Tax Liabilities
|
||||
213100 HST/GST Collected on Sales ← single bucket; tax report breaks down by code
|
||||
213500 QST Collected
|
||||
214100 Net HST/GST Payable
|
||||
215100 Source Deductions Payable — Federal Tax
|
||||
215200 Source Deductions Payable — CPP
|
||||
215300 Source Deductions Payable — EI
|
||||
216100 Corporate Income Tax — Federal Payable
|
||||
216200 Corporate Income Tax — Provincial Payable
|
||||
216300 Corporate Tax Instalments Paid (contra)
|
||||
```
|
||||
|
||||
## 9. Shareholder, Associated Corporations & Equity
|
||||
|
||||
**Associated corporations** (Gurpreet >25% owner of each → ITA s.256 associated group):
|
||||
- Nexa Systems Inc (this company)
|
||||
- Westin Healthcare Inc
|
||||
- Divine Mobility Inc
|
||||
|
||||
**Treatment**: Westin and Divine are **regular Customers and Vendors of Nexa**, NOT slush accounts. Their transactions flow through normal AR/AP. They get partner records tagged `Related Party — Associated Corporation` for disclosure tracking. The "Due To/From Related Party" GL buckets exist only for true intercompany loans (cash moved between the corps' bank accounts without an invoice).
|
||||
|
||||
```
|
||||
Due From — Assets
|
||||
115100 Due From Shareholder — Gurpreet
|
||||
115900 Due From Associated Corporations (intercompany loans only — NOT customer AR)
|
||||
|
||||
Due To — Liabilities
|
||||
221100 Due To Shareholder — Gurpreet (short-term, <1 year)
|
||||
221200 Shareholder Loan — Gurpreet (long-term, with commercial terms)
|
||||
222900 Due To Associated Corporations (intercompany loans only — NOT vendor AP)
|
||||
|
||||
Equity
|
||||
311100 Share Capital — Common Shares
|
||||
311200 Share Capital — Preferred Shares (placeholder)
|
||||
311300 Contributed Surplus
|
||||
321100 Retained Earnings — Current Year
|
||||
321200 Retained Earnings — Prior Years
|
||||
321900 Dividends Declared (contra)
|
||||
```
|
||||
|
||||
**Partner setup** (under Contacts, not GL accounts):
|
||||
- `Westin Healthcare Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||
- `Divine Mobility Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||
- Nexa invoices Westin/Divine like any client → AR in 112xxx, revenue in 4xxxxx, HST 13% (Ontario)
|
||||
- Westin/Divine bill Nexa → AP in 211xxx, expense in 6xxxxx / COGS in 5xxxxx
|
||||
|
||||
**Intercompany compliance flags (CRITICAL — drives major tax decisions)**:
|
||||
|
||||
1. **Small Business Deduction (SBD) sharing — ITA s.125(5.1)**: The $500k federal SBD limit is **shared across all associated corporations**. If Nexa, Westin, and Divine are each profitable, they collectively get **one** $500k pool, not three. The corps must file Schedule 23 (T2) allocating the limit. Strategy: allocate the limit to whichever corp has the highest taxable income each year.
|
||||
|
||||
2. **SR&ED expenditure limit shared — ITA s.127(10.2)**: The $3M expenditure limit for the 35% refundable ITC is also shared across the associated group. Same Schedule 23 mechanism. Nexa being the dev shop probably consumes most/all of it.
|
||||
|
||||
3. **Transfer pricing — ITA s.247**: Services between related corps must be priced at fair market value. Nexa invoicing Westin at $50/hr while billing arm's-length clients $150/hr will be scrutinized. Document the rate methodology. Penalty for non-compliance is 10% of the adjustment.
|
||||
|
||||
4. **Subsection 15(2) shareholder loans**: outstanding >1 year past FY end → taxable to Gurpreet personally.
|
||||
|
||||
5. **T2 Schedule 9** (Related and Associated Corporations) must be filed by Nexa listing Westin and Divine.
|
||||
|
||||
6. **GAAR risk**: aggressive intercompany pricing or loan arrangements designed primarily for tax benefit can be challenged under general anti-avoidance rules.
|
||||
|
||||
## 10. Analytic Plans
|
||||
|
||||
### 10.1 Project Plan
|
||||
- One analytic account per customer engagement
|
||||
- Naming: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g., `PRJ-2026-WESTIN-ERP`)
|
||||
- Required on revenue, COGS, project costs
|
||||
- Linked to Odoo Project module for time tracking → automatic GL posting
|
||||
|
||||
### 10.2 Department Plan
|
||||
- `DEPT-DEV` — Development
|
||||
- `DEPT-SALES` — Sales & Marketing
|
||||
- `DEPT-ADMIN` — Admin & Operations
|
||||
- `DEPT-HOSTING` — Hosting Operations (optional future split)
|
||||
- Required on payroll, OpEx
|
||||
|
||||
### 10.3 SR&ED Tag Plan
|
||||
- `SRED-T4-DEV-SALARY` — T4 dev employees on R&D (full proxy 55%)
|
||||
- `SRED-SPECIFIED-EMPLOYEE` — Gurpreet/officers (75% basic salary cap)
|
||||
- `SRED-CONTRACTOR-CA-ARM-LENGTH` — Canadian arm's length (80% eligible)
|
||||
- `SRED-CONTRACTOR-CA-NON-ARM-LENGTH` — affiliated CA contractors
|
||||
- `SRED-MATERIALS-CONSUMED` — R&D materials
|
||||
- `SRED-OVERHEAD-PROXY-BASIS` — direct labour basis
|
||||
- `NOT-ELIGIBLE` — default
|
||||
|
||||
**T661 generation at year-end**: filter analytic report on SR&ED tag → eligible salaries + 55% proxy + 80% contractor + materials = total qualified expenditures × 35% refundable ITC.
|
||||
|
||||
## 11. Tax Setup & Fiscal Positions
|
||||
|
||||
**Consolidated active taxes** (~14, down from 49):
|
||||
|
||||
| Tax | Rate | Sale / Purchase | Applies |
|
||||
|---|---|---|---|
|
||||
| HST 13% Ontario | 13% | Both | ON |
|
||||
| HST 15% Atlantic | 15% | Both | NB, NS, PE, NL |
|
||||
| GST 5% | 5% | Both | AB, MB, SK, BC, YT, NT, NU |
|
||||
| GST 5% + PST 7% BC | 12% group | Both | BC (goods, rare for services) |
|
||||
| GST 5% + PST 7% MB | 12% group | Both | MB |
|
||||
| GST 5% + PST 6% SK | 11% group | Both | SK |
|
||||
| GST 5% + QST 9.975% QC | 14.975% group | Both | QC |
|
||||
| Zero-rated Export | 0% | Sale | US, EU, ROW |
|
||||
| Tax Exempt | 0% | Sale | Cert-holders |
|
||||
|
||||
**Fiscal Positions** (auto-applied based on customer billing address):
|
||||
|
||||
| Position | Customer Location | Auto-Substitute Default Tax |
|
||||
|---|---|---|
|
||||
| CA — Ontario (default) | ON | HST 13% |
|
||||
| CA — Atlantic | NB/NS/PE/NL | HST 15% |
|
||||
| CA — Quebec | QC | GST 5% + QST 9.975% |
|
||||
| CA — BC | BC | GST 5% (PST per-product) |
|
||||
| CA — Prairies / Territories | AB/MB/SK/YT/NT/NU | GST 5% |
|
||||
| Export — US | United States | 0% Zero-rated |
|
||||
| Export — International | Outside CA/US | 0% Zero-rated |
|
||||
| Tax Exempt | Tagged customers | 0% |
|
||||
|
||||
**Invoice flow**: customer → fiscal position auto-applies → product picks default tax → fiscal position substitutes → no manual tax decisions.
|
||||
|
||||
**Export advantage**: zero-rated sales charge no HST but retain ITC claims on all related inputs. For a small shop with 30% US revenue, this is ~$5–15k/year in recovered HST.
|
||||
|
||||
## 12. Cleanup Plan
|
||||
|
||||
### Phase 1 — Archive (~370 accounts)
|
||||
- Every l10n_ca account NOT in the keep-list (built from Sections 4–9).
|
||||
- Constraint: Odoo blocks archiving accounts with postings. Archive zero-history only.
|
||||
- Accounts with history we no longer want: stop posting; they go to $0 going forward.
|
||||
|
||||
### Phase 2 — Rename (~20 accounts)
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| 1400 Transferred to Gurpreet | 221100 Due To Shareholder — Gurpreet |
|
||||
| 1505 Sent to India | 612200 Contract Labour — Foreign |
|
||||
| 1580 Transferred to Westin | ARCHIVE — Westin is an associated corp, future transactions go through normal AR/AP via partner record `Westin Healthcare Inc` |
|
||||
| 1590 Transferred to Divine | ARCHIVE — Divine is an associated corp, future transactions go through normal AR/AP via partner record `Divine Mobility Inc` |
|
||||
| 1600 Transferred to Manpreet | ARCHIVE — Manpreet is an employee of another company, not a related party of Nexa; historical transactions to be re-classified by accountant during reconciliation |
|
||||
| 1500 Food & Entertainment | 671200 Meals & Entertainment — 50% Deductible |
|
||||
| 1501 Office Expenses | 621500 Office Supplies & Consumables |
|
||||
| 411000 Inside Sales | ARCHIVE (replaced by 412xxx) |
|
||||
| 412000 Harmonized Provinces Sales | ARCHIVE (jurisdiction = tax codes) |
|
||||
| 413000 Non-Harmonized Provinces Sales | ARCHIVE |
|
||||
| 414000 International Sales | ARCHIVE |
|
||||
| 12000 Abdul & Future Mobility | ARCHIVE (use partner subledger) |
|
||||
| 12001 MSI Account | ARCHIVE |
|
||||
| 110010 Bank Fee | 691100 Bank Service Charges |
|
||||
| 511100 Inside Purchases | ARCHIVE |
|
||||
|
||||
### Phase 3 — Add (~70 new accounts)
|
||||
All per Sections 4–9.
|
||||
|
||||
### Phase 4 — Bank Consolidation
|
||||
Current 8 bank journals (BMO, RBC, RBC VISA, Scotia ×3, Bank, Cash). Audit; archive inactive. Target: ≤5 active (primary operating, USD for future global, LOC, 1–2 credit cards).
|
||||
|
||||
### Phase 5 — Lock Prior Periods
|
||||
Set `fiscalyear_lock_date = 2025-12-31`. Blocks postings to closed periods. Forces all 2026 work into new structure.
|
||||
|
||||
## 13. Automation Hooks
|
||||
|
||||
### Product Categories with Default Accounts
|
||||
|
||||
| Product Category | Default Income | Default COGS | Default Tax |
|
||||
|---|---|---|---|
|
||||
| Services / SaaS Subscription | 411100 | — | per fiscal position |
|
||||
| Services / Hosting | 411200 | — | per fiscal position |
|
||||
| Services / Support Contract | 411300 | — | per fiscal position |
|
||||
| Services / Custom Software Dev | 412100 | — | per fiscal position |
|
||||
| Services / Web App Dev | 412200 | — | per fiscal position |
|
||||
| Services / Website Dev | 412300 | — | per fiscal position |
|
||||
| Services / ERP Implementation | 412400 | — | per fiscal position |
|
||||
| Services / Consulting | 413100 | — | per fiscal position |
|
||||
| Services / Training | 413200 | — | per fiscal position |
|
||||
| Services / Setup Fee | 411500 | — | per fiscal position |
|
||||
| Resale / Software | 414100 | 513100 | per fiscal position |
|
||||
| Resale / Hardware | 414200 | 513200 | per fiscal position |
|
||||
|
||||
### Bank Reconciliation Rules
|
||||
|
||||
| Pattern (description contains) | Auto-categorize To | Tax |
|
||||
|---|---|---|
|
||||
| `AMAZON WEB SERVICES`, `AWS` | 511100 Cloud Infrastructure | HST 13% ITC |
|
||||
| `HETZNER`, `OVH`, `DIGITALOCEAN`, `LINODE` | 511100 | 0% foreign |
|
||||
| `CLOUDFLARE`, `FASTLY` | 511110 CDN | mixed |
|
||||
| `GITHUB`, `JETBRAINS`, `CURSOR` | 631200 Software — Dev Tools | HST 13% ITC |
|
||||
| `MICROSOFT`, `SLACK`, `NOTION`, `LINEAR` | 631100 Software — Productivity | HST 13% ITC |
|
||||
| `STRIPE PAYOUT` | AR receipts journal | — |
|
||||
| `STRIPE FEE` | 691200 Merchant Processing | exempt |
|
||||
| `GOOGLE ADS`, `LINKEDIN ADS` | 641100 Advertising | HST 13% ITC |
|
||||
|
||||
### Bank Feeds (Plaid via Odoo Enterprise)
|
||||
Daily auto-import → bank reconciliation rules → ~70% of transactions auto-categorized.
|
||||
|
||||
### Subscription Module
|
||||
Already installed. Use for SaaS/Hosting/Support contracts: recurring invoices, Stripe auto-charge, MRR/ARR/churn dashboards.
|
||||
|
||||
### Default Journals
|
||||
- Customer Invoices → `INV`
|
||||
- Vendor Bills → `BILL`
|
||||
- Bank feeds → respective bank journals
|
||||
- HR Expenses → `EXP` (add if missing)
|
||||
- Misc → `MISC`
|
||||
- Exchange Difference → `EXCH`
|
||||
|
||||
## 14. Out-of-Scope (Future Sub-Projects)
|
||||
|
||||
- **Historical reconciliation** — load accountant's Excel records into new structure (requires accountant docs).
|
||||
- **Custom CCA module** — only if asset count grows; until then, accountant maintains CCA schedule separately.
|
||||
- **Multi-currency setup** — add USD bank + currency-rate-live config when first US client signs.
|
||||
- **Payroll system** — when first T4 employee is hired; integrate with Wagepoint/Payworks/ADP or Odoo Payroll.
|
||||
- **Approval workflows** — purchase approval, expense approval limits.
|
||||
- **Inventory** — N/A unless reselling hardware regularly.
|
||||
|
||||
## 15. Tax-Saving Opportunities Enabled
|
||||
|
||||
| Opportunity | Mechanism | Estimated Annual Value | Notes |
|
||||
|---|---|---|---|
|
||||
| SR&ED ITC | Analytic SR&ED tag + T661 filing | $30k–$100k (refundable) | **$3M expenditure limit SHARED across Nexa/Westin/Divine — allocate to Nexa via S23** |
|
||||
| Zero-rated exports | Fiscal position for US/international | $5–15k recovered HST on inputs | Per-company |
|
||||
| Small Business Deduction (SBD) | Federal 9% on first $500k taxable income | ~$30k/yr if hitting threshold | **$500k limit SHARED across associated group — allocate to highest-income corp via S23** |
|
||||
| CCA Class 50 + AccII | 82.5% Y1 deduction on computers/servers | Time-value, front-loads deductions | Per-company |
|
||||
| Quick Method GST/HST | If <$400k sales, simpler method | $500–2k/yr cash if eligible | **LIKELY UNAVAILABLE — Quick Method $400k threshold applies to associated-group totals; Nexa + Westin + Divine combined revenue probably exceeds limit. Re-verify with accountant.** |
|
||||
| OIDMTC (Ontario Interactive Digital Media) | If building interactive media products | 35–40% of eligible labour | Strict eligibility test; need to verify product fits |
|
||||
| Apprenticeship Job Creation TC | 10% of eligible apprentice wages, max $2k/yr per apprentice | Per apprentice hired | Activates when first apprentice T4 employee hired |
|
||||
| Intercompany cost recovery | Bill associated corps for shared services (back-office, hosting, IT) | Allocates expenses to highest-tax-rate corp | Requires arm's-length pricing documentation |
|
||||
|
||||
## 16. Risks & Open Questions
|
||||
|
||||
1. **Associated corporation tax planning** — Westin Healthcare Inc, Divine Mobility Inc, and Nexa Systems Inc share the $500k SBD limit and the $3M SR&ED expenditure limit. Yearly Schedule 23 allocation decision needs accountant input. Recommendation: allocate SR&ED limit primarily to Nexa (dev shop); allocate SBD to whichever corp has highest taxable income each year.
|
||||
2. **Transfer pricing on intercompany services** — Nexa billing Westin/Divine must be at fair market value. Document hourly rate methodology and apply consistently across all clients. Penalty: 10% of any adjustment.
|
||||
3. **Past data backposting** — once accountant records arrive, mapping old transactions into new structure requires care to avoid breaking the post-2025-12-31 lock.
|
||||
4. **BC PST on software services** — BC PST exempts custom software developed for a specific customer; off-the-shelf software and certain SaaS subscriptions ARE taxable. For Nexa's mix (most work is custom dev = exempt; SaaS sold off-the-shelf to BC customers = taxable at 7%), each BC customer/product combo needs review. Default to "GST only" for custom dev; flag SaaS-to-BC for review at first sale.
|
||||
5. **Quebec QST registration** — required if Nexa has QC customers and revenue >$30k. Confirm registration status. If not yet registered and you start taking QC clients, registration with Revenu Québec is separate from CRA.
|
||||
8. **HST filing cadence review** — currently annual. Once revenue clears $1.5M (combined Nexa-only, not associated group), CRA may auto-move you to **quarterly** filing. Monitor and update filing cadence in tax report config when it happens.
|
||||
6. **Specified employee SR&ED math** — Gurpreet's salary cap is 75%, no bonus inclusion. Accountant must apply at T661 time.
|
||||
7. **Multi-company Odoo (future sub-project)** — Westin and Divine currently run on separate Odoo databases (odoo-westin, odoo-mobility). Future option: migrate all three into one multi-company nexamain database to enable auto-mirrored intercompany invoices (Nexa invoices Westin → auto-creates Bill in Westin's books). Major data-migration effort; only worth it once intercompany volume justifies the effort.
|
||||
|
||||
## 17. Acceptance Criteria
|
||||
|
||||
- [ ] All 11 sections of CoA approved and present in odoo-nexa nexamain DB
|
||||
- [ ] ≥370 unused accounts archived
|
||||
- [ ] 14 active taxes (down from 49)
|
||||
- [ ] 8 fiscal positions configured with auto-detection
|
||||
- [ ] 3 analytic plans created (Project, Department, SR&ED Tag) with seed analytic accounts
|
||||
- [ ] Product categories created with default accounts
|
||||
- [ ] Bank reconciliation rules created
|
||||
- [ ] Fiscal year locked at 2025-12-31
|
||||
- [ ] Company HST/BN number stored in full 15-char form (`741224877 RT0001`)
|
||||
- [ ] HST report config set to **annual filer**, fiscal-year-end Dec 31, deadline March 31
|
||||
- [ ] Westin Healthcare Inc and Divine Mobility Inc partner records created with Customer + Vendor flags, tagged `RP-Associated`
|
||||
- [ ] Test invoice flows through correctly for: ON customer (HST 13%), US customer (Zero-rated), QC customer (GST+QST)
|
||||
- [ ] Test vendor bill creates correct ITC for: Canadian vendor (HST ITC), foreign vendor (no ITC)
|
||||
- [ ] Test intercompany invoice: Nexa → Westin generates proper AR + 13% HST collected (Westin is Ontario-based)
|
||||
- [ ] Bank consolidation complete; ≤5 active bank journals
|
||||
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# NFC Clock Kiosk — Design
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design — pending implementation plan
|
||||
**Pilot scope:** 1 station per company
|
||||
|
||||
## Problem
|
||||
|
||||
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
|
||||
|
||||
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
|
||||
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
|
||||
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
|
||||
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
|
||||
2. **Single-credential**: same card the employee uses for door access also clocks them in
|
||||
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
|
||||
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
|
||||
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
|
||||
6. **One-time setup**: enroll once, then employees never touch a setup flow again
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
|
||||
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
|
||||
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
|
||||
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
|
||||
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
|
||||
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
|
||||
|
||||
## Architecture decision
|
||||
|
||||
**Option B: Separate kiosk page, shared backend.**
|
||||
|
||||
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
|
||||
|
||||
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
|
||||
|
||||
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
|
||||
|
||||
## Hardware decision
|
||||
|
||||
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
|
||||
|
||||
- Built-in NFC antenna on the back, dead-center
|
||||
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
|
||||
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
|
||||
- Knox enables true kiosk lockdown
|
||||
- Pogo dock = magnetic constant power, no cable to yank
|
||||
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
|
||||
|
||||
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
|
||||
|
||||
## Data model
|
||||
|
||||
### `hr.employee` — new field
|
||||
- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null
|
||||
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
|
||||
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
|
||||
|
||||
### `res.company` — new field
|
||||
- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location`
|
||||
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
|
||||
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
|
||||
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
|
||||
|
||||
### `hr.attendance` — new fields
|
||||
- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in.
|
||||
- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out.
|
||||
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
|
||||
|
||||
### `ir.config_parameter` — new entries
|
||||
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
|
||||
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
|
||||
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
|
||||
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
|
||||
|
||||
### `res.config.settings` — new view section
|
||||
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
|
||||
|
||||
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
|
||||
|
||||
## Backend — controller and endpoints
|
||||
|
||||
**New file:** `controllers/clock_nfc_kiosk.py`
|
||||
|
||||
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
|
||||
|
||||
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
|
||||
|
||||
### `GET /fusion_clock/kiosk/nfc` — page render
|
||||
- Renders the NFC kiosk QWeb template
|
||||
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
|
||||
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
|
||||
- Logic:
|
||||
1. Normalize UID (uppercase, colon-separated, reject malformed input)
|
||||
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
|
||||
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
|
||||
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
|
||||
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
|
||||
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
|
||||
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
|
||||
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
|
||||
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
|
||||
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
|
||||
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
|
||||
- Logic:
|
||||
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
|
||||
2. Normalize UID
|
||||
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
|
||||
4. Write `x_fclk_nfc_card_uid` on the target employee
|
||||
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
|
||||
6. Return `{ success: true, employee_name, card_uid }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
|
||||
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
|
||||
|
||||
## Frontend — kiosk page UX
|
||||
|
||||
**Files:**
|
||||
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
|
||||
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
|
||||
|
||||
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
┌─── (3s timeout) ─────────────────────────┐
|
||||
▼ │
|
||||
┌─────────────────────────┐ tap detected ┌────────────────────┐
|
||||
│ IDLE │ ────────────────► │ PROCESSING │
|
||||
│ "Tap card to clock │ │ spinner, "Reading"│
|
||||
│ in or out" │ └────────────────────┘
|
||||
│ big clock, date, │ │
|
||||
│ company name │ success / error
|
||||
└─────────────────────────┘ ▼
|
||||
▲ ┌─────────────────────────┐
|
||||
│ │ RESULT │
|
||||
│ │ green: "Welcome John, │
|
||||
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
|
||||
│ red: "Card not │
|
||||
│ enrolled" │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### IDLE state
|
||||
- Top: company name + current time (HH:MM, updates every second) + date
|
||||
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
|
||||
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
|
||||
|
||||
### PROCESSING state
|
||||
- Brief spinner + "Reading card…"
|
||||
- Mostly imperceptible at typical network latency
|
||||
|
||||
### RESULT state — success
|
||||
- Green panel
|
||||
- Large employee avatar on the left
|
||||
- "John Smith" — name in big text
|
||||
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
|
||||
- Auto-return to IDLE after 3s
|
||||
|
||||
### RESULT state — error
|
||||
- Red panel
|
||||
- `card_unknown` → "Card not recognized. See your manager."
|
||||
- `network_error` → "No connection. Please try again."
|
||||
- `debounce` → silent (no UI change to avoid double-tap confusion)
|
||||
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
|
||||
- Auto-return to IDLE after 4s
|
||||
|
||||
### Web NFC implementation
|
||||
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
|
||||
- After activation, `NDEFReader.scan()` runs continuously
|
||||
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
|
||||
- UID format: hex bytes joined by colons, uppercased
|
||||
- If `scan()` throws, restart with a 1-second backoff
|
||||
|
||||
### Camera implementation
|
||||
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
|
||||
- Hidden `<video>` element streams continuously
|
||||
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~30–60 KB), POST as base64 in the same JSON payload as the UID
|
||||
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
|
||||
|
||||
### Enroll Mode
|
||||
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
|
||||
- Enroll Mode UI:
|
||||
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
|
||||
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
|
||||
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
|
||||
4. "Done" button → exit Enroll Mode → back to IDLE
|
||||
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
|
||||
|
||||
### One-time setup flow (first load on a new tablet)
|
||||
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
|
||||
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
|
||||
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
|
||||
4. "Setup complete." → enters IDLE
|
||||
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
|
||||
|
||||
### Mock-tap debug mode
|
||||
- Gated by `fusion_clock.nfc_kiosk_debug = True`
|
||||
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
|
||||
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
|
||||
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
|
||||
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
|
||||
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
|
||||
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
|
||||
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
|
||||
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
|
||||
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
|
||||
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
|
||||
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
|
||||
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
|
||||
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
|
||||
|
||||
## Hardware checklist (per company)
|
||||
|
||||
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
|
||||
- Samsung official Pogo charging dock — ~$100
|
||||
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
|
||||
- USB-C 30W PSU + cable — ~$25
|
||||
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
|
||||
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
|
||||
|
||||
**Total**: ~$915 per company, one-time.
|
||||
|
||||
## Provisioning script (one-time per tablet)
|
||||
|
||||
**Prerequisite — Odoo side (one-time per company):**
|
||||
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
|
||||
- Generate a long random password; store it in a password manager
|
||||
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
|
||||
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
|
||||
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
|
||||
|
||||
**Tablet side:**
|
||||
1. Factory reset
|
||||
2. Sign in with company Google account
|
||||
3. Install Fully Kiosk Browser from Play Store
|
||||
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
|
||||
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
|
||||
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
|
||||
7. Mount on dock
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
|
||||
- Tap with valid UID → attendance toggled, photo saved, activity logged
|
||||
- Tap with unknown UID → `card_unknown` error, no attendance row
|
||||
- Tap when `x_fclk_enable_clock=False` → `clock_disabled` error
|
||||
- Double-tap same UID within 5s → second is debounced
|
||||
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
|
||||
- Enroll with wrong password → 403
|
||||
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
|
||||
- UID normalization: lowercase input → stored uppercase
|
||||
|
||||
### Manual smoke tests (real tablet or Android phone for dev)
|
||||
- Cold boot → IDLE within 5s
|
||||
- Tap → RESULT within 1s
|
||||
- Photo attached to attendance record (verify in backend)
|
||||
- Enroll Mode password gate works; 60s timeout exits cleanly
|
||||
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
|
||||
- Tap own card 5x in fast succession → only one state change (debounce holds)
|
||||
|
||||
### Dev shortcut
|
||||
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
|
||||
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
|
||||
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
|
||||
|
||||
### Soak test (before declaring pilot ready)
|
||||
- 24h continuous on the dock
|
||||
- Periodic taps every few hours
|
||||
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
|
||||
|
||||
## Future considerations
|
||||
|
||||
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
|
||||
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
|
||||
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
|
||||
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
|
||||
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,969 @@
|
||||
# Step Qty Gate, Partial-Qty Handling, and Job Display Rename — Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a quantity gate on `fp.job.step.button_finish` (with last-step exemption), introduce a per-row `Complete 1 → Next` action for streaming flow, add an auto-move shim on Finish & Next for the 1-of-1 case, and override `fp.job.display_name` so jobs render as `Work Order # 00011` instead of `WH/JOB/00011`.
|
||||
|
||||
**Architecture:** Five small Python changes (one compute + one gate + one action + one helper + manager-bypass keys) on `fp.job` and `fp.job.step`, plus two view edits (form `<h1>` and embedded step list row button). Move wizard's existing zero-qty + over-qty guards stay; one regression test added for them. All changes deploy on entech, sync back to the local repo as the final task.
|
||||
|
||||
**Tech Stack:** Odoo 19, PostgreSQL. No new dependencies.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md`](../specs/2026-05-12-step-qty-gate-and-display-rename-design.md)
|
||||
|
||||
---
|
||||
|
||||
## Deployment conventions
|
||||
|
||||
Same pattern as the milestone-cascade plan that just shipped:
|
||||
|
||||
- File paths are **entech container paths** (`/mnt/extra-addons/custom/...`).
|
||||
- Edits go via base64-encoded Python patch scripts:
|
||||
```bash
|
||||
B64=$(base64 -w0 path/to/_patch.py)
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\""
|
||||
```
|
||||
- After each Python change: manifest version bump, then upgrade module:
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
|
||||
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u <module> --stop-after-init' 2>&1 | tail -5 && \
|
||||
systemctl start odoo && systemctl is-active odoo\""
|
||||
```
|
||||
- Tests via:
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
|
||||
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && \
|
||||
systemctl start odoo\""
|
||||
```
|
||||
- Backups: `cp <file> /tmp/<basename>.bak` before the first patch of any file.
|
||||
- No git commits during tasks. Final task (Task 7) syncs touched files back to `K:/Github/Odoo-Modules/` and commits there.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Type | Responsibility |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs/models/fp_job.py` | modify | Add `_compute_display_name` override (renames `WH/JOB/00011` → `Work Order # 00011`). |
|
||||
| `fusion_plating/models/fp_job_step.py` | modify | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move`; wire the helper into `action_finish_and_advance`. |
|
||||
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | `<h1>` binds `display_name`; per-row "Complete 1 → Next" button. |
|
||||
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | modify | Append `TestQtyGate` class with 14 tests. |
|
||||
| `fusion_plating/__manifest__.py` | modify | Version bump. |
|
||||
| `fusion_plating_jobs/__manifest__.py` | modify | Version bump. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Display rename — `Work Order # 00011`
|
||||
|
||||
**Files:**
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||
|
||||
- [ ] **Step 1: Backup files**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t1.xml.bak'"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `_compute_display_name` to `fp.job`**
|
||||
|
||||
Locate the existing class declaration in `fp_job.py` (around the first `class FpJob(models.Model)` line, then the `_inherit = 'fp.job'` block). Find the existing `name` field declaration (around line 62 — `name = fields.Char(...)`). Add the new compute method immediately after the existing field declarations on the class (any spot inside the class body before existing `@api.depends` methods is fine; convention is to put it near the field it depends on).
|
||||
|
||||
Insert:
|
||||
|
||||
```python
|
||||
@api.depends('name')
|
||||
def _compute_display_name(self):
|
||||
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
|
||||
human-facing surface (form header, breadcrumbs, M2O dropdowns,
|
||||
smart-button titles, error messages). The DB `name` is unchanged
|
||||
so existing certs / deliveries / chatter references don't break.
|
||||
"""
|
||||
for job in self:
|
||||
if job.name and '/' in job.name:
|
||||
suffix = job.name.rsplit('/', 1)[-1]
|
||||
job.display_name = _('Work Order # %s') % suffix
|
||||
else:
|
||||
job.display_name = job.name or ''
|
||||
```
|
||||
|
||||
Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the `state` field's closing `)` if it's unique).
|
||||
|
||||
- [ ] **Step 3: Bind `display_name` in the form header**
|
||||
|
||||
In `fp_job_form_inherit.xml`, find the `<h1>` block in the sheet header that currently binds `name`:
|
||||
|
||||
Search anchor:
|
||||
```xml
|
||||
<h1><field name="name"/></h1>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```xml
|
||||
<h1><field name="display_name"/></h1>
|
||||
```
|
||||
|
||||
If the file uses a slightly different markup (e.g. with extra attributes like `class=...` or `readonly=...`), keep those attributes and just change `name="name"` to `name="display_name"`.
|
||||
|
||||
- [ ] **Step 4: Bump fusion_plating_jobs manifest version**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1 | grep -oP '\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+') && echo \\\"current: \\\$CUR\\\"\""
|
||||
```
|
||||
|
||||
Bump the last component (`19.0.8.19.6` → `19.0.8.19.7`):
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.6'/'version': '19.0.8.19.7'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
|
||||
```
|
||||
|
||||
(If the current version is different from `19.0.8.19.6` because Phase 1 work iterated more, substitute the actual current version.)
|
||||
|
||||
- [ ] **Step 5: Validate Python + XML syntax**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"py OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")'\""
|
||||
```
|
||||
|
||||
Expected: `py OK` and `xml OK`.
|
||||
|
||||
- [ ] **Step 6: Upgrade fusion_plating_jobs**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
|
||||
```
|
||||
|
||||
Expected: `Modules loaded`, `Registry loaded`, then `active`.
|
||||
|
||||
- [ ] **Step 7: Verify display_name renders correctly via odoo shell**
|
||||
|
||||
```bash
|
||||
SCRIPT='job = env["fp.job"].search([("name", "like", "WH/JOB/")], limit=1)
|
||||
print(">>> name=", job.name)
|
||||
print(">>> display_name=", job.display_name)'
|
||||
B64=$(echo -n "$SCRIPT" | base64 -w0)
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/check.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/check.py' 2>&1 | grep '>>>'\""
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
>>> name= WH/JOB/00011
|
||||
>>> display_name= Work Order # 00011
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Quantity gate on `button_finish`
|
||||
|
||||
**Files:**
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||
|
||||
- [ ] **Step 1: Backup**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py /tmp/fp_job_step_t2.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py /tmp/test_fp_job_milestone_cascade_t2.py.bak'"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add quantity gate to `button_finish`**
|
||||
|
||||
Find the existing method in `fp_job_step.py` (around line 385). The current opening looks like:
|
||||
|
||||
```python
|
||||
def button_finish(self):
|
||||
for step in self:
|
||||
if step.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||
) % (step.name, step.state))
|
||||
now = fields.Datetime.now()
|
||||
# Close the open timelog (the one with no date_finished)
|
||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||
```
|
||||
|
||||
Use a patch script to inject the quantity gate immediately after the existing `state != 'in_progress'` check. New text:
|
||||
|
||||
```python
|
||||
def button_finish(self):
|
||||
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||
for step in self:
|
||||
if step.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||
) % (step.name, step.state))
|
||||
# Quantity gate: refuses if parts still parked AND there's a
|
||||
# downstream step to move them to. Last runnable step is
|
||||
# exempt — parts finishing there complete in place.
|
||||
if not skip_qty_gate and step.qty_at_step > 0:
|
||||
has_downstream = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > step.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
)
|
||||
if has_downstream:
|
||||
raise UserError(_(
|
||||
"Step '%(name)s' still has %(n)d part(s) parked "
|
||||
"— move them to the next step before finishing. "
|
||||
"Use the row's 'Complete 1 → Next' or 'Move…' "
|
||||
"button."
|
||||
) % {'name': step.name, 'n': step.qty_at_step})
|
||||
now = fields.Datetime.now()
|
||||
# Close the open timelog (the one with no date_finished)
|
||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||
```
|
||||
|
||||
Patch script uses the existing method-opening anchor (`def button_finish(self):\n for step in self:\n if step.state != 'in_progress':`) and replaces with the new opening.
|
||||
|
||||
- [ ] **Step 3: Add `TestQtyGate` test class skeleton + 3 gate tests**
|
||||
|
||||
Append to `test_fp_job_milestone_cascade.py`:
|
||||
|
||||
```python
|
||||
|
||||
|
||||
class TestQtyGate(TransactionCase):
|
||||
"""Step-level quantity gate + partial-qty handling.
|
||||
|
||||
Covers:
|
||||
- button_finish blocks when qty_at_step > 0 AND downstream
|
||||
steps exist (mid-recipe)
|
||||
- manager bypass via fp_skip_qty_gate=True
|
||||
- last-runnable-step exemption (qty_at_step > 0 allowed)
|
||||
- action_complete_one_to_next (Task 3)
|
||||
- auto-move shim on action_finish_and_advance (Task 4)
|
||||
- display_name rename (Task 1)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'QtyWidget',
|
||||
})
|
||||
|
||||
def _make_job(self, qty=3, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': qty,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
def _make_step(self, job, name='Step', sequence=10, state='pending'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': name,
|
||||
'sequence': sequence,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
def _make_two_step_chain(self, qty=3):
|
||||
"""Create a job with two steps; the first is in_progress
|
||||
with `qty` parts parked, the second is ready. Returns
|
||||
(job, step1, step2)."""
|
||||
job = self._make_job(qty=qty)
|
||||
step1 = self._make_step(
|
||||
job, name='Plate', sequence=10, state='in_progress',
|
||||
)
|
||||
step2 = self._make_step(
|
||||
job, name='Bake', sequence=20, state='ready',
|
||||
)
|
||||
# date_started required by button_finish's timelog close
|
||||
step1.date_started = fields.Datetime.now()
|
||||
return job, step1, step2
|
||||
|
||||
# ---------------- button_finish gate ----------------------------
|
||||
|
||||
def test_button_finish_blocks_when_qty_at_step(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
# First-step seed gives step1 qty_at_step = job.qty = 3
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.button_finish()
|
||||
self.assertIn('parts parked', str(exc.exception))
|
||||
|
||||
def test_button_finish_bypass(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
step1.with_context(fp_skip_qty_gate=True).button_finish()
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
def test_button_finish_allows_last_step_with_qty(self):
|
||||
"""Last runnable step is exempt — parts complete in place."""
|
||||
job = self._make_job(qty=5)
|
||||
last = self._make_step(
|
||||
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(last.qty_at_step, 5) # first-step seed
|
||||
# No downstream step → gate exempt
|
||||
last.button_finish()
|
||||
self.assertEqual(last.state, 'done')
|
||||
|
||||
def test_button_finish_passes_when_qty_zero(self):
|
||||
"""qty_at_step==0 (already moved out manually) → no gate fires."""
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
# Move all parts out so step1.qty_at_step = 0
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 0)
|
||||
step1.button_finish()
|
||||
self.assertEqual(step1.state, 'done')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Bump fusion_plating manifest version**
|
||||
|
||||
Find current version, bump the last component:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py | head -1\""
|
||||
```
|
||||
|
||||
Then bump (assuming current is `19.0.18.14.12`):
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.12'/'version': '19.0.18.14.13'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Validate Python**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\""
|
||||
```
|
||||
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 6: Upgrade fusion_plating + fusion_plating_jobs with tests**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && systemctl start odoo\""
|
||||
```
|
||||
|
||||
Expected: 4 `Starting TestQtyGate.test_button_finish_*` lines, no FAIL or ERROR lines for TestQtyGate.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `action_complete_one_to_next`
|
||||
|
||||
**Files:**
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||
|
||||
- [ ] **Step 1: Add `action_complete_one_to_next` method**
|
||||
|
||||
Append the new method to `fp_job_step.py` at the end of the `FpJobStep` class (after `button_manager_reset_to_ready` from the milestone-cascade Phase 1 work, since both are recent additions and group together). Patch via append-or-anchor-replace.
|
||||
|
||||
Code:
|
||||
|
||||
```python
|
||||
|
||||
def action_complete_one_to_next(self):
|
||||
"""One-piece flow shortcut: records move(qty=1) from this step
|
||||
to the next pending/ready step, drains qty_at_step by 1. If
|
||||
the drain takes qty_at_step to 0, auto-finishes the source
|
||||
and starts the destination step (delegates to
|
||||
action_finish_and_advance)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' must be in progress to complete a part."
|
||||
) % self.name)
|
||||
if self.qty_at_step < 1:
|
||||
raise UserError(_(
|
||||
"No parts parked at step '%s' — nothing to complete."
|
||||
) % self.name)
|
||||
next_step = self.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > self.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
).sorted('sequence')[:1]
|
||||
if not next_step:
|
||||
raise UserError(_(
|
||||
"Step '%s' is the last runnable step on the job — "
|
||||
"no downstream step to move into. Finish the step "
|
||||
"instead (it will close out the job)."
|
||||
) % self.name)
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.id,
|
||||
'to_step_id': next_step.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 1,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
# qty_at_step is computed from moves; force re-read before
|
||||
# checking whether this was the last part. Without invalidate
|
||||
# the cache still says "still 1 parked" and auto-finish never
|
||||
# fires.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step == 0:
|
||||
return self.action_finish_and_advance()
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add 4 tests for `action_complete_one_to_next`**
|
||||
|
||||
Append to `TestQtyGate` class:
|
||||
|
||||
```python
|
||||
|
||||
# ---------------- action_complete_one_to_next -------------------
|
||||
|
||||
def test_complete_one_to_next_records_move(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
step1.action_complete_one_to_next()
|
||||
# One move(qty=1) created
|
||||
moves = self.env['fp.job.step.move'].search([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(len(moves), 1)
|
||||
self.assertEqual(moves.qty_moved, 1)
|
||||
# step1 still in progress, 2 parts left
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.state, 'in_progress')
|
||||
self.assertEqual(step1.qty_at_step, 2)
|
||||
|
||||
def test_complete_one_to_next_auto_finishes_on_last(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 1)
|
||||
step1.action_complete_one_to_next()
|
||||
# Source step done; next step started
|
||||
self.assertEqual(step1.state, 'done')
|
||||
self.assertEqual(step2.state, 'in_progress')
|
||||
|
||||
def test_complete_one_to_next_blocks_when_empty(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
# Move all out first → qty_at_step = 0
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertIn('nothing to complete', str(exc.exception))
|
||||
|
||||
def test_complete_one_to_next_blocks_when_no_next_step(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(qty=3)
|
||||
last = self._make_step(
|
||||
job, name='Inspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
last.action_complete_one_to_next()
|
||||
self.assertIn('last runnable step', str(exc.exception))
|
||||
|
||||
def test_complete_one_to_next_blocks_when_not_in_progress(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.state = 'pending' # not in_progress
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertIn('must be in progress', str(exc.exception))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump fusion_plating manifest version**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.13'/'version': '19.0.18.14.14'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate + run tests**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_complete_one_to_next.*(FAIL|ERROR|Starting)' | head -15 && systemctl start odoo\""
|
||||
```
|
||||
|
||||
Expected: 5 `Starting` lines (the test from Step 2 plus 4 here), zero FAIL/ERROR.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Auto-move shim on Finish & Next
|
||||
|
||||
**Files:**
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||
|
||||
- [ ] **Step 1: Add `_fp_record_one_piece_auto_move` helper**
|
||||
|
||||
Find the existing `action_finish_and_advance` method on `fp.job.step` (search for `def action_finish_and_advance`). It probably looks like:
|
||||
|
||||
```python
|
||||
def action_finish_and_advance(self):
|
||||
"""Finish this step and auto-start the next pending/ready
|
||||
step (Steelhead-style per-row button)."""
|
||||
self.ensure_one()
|
||||
if self.state == 'in_progress':
|
||||
self.button_finish()
|
||||
# ...rest: pick next step + button_start
|
||||
```
|
||||
|
||||
Add the helper as a sibling method, then wire it in. New code:
|
||||
|
||||
```python
|
||||
|
||||
def _fp_record_one_piece_auto_move(self):
|
||||
"""Decide whether to silently record a move(qty=1) before
|
||||
the step finishes. Five cases:
|
||||
- qty_at_step == 0: nothing to do (parts already moved).
|
||||
- last runnable step: parts complete in place; no move.
|
||||
- qty_at_step == 1 + downstream: record move(1).
|
||||
- qty_at_step > 1 + downstream: raise.
|
||||
- qty_at_step > 1 + last step: allow (parts complete in
|
||||
place; qty_done auto-tick is Phase 2).
|
||||
Called from action_finish_and_advance just before
|
||||
button_finish.
|
||||
"""
|
||||
self.ensure_one()
|
||||
qty = self.qty_at_step
|
||||
if qty <= 0:
|
||||
return False
|
||||
next_step = self.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > self.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
).sorted('sequence')[:1]
|
||||
if not next_step:
|
||||
# Last runnable step: parts complete in place.
|
||||
return False
|
||||
if qty > 1:
|
||||
raise UserError(_(
|
||||
"Step '%s' still has %d parts here — use the row's "
|
||||
"'Complete 1 → Next' button (for one-by-one flow) "
|
||||
"or the 'Move…' wizard (for batched flow) to drain "
|
||||
"the step before finishing."
|
||||
) % (self.name, qty))
|
||||
# qty == 1 + next_step exists → record move silently.
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.id,
|
||||
'to_step_id': next_step.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 1,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the helper into `action_finish_and_advance`**
|
||||
|
||||
Find `action_finish_and_advance`. The current code likely starts:
|
||||
|
||||
```python
|
||||
def action_finish_and_advance(self):
|
||||
self.ensure_one()
|
||||
if self.state == 'in_progress':
|
||||
self.button_finish()
|
||||
```
|
||||
|
||||
Insert the helper call before `button_finish`:
|
||||
|
||||
```python
|
||||
def action_finish_and_advance(self):
|
||||
self.ensure_one()
|
||||
if self.state == 'in_progress':
|
||||
# Auto-move shim: for qty_at_step==1 + downstream, record a
|
||||
# move(qty=1) so the qty gate in button_finish passes. Raises
|
||||
# for qty>1 with a friendly pointer to Complete 1 → Next.
|
||||
self._fp_record_one_piece_auto_move()
|
||||
self.button_finish()
|
||||
```
|
||||
|
||||
The patch script uses the existing method's `self.ensure_one()\n if self.state == 'in_progress':\n self.button_finish()` as the anchor.
|
||||
|
||||
- [ ] **Step 3: Add 4 auto-move shim tests**
|
||||
|
||||
Append to `TestQtyGate`:
|
||||
|
||||
```python
|
||||
|
||||
# ---------------- auto-move shim on Finish & Next ---------------
|
||||
|
||||
def test_finish_and_advance_auto_move_for_qty_1(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 1)
|
||||
step1.action_finish_and_advance()
|
||||
# Move(qty=1) recorded silently
|
||||
moves = self.env['fp.job.step.move'].search([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(len(moves), 1)
|
||||
self.assertEqual(moves.qty_moved, 1)
|
||||
self.assertEqual(step1.state, 'done')
|
||||
self.assertEqual(step2.state, 'in_progress')
|
||||
|
||||
def test_finish_and_advance_blocks_for_qty_gt_1(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_finish_and_advance()
|
||||
self.assertIn("Complete 1", str(exc.exception))
|
||||
# State unchanged
|
||||
self.assertEqual(step1.state, 'in_progress')
|
||||
|
||||
def test_finish_and_advance_passes_for_qty_0(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
# Move all out first
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
before = self.env['fp.job.step.move'].search_count([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
step1.action_finish_and_advance()
|
||||
after = self.env['fp.job.step.move'].search_count([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(after, before) # no extra move
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
|
||||
"""Last runnable step: parts complete in place; no auto-move,
|
||||
no UserError, no qty gate."""
|
||||
job = self._make_job(qty=5)
|
||||
last = self._make_step(
|
||||
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(last.qty_at_step, 5)
|
||||
before = self.env['fp.job.step.move'].search_count([])
|
||||
last.action_finish_and_advance()
|
||||
after = self.env['fp.job.step.move'].search_count([])
|
||||
self.assertEqual(after, before) # no move recorded
|
||||
self.assertEqual(last.state, 'done')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Bump fusion_plating manifest version**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.14'/'version': '19.0.18.14.15'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Validate + run tests**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_finish_and_advance.*(FAIL|ERROR|Starting)' | head -10 && systemctl start odoo\""
|
||||
```
|
||||
|
||||
Expected: 4 `Starting` lines for `test_finish_and_advance_*`, zero FAIL/ERROR.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Per-row "Complete 1 → Next" button + display_name tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||
|
||||
- [ ] **Step 1: Add the per-row button**
|
||||
|
||||
In `fp_job_form_inherit.xml`, find the embedded step list's button block. The existing per-row buttons include `button_pause`, `action_open_input_wizard`, `button_skip`, `action_open_move_wizard`. We're adding "Complete 1 → Next" after `button_pause` and before `action_open_input_wizard` (so it sits with the primary-action buttons).
|
||||
|
||||
Anchor — the existing Pause button:
|
||||
```xml
|
||||
<button name="button_pause" type="object"
|
||||
string="Pause" icon="fa-pause"
|
||||
class="btn-link text-warning"
|
||||
invisible="state != 'in_progress'"/>
|
||||
```
|
||||
|
||||
Insert immediately after Pause's closing `/>`:
|
||||
|
||||
```xml
|
||||
<!-- Streaming flow: complete 1 part at a time, move to next
|
||||
step. Hidden when there's nothing parked or the step isn't
|
||||
actively running. Auto-finishes the step when qty_at_step
|
||||
drains to 0. -->
|
||||
<button name="action_complete_one_to_next" type="object"
|
||||
string="Complete 1 → Next" icon="fa-forward"
|
||||
class="btn-link text-success"
|
||||
invisible="state != 'in_progress' or qty_at_step < 1"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add display_name + Move wizard regression tests**
|
||||
|
||||
Append to `TestQtyGate`:
|
||||
|
||||
```python
|
||||
|
||||
# ---------------- display_name rename ----------------------------
|
||||
|
||||
def test_display_name_format(self):
|
||||
job = self._make_job(qty=1)
|
||||
# The default ir.sequence creates name='WH/JOB/NNNNN'.
|
||||
self.assertTrue(job.name.startswith('WH/JOB/'))
|
||||
self.assertTrue(job.display_name.startswith('Work Order # '))
|
||||
# Suffix matches.
|
||||
suffix = job.name.rsplit('/', 1)[-1]
|
||||
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
|
||||
|
||||
def test_display_name_no_slash_passthrough(self):
|
||||
"""Manually-named jobs without the sequence prefix display
|
||||
as-is (no rewrite)."""
|
||||
job = self._make_job(qty=1)
|
||||
# Override name to something without a slash
|
||||
job.name = 'SmokeJob42'
|
||||
job.invalidate_recordset(['display_name'])
|
||||
self.assertEqual(job.display_name, 'SmokeJob42')
|
||||
|
||||
# ---------------- Move wizard zero-qty regression ----------------
|
||||
|
||||
def test_move_wizard_blocks_zero_qty(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
wiz = self.env['fp.job.step.move.wizard'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'qty_moved': 0,
|
||||
'transfer_type':'step',
|
||||
})
|
||||
with self.assertRaises(UserError) as exc:
|
||||
wiz.action_commit()
|
||||
self.assertIn('at least 1', str(exc.exception))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump fusion_plating_jobs manifest version**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.7'/'version': '19.0.8.19.8'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate XML + Python**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"py OK\\\")'\""
|
||||
```
|
||||
|
||||
Expected: `xml OK`, `py OK`.
|
||||
|
||||
- [ ] **Step 5: Upgrade fusion_plating_jobs + run tests**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR)' | head -10 && systemctl start odoo\""
|
||||
```
|
||||
|
||||
Expected: 0 lines (no failures in `TestQtyGate`).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: End-to-end smoke test on entech
|
||||
|
||||
**Files:** none (verification via odoo shell + browser).
|
||||
|
||||
- [ ] **Step 1: Create a 3-step recipe job with qty=2**
|
||||
|
||||
```bash
|
||||
SCRIPT='partner = env["res.partner"].create({"name": "QtyGate Smoke"})
|
||||
prod = env["product.product"].create({"name": "QtyGateProd"})
|
||||
job = env["fp.job"].create({"partner_id": partner.id, "product_id": prod.id, "qty": 2})
|
||||
step1 = env["fp.job.step"].create({"job_id": job.id, "name": "S1-Plate", "sequence": 10, "state": "in_progress"})
|
||||
step1.date_started = fields.Datetime.now()
|
||||
step2 = env["fp.job.step"].create({"job_id": job.id, "name": "S2-Bake", "sequence": 20, "state": "ready"})
|
||||
step3 = env["fp.job.step"].create({"job_id": job.id, "name": "S3-Inspect", "sequence": 30, "state": "ready"})
|
||||
job.invalidate_recordset()
|
||||
print(">>> JOB_ID=", job.id)
|
||||
print(">>> JOB_NAME=", job.name)
|
||||
print(">>> DISPLAY_NAME=", job.display_name)
|
||||
print(">>> step1.qty_at_step=", step1.qty_at_step)
|
||||
env.cr.commit()'
|
||||
B64=$(echo -n "$SCRIPT" | base64 -w0)
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/smoke_qty.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_qty.py' 2>&1 | grep '>>>'\""
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
>>> JOB_ID= <some id>
|
||||
>>> JOB_NAME= WH/JOB/00xxx
|
||||
>>> DISPLAY_NAME= Work Order # 00xxx
|
||||
>>> step1.qty_at_step= 2
|
||||
```
|
||||
|
||||
Note JOB_ID for later steps.
|
||||
|
||||
- [ ] **Step 2: Try to finish step1 — must be blocked**
|
||||
|
||||
```bash
|
||||
SCRIPT='from odoo.exceptions import UserError
|
||||
step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||
try:
|
||||
step1.button_finish()
|
||||
print(">>> RESULT: no error (unexpected)")
|
||||
except UserError as e:
|
||||
print(">>> RESULT: blocked,", str(e)[:120])'
|
||||
```
|
||||
|
||||
Run the script (substituting JOB_ID). Expected:
|
||||
```
|
||||
>>> RESULT: blocked, Step 'S1-Plate' still has 2 part(s) parked — move them to the next step before finishing...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use action_complete_one_to_next to drain step1**
|
||||
|
||||
```bash
|
||||
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||
step1.action_complete_one_to_next()
|
||||
step1.invalidate_recordset(["qty_at_step"])
|
||||
print(">>> step1.state=", step1.state, "qty_at_step=", step1.qty_at_step)
|
||||
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
|
||||
step2.invalidate_recordset(["qty_at_step"])
|
||||
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
|
||||
env.cr.commit()'
|
||||
```
|
||||
|
||||
Expected after first call:
|
||||
```
|
||||
>>> step1.state= in_progress qty_at_step= 1
|
||||
>>> step2.state= ready qty_at_step= 0
|
||||
```
|
||||
|
||||
(Step2 stays `ready` because step1 still has 1 part — step1 isn't done yet.)
|
||||
|
||||
- [ ] **Step 4: Complete the second part — auto-finish**
|
||||
|
||||
```bash
|
||||
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||
step1.action_complete_one_to_next()
|
||||
step1.invalidate_recordset()
|
||||
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
|
||||
step2.invalidate_recordset()
|
||||
print(">>> step1.state=", step1.state)
|
||||
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
|
||||
env.cr.commit()'
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
>>> step1.state= done
|
||||
>>> step2.state= in_progress qty_at_step= 2
|
||||
```
|
||||
|
||||
(step2 now has both parts; auto-finish + auto-start fired on the last `Complete 1 → Next` call.)
|
||||
|
||||
- [ ] **Step 5: Open the job in browser, verify the header label**
|
||||
|
||||
Navigate to `https://enplating.com/odoo` → open the smoke job. Verify:
|
||||
- Form header reads **"Work Order # 00xxx"** (not WH/JOB/00xxx).
|
||||
- Step1 row no longer shows the "Complete 1 → Next" button (state=done).
|
||||
- Step2 row DOES show "Complete 1 → Next" (state=in_progress, qty_at_step > 0).
|
||||
|
||||
- [ ] **Step 6: Clean up smoke data**
|
||||
|
||||
```bash
|
||||
SCRIPT='job = env["fp.job"].browse(<JOB_ID>)
|
||||
if job.exists():
|
||||
env["fp.job.step.move"].search([("job_id", "=", job.id)]).sudo().unlink()
|
||||
job.step_ids.sudo().unlink()
|
||||
job.sudo().unlink()
|
||||
env["res.partner"].search([("name", "=", "QtyGate Smoke")]).sudo().unlink()
|
||||
env["product.product"].search([("name", "=", "QtyGateProd")]).sudo().unlink()
|
||||
env.cr.commit()
|
||||
print(">>> cleanup done")'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Sync touched files back to local repo + commit
|
||||
|
||||
**Files:**
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py`
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py`
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py`
|
||||
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Pull each touched file from entech to local repo**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Review diff**
|
||||
|
||||
```bash
|
||||
cd K:/Github/Odoo-Modules && git diff --stat fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/
|
||||
```
|
||||
|
||||
Expected: ~6 files changed, additions concentrated in `fp_job_step.py` (button_finish gate + action_complete_one_to_next + _fp_record_one_piece_auto_move + wiring), `fp_job.py` (_compute_display_name), and `test_fp_job_milestone_cascade.py` (14 new tests).
|
||||
|
||||
- [ ] **Step 3: Stage + commit**
|
||||
|
||||
```bash
|
||||
cd K:/Github/Odoo-Modules && git add fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/ && git commit -m "$(cat <<'EOF'
|
||||
feat(jobs): step qty gate + partial-qty + display rename
|
||||
|
||||
Three coupled shop-floor corrections:
|
||||
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
|
||||
downstream pending/ready step exists. Last runnable step is
|
||||
exempt (parts complete in place). Manager bypass via
|
||||
fp_skip_qty_gate=True context key.
|
||||
- fp.job.step.action_complete_one_to_next: per-row "Complete
|
||||
1 -> Next" button. Records move(qty=1) to next step; if that
|
||||
drains qty_at_step to 0, auto-finishes source + auto-starts
|
||||
destination via existing action_finish_and_advance.
|
||||
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
|
||||
wired into action_finish_and_advance. qty=1 + downstream =>
|
||||
silently record move(1). qty>1 + downstream => raise pointing
|
||||
at Complete 1 -> Next. Last step always allowed.
|
||||
- fp.job._compute_display_name: renders "Work Order # 00011"
|
||||
in form header, breadcrumbs, M2O dropdowns, error messages.
|
||||
DB name stays as WH/JOB/00011 - existing refs unchanged.
|
||||
- 14 new TestQtyGate tests covering gate / shim / auto-finish /
|
||||
last-step exemption / display rename / Move wizard zero-qty.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Push (optional)**
|
||||
|
||||
```bash
|
||||
cd K:/Github/Odoo-Modules && git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Architecture sections 1–5 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
|
||||
- **Placeholder scan:** Two `<JOB_ID>` placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
|
||||
- **Type consistency:** `action_complete_one_to_next` / `_fp_record_one_piece_auto_move` / `button_finish` all reference the same field names (`qty_at_step`, `state`, `sequence`, `job_id`, `step_ids`). The auto-move-shim's call site in `action_finish_and_advance` matches the helper's signature (no arguments, returns bool that the caller ignores). Test `TestQtyGate.setUpClass` matches the test method's `self.partner`, `self.product` references.
|
||||
- **Field invalidation:** Every test that creates a Move and then checks `qty_at_step` calls `invalidate_recordset(['qty_at_step'])` first. Inside `action_complete_one_to_next` itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Job Milestone Cascade — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Approved for implementation (Phase 1)
|
||||
**Scope:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_logistics` (on entech)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the per-step "Finish & Next" button on the `fp.job` form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
|
||||
|
||||
```
|
||||
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
|
||||
```
|
||||
|
||||
Each click runs the existing downstream method (no new business logic invented). The button is **one place** the manager looks; the system always tells them what's next.
|
||||
|
||||
## Motivation (workflow gap audit)
|
||||
|
||||
End-to-end audit found:
|
||||
|
||||
- **G1.** `fp.job.state` and `fp.job.workflow_state_id` are two parallel state machines that drift.
|
||||
- **G2.** No auto-fire of `button_mark_done` when all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op.
|
||||
- **G3.** Delivery + cert creation only happen via `button_mark_done`.
|
||||
- **G4.** Invoice timing is strategy-dependent; no `on_job_done` strategy.
|
||||
- **G5.** Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
|
||||
- **G6.** No "next action" surface on the job header.
|
||||
|
||||
Phase 1 closes **G2 and G6 directly**, makes meaningful progress on **G5**, and lays groundwork for G3/G4. G1 is explicitly deferred.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Ship in recipe vs separate | **Separate (Option C — Hybrid)** | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
|
||||
| Cert gate strictness on Mark Shipped | **Hard block** (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
|
||||
| Per-cert vs bulk issuance | **Per-cert** | Each cert (CoC vs Thickness Report) needs its own compliance review. |
|
||||
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
|
||||
| Migration of existing data | **None — dev stage** | No production jobs to preserve. Just rewrite the `Shipped` state seed XML; `-u` reloads it. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### New compute fields on `fp.job`
|
||||
|
||||
```python
|
||||
all_steps_terminal = fields.Boolean(
|
||||
compute='_compute_all_steps_terminal', store=True,
|
||||
help='True ⇔ at least one step exists AND every step is in '
|
||||
'done/skipped/cancelled.',
|
||||
)
|
||||
|
||||
next_milestone_action = fields.Selection([
|
||||
('mark_done', 'Mark Job Done'),
|
||||
('issue_certs', 'Issue Certs'),
|
||||
('schedule_delivery', 'Schedule Delivery'),
|
||||
('mark_shipped', 'Mark Shipped'),
|
||||
('closed', 'Closed'),
|
||||
], compute='_compute_next_milestone_action')
|
||||
|
||||
next_milestone_label = fields.Char(
|
||||
compute='_compute_next_milestone_action',
|
||||
help='Human label for the next-action button — read by the view.',
|
||||
)
|
||||
```
|
||||
|
||||
`_compute_next_milestone_action` resolution order (top wins):
|
||||
|
||||
```
|
||||
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
|
||||
2. state != 'done' → mark_done
|
||||
3. ANY required cert in state='draft' → issue_certs
|
||||
4. NO delivery, OR delivery in state='draft' → schedule_delivery
|
||||
5. delivery.state in scheduled/in_transit → mark_shipped
|
||||
6. otherwise → closed
|
||||
```
|
||||
|
||||
### Dispatcher action
|
||||
|
||||
```python
|
||||
def action_advance_next_milestone(self):
|
||||
"""Single entry point — branches on next_milestone_action and
|
||||
delegates to the existing method. Never invents new business logic."""
|
||||
self.ensure_one()
|
||||
handlers = {
|
||||
'mark_done': self.button_mark_done,
|
||||
'issue_certs': self._action_open_draft_certs,
|
||||
'schedule_delivery': self._action_open_draft_delivery,
|
||||
'mark_shipped': self._action_mark_active_delivery_delivered,
|
||||
}
|
||||
fn = handlers.get(self.next_milestone_action)
|
||||
if fn:
|
||||
return fn()
|
||||
return True
|
||||
```
|
||||
|
||||
**Helper methods** (each returns an Odoo action dict or calls the existing
|
||||
business-logic method):
|
||||
|
||||
- `_action_open_draft_certs` → returns an `ir.actions.act_window` opening
|
||||
the `fp.certificate` list view with domain
|
||||
`[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]` and
|
||||
`target='current'` so the manager works on the cert list, then uses the
|
||||
breadcrumb to return.
|
||||
- `_action_open_draft_delivery` → finds the first delivery in
|
||||
`state='draft'` for this job and returns an `ir.actions.act_window`
|
||||
opening that record's form in `target='current'`. Falls back to the
|
||||
delivery list view filtered to this job if no draft delivery exists.
|
||||
- `_action_mark_active_delivery_delivered` → finds the first delivery in
|
||||
`state in ('scheduled', 'in_transit')`, calls `action_mark_delivered`
|
||||
on it directly (no UI navigation — the cascade just *does* the thing).
|
||||
Posts to job chatter on success.
|
||||
|
||||
`target='current'` is chosen everywhere because the manager is working
|
||||
on the cascade as a multi-step process; a popup would lose breadcrumb
|
||||
context. The existing job-form breadcrumb survives, so they can navigate
|
||||
back when done.
|
||||
|
||||
### New trigger on `fp.job.workflow.state`
|
||||
|
||||
```python
|
||||
trigger_on_delivery_state = fields.Boolean(
|
||||
string='Trigger on Delivery Delivered',
|
||||
help='When True, this state passes once at least one '
|
||||
'fusion.plating.delivery linked to the job reaches '
|
||||
'state="delivered". Use for the Shipped milestone in '
|
||||
'lieu of recipe-side default_kind="ship" tagging.',
|
||||
)
|
||||
```
|
||||
|
||||
`fp.job.workflow.state._fp_is_passed_for_job(job)` gains:
|
||||
|
||||
```python
|
||||
if self.trigger_on_delivery_state:
|
||||
return any(d.state == 'delivered' for d in job.delivery_ids)
|
||||
```
|
||||
|
||||
`fp.job._compute_workflow_state_id`'s `@api.depends` extends to include `delivery_ids.state`.
|
||||
|
||||
### Cert auto-create hardening
|
||||
|
||||
Add to `fp.job`:
|
||||
|
||||
```python
|
||||
def _resolve_required_cert_types(self):
|
||||
"""Return the set of cert types this job must produce.
|
||||
Reads the part's certificate_requirement; falls back to the
|
||||
customer's send_coc / send_thickness_report flags when the part
|
||||
is set to 'inherit'."""
|
||||
req = (self.part_catalog_id and
|
||||
self.part_catalog_id.certificate_requirement) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
}.get(req, {'coc'})
|
||||
```
|
||||
|
||||
`_fp_create_certificates` is rewritten to loop over the resolved set and create one draft `fp.certificate` per type, idempotent per type (checks `x_fc_job_id` + `certificate_type` before creating).
|
||||
|
||||
### Cert gate on Mark Shipped
|
||||
|
||||
`fusion.plating.delivery.action_mark_delivered` gains a gate:
|
||||
|
||||
```python
|
||||
def action_mark_delivered(self):
|
||||
skip_cert = self.env.context.get('fp_skip_cert_gate')
|
||||
for delivery in self:
|
||||
if not skip_cert and delivery.job_ref:
|
||||
job = self.env['fp.job'].search(
|
||||
[('name', '=', delivery.job_ref)], limit=1)
|
||||
if job:
|
||||
draft_certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if draft_certs:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %(d)s shipped — '
|
||||
'job %(j)s still has %(n)d draft certificate(s). '
|
||||
'Issue them first, or override via '
|
||||
'fp_skip_cert_gate=True context key.'
|
||||
) % {
|
||||
'd': delivery.name,
|
||||
'j': job.name,
|
||||
'n': len(draft_certs),
|
||||
})
|
||||
return super().action_mark_delivered()
|
||||
```
|
||||
|
||||
Lives in `fusion_plating_certificates/models/fp_delivery.py` (so the gate ships with the certs module — no coupling to logistics).
|
||||
|
||||
### View changes
|
||||
|
||||
In `fusion_plating_jobs/views/fp_job_form_inherit.xml`:
|
||||
|
||||
1. **Hide existing Finish & Next** when `all_steps_terminal`:
|
||||
|
||||
```xml
|
||||
<button name="action_finish_current_step" type="object"
|
||||
string="Finish & Next" class="btn-primary" icon="fa-arrow-right"
|
||||
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||
```
|
||||
|
||||
2. **Add four mutually-exclusive milestone buttons.** Each binds to `action_advance_next_milestone` but with a hardcoded label so users don't see a generic button. Visibility is gated on `next_milestone_action`:
|
||||
|
||||
```xml
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Job Done" class="btn-success" icon="fa-check-circle"
|
||||
invisible="next_milestone_action != 'mark_done'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Issue Certs" class="btn-primary" icon="fa-certificate"
|
||||
invisible="next_milestone_action != 'issue_certs'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Schedule Delivery" class="btn-primary" icon="fa-truck"
|
||||
invisible="next_milestone_action != 'schedule_delivery'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Shipped" class="btn-success" icon="fa-paper-plane"
|
||||
invisible="next_milestone_action != 'mark_shipped'"/>
|
||||
```
|
||||
|
||||
`next_milestone_action == 'closed'` shows nothing (terminal).
|
||||
|
||||
3. **Hide invisible field** — register `<field name="next_milestone_action" invisible="1"/>` and `<field name="all_steps_terminal" invisible="1"/>` so the view can reference them in `invisible=` expressions.
|
||||
|
||||
### Data change — Shipped workflow state seed
|
||||
|
||||
In `fusion_plating_jobs/data/fp_workflow_state_data.xml`, replace the `Shipped` state record:
|
||||
|
||||
```xml
|
||||
<record id="workflow_state_shipped" model="fp.job.workflow.state">
|
||||
<field name="name">Shipped</field>
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_on_delivery_state" eval="True"/>
|
||||
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
Keep `noupdate="1"` on the wrapping `<data>` block since shops may further customise. In dev, `-u fusion_plating_jobs` re-applies it on fresh DBs.
|
||||
|
||||
## State transition cascade (visual)
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Steps still running │ ← Finish & Next visible
|
||||
└──────────┬───────────┘
|
||||
▼ last step done
|
||||
┌──────────────────────┐
|
||||
│ Mark Job Done │ ← button cascade starts
|
||||
└──────────┬───────────┘
|
||||
▼ button_mark_done (gates + create delivery + cert)
|
||||
┌────────────────────────────┴─────────────────────────────┐
|
||||
│ │
|
||||
any draft cert? no required certs
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┐ (skip to next)
|
||||
│ Issue Certs│
|
||||
└─────┬──────┘
|
||||
▼ all certs issued
|
||||
┌─────────────────┐
|
||||
│ Schedule Deliv. │
|
||||
└─────┬───────────┘
|
||||
▼ delivery scheduled
|
||||
┌─────────────┐
|
||||
│ Mark Shipped │ ← gates on issued certs (cert module)
|
||||
└─────┬────────┘
|
||||
▼ delivery.action_mark_delivered
|
||||
(workflow_state → Shipped via the new trigger;
|
||||
invoice fires if strategy='on_delivery')
|
||||
│
|
||||
▼
|
||||
Closed
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_jobs/models/fp_job.py` | Add `all_steps_terminal`, `next_milestone_action`, `next_milestone_label` compute fields. Add `action_advance_next_milestone` dispatcher + 3 helper methods. Add `_resolve_required_cert_types`. Rewrite `_fp_create_certificates` to loop over resolved types. Extend `@api.depends` on `_compute_workflow_state_id` to include `delivery_ids.state`. |
|
||||
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | Add `trigger_on_delivery_state` Boolean. Extend `_fp_is_passed_for_job` with delivery-state branch. |
|
||||
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | Rewrite `Shipped` state seed: drop `trigger_default_kinds='ship'`, add `trigger_on_delivery_state=True`. |
|
||||
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Hide `Finish & Next` when `all_steps_terminal`. Add 4 milestone buttons. Add invisible field declarations. |
|
||||
| `fusion_plating_certificates/models/fp_delivery.py` | Inherit `fusion.plating.delivery`; override `action_mark_delivered` to gate on draft certs. Manager bypass via `fp_skip_cert_gate=True`. |
|
||||
| `fusion_plating_certificates/__init__.py` / `models/__init__.py` | Register the new `fp_delivery.py` if needed. |
|
||||
|
||||
Manifest versions to bump:
|
||||
- `fusion_plating_jobs`
|
||||
- `fusion_plating_certificates`
|
||||
|
||||
## Out of scope (Phase 2+)
|
||||
|
||||
- **Send Certs to Customer button** — wrap `action_send_to_customer` per cert into the cascade after Mark Shipped. Existing `fp_notification_trigger` hooks already handle ship-time customer email; needs integration design.
|
||||
- **`on_job_done` invoice strategy** — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension in `fusion_plating_invoicing/models/sale_order.py`.
|
||||
- **`fp.job.state` ↔ `workflow_state_id` reconciliation (G1)** — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
|
||||
|
||||
## Implementation notes / gotchas
|
||||
|
||||
- `next_milestone_action` is **not stored** — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
|
||||
- The cascade reads `delivery_ids` on `fp.job`. Confirm this field exists (related/computed) before relying on it. Fallback: search `fusion.plating.delivery` by `job_ref == self.name`.
|
||||
- The cert gate in `action_mark_delivered` lives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify).
|
||||
- View buttons share the same `name="action_advance_next_milestone"` but Odoo distinguishes them by their `string=` attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see `sale.order` action buttons).
|
||||
- All four buttons are inside the header; users won't see more than one at a time thanks to the `invisible=` filters.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
|
||||
|
||||
## Goal
|
||||
|
||||
Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
|
||||
|
||||
1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
|
||||
2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
|
||||
3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
|
||||
|
||||
Real shop floors run three flows on the same job model:
|
||||
|
||||
| Flow | Example | Operator UX needed |
|
||||
|---|---|---|
|
||||
| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
|
||||
| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
|
||||
| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
|
||||
|
||||
The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
|
||||
| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
|
||||
| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
|
||||
| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
|
||||
| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
|
||||
| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
|
||||
| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. `fp.job.display_name` compute
|
||||
|
||||
Single override on `fp.job`. No model change beyond adding a computed method.
|
||||
|
||||
```python
|
||||
@api.depends('name')
|
||||
def _compute_display_name(self):
|
||||
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
|
||||
human-facing surface (form header, breadcrumbs, M2O dropdowns,
|
||||
smart-button titles, error messages). The DB `name` is unchanged
|
||||
so existing certs / deliveries / chatter references don't break.
|
||||
"""
|
||||
for job in self:
|
||||
if job.name and '/' in job.name:
|
||||
suffix = job.name.rsplit('/', 1)[-1]
|
||||
job.display_name = _('Work Order # %s') % suffix
|
||||
else:
|
||||
job.display_name = job.name or ''
|
||||
```
|
||||
|
||||
View change: the form `<h1>` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
|
||||
|
||||
### 2. Quantity gate on `fp.job.step.button_finish`
|
||||
|
||||
The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
|
||||
|
||||
```python
|
||||
def button_finish(self):
|
||||
"""[existing docstring extended]
|
||||
|
||||
Quantity gate (new): refuses if qty_at_step > 0 AND there is at
|
||||
least one downstream pending/ready step. The last runnable step
|
||||
is exempt — parts finishing in place are valid. Manager bypass
|
||||
via context key fp_skip_qty_gate=True.
|
||||
"""
|
||||
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||
for step in self:
|
||||
if step.state != 'in_progress':
|
||||
raise UserError(...) # existing
|
||||
if not skip_qty_gate and step.qty_at_step > 0:
|
||||
has_downstream = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > step.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
)
|
||||
if has_downstream:
|
||||
raise UserError(_(
|
||||
"Step '%(name)s' still has %(n)d part(s) parked "
|
||||
"— move them to the next step before finishing. "
|
||||
"Use the row's 'Complete 1 → Next' or 'Move…' "
|
||||
"button."
|
||||
) % {'name': step.name, 'n': step.qty_at_step})
|
||||
# No downstream step: this is the last runnable step.
|
||||
# Parts finishing here become "done" with the recipe.
|
||||
# ...remainder unchanged
|
||||
```
|
||||
|
||||
### 3. New `fp.job.step.action_complete_one_to_next`
|
||||
|
||||
```python
|
||||
def action_complete_one_to_next(self):
|
||||
"""One-piece flow shortcut: records move(qty=1) from this step
|
||||
to the next pending/ready step. Drains qty_at_step by 1. If the
|
||||
drain takes qty_at_step to 0, auto-finishes the source step and
|
||||
starts the destination step (delegates to action_finish_and_advance,
|
||||
which already handles auto-start)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' must be in progress to complete a part."
|
||||
) % self.name)
|
||||
if self.qty_at_step < 1:
|
||||
raise UserError(_(
|
||||
"No parts parked at step '%s' — nothing to complete."
|
||||
) % self.name)
|
||||
next_step = self.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > self.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
).sorted('sequence')[:1]
|
||||
if not next_step:
|
||||
raise UserError(_(
|
||||
"Step '%s' is the last runnable step on the job — "
|
||||
"no downstream step to move into. Finish the step "
|
||||
"instead (it will close out the job)."
|
||||
) % self.name)
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.id,
|
||||
'to_step_id': next_step.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 1,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
# qty_at_step is computed from moves; force re-read before deciding
|
||||
# whether this was the last part. Without invalidate the cache says
|
||||
# "still 1 parked" and the auto-finish never fires.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step == 0:
|
||||
return self.action_finish_and_advance()
|
||||
return True
|
||||
```
|
||||
|
||||
### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
|
||||
|
||||
Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
|
||||
|
||||
- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
|
||||
- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
|
||||
- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
|
||||
|
||||
The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
|
||||
|
||||
```python
|
||||
def _fp_record_one_piece_auto_move(self):
|
||||
"""Helper called from action_finish_and_advance. Decides whether
|
||||
to silently record a move(qty=1) before the step finishes. Three
|
||||
cases:
|
||||
- qty_at_step == 0: nothing to do (parts already moved manually).
|
||||
- qty_at_step == 1 + downstream step exists: record move(1).
|
||||
- qty_at_step == 1 + no downstream (last step): no move; parts
|
||||
complete in place.
|
||||
- qty_at_step > 1 + downstream exists: raise (operator must use
|
||||
Complete 1 → Next or Move… to drain the step).
|
||||
- qty_at_step > 1 + no downstream (last step): allow; parts
|
||||
all complete in place. (qty_done auto-tick is Phase 2.)
|
||||
"""
|
||||
self.ensure_one()
|
||||
qty = self.qty_at_step
|
||||
if qty <= 0:
|
||||
return False
|
||||
next_step = self.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > self.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
).sorted('sequence')[:1]
|
||||
if not next_step:
|
||||
# Last runnable step — parts here complete in place. The
|
||||
# button_finish gate already permits this case; just allow.
|
||||
return False
|
||||
if qty > 1:
|
||||
raise UserError(_(
|
||||
"Step '%s' still has %d parts here — use the row's "
|
||||
"'Complete 1 → Next' button (for one-by-one flow) or "
|
||||
"the 'Move…' wizard (for batched flow) to drain the "
|
||||
"step before finishing."
|
||||
) % (self.name, qty))
|
||||
# qty == 1 and next_step exists → record the move silently.
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.id,
|
||||
'to_step_id': next_step.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 1,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
return True
|
||||
```
|
||||
|
||||
Wired into `action_finish_and_advance` immediately before the existing finish logic:
|
||||
|
||||
```python
|
||||
def action_finish_and_advance(self):
|
||||
self.ensure_one()
|
||||
if self.state == 'in_progress':
|
||||
self._fp_record_one_piece_auto_move() # may raise on qty>1
|
||||
# ...rest unchanged (button_finish + auto-start next)
|
||||
```
|
||||
|
||||
### 5. View additions
|
||||
|
||||
In `fp_job_form_inherit.xml` (embedded step list):
|
||||
|
||||
```xml
|
||||
<!-- Complete 1 part and advance — streaming flow (large parts
|
||||
going one-by-one through the same step). Hidden when there's
|
||||
nothing parked or the step isn't actively running. -->
|
||||
<button name="action_complete_one_to_next" type="object"
|
||||
string="Complete 1 → Next" icon="fa-forward"
|
||||
class="btn-link text-success"
|
||||
invisible="state != 'in_progress' or qty_at_step < 1"/>
|
||||
```
|
||||
|
||||
Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
|
||||
|
||||
In the form header `<sheet>` block, change the `<h1>` to bind `display_name`:
|
||||
|
||||
```xml
|
||||
<h1><field name="display_name"/></h1>
|
||||
```
|
||||
|
||||
`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
|
||||
|
||||
## State transition diagram
|
||||
|
||||
```
|
||||
Before this work:
|
||||
in_progress ──button_finish──> done (no qty check)
|
||||
|
||||
After:
|
||||
any step, qty_at_step==0 ──button_finish──> done
|
||||
mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
|
||||
mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
|
||||
mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
|
||||
mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
|
||||
LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
|
||||
```
|
||||
|
||||
"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
|
||||
|
||||
## Test plan
|
||||
|
||||
New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
|
||||
|
||||
| Test | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
|
||||
| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
|
||||
| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
|
||||
| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
|
||||
| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
|
||||
| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
|
||||
| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
|
||||
| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
|
||||
| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
|
||||
| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
|
||||
| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
|
||||
| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
|
||||
| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
|
||||
| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
|
||||
| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
|
||||
| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
|
||||
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `<h1>` → `display_name`; per-row "Complete 1 → Next" button. |
|
||||
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Version bump. |
|
||||
| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
|
||||
- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
|
||||
- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
|
||||
- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
|
||||
|
||||
## Implementation notes / gotchas
|
||||
|
||||
- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
|
||||
- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
|
||||
- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.
|
||||
BIN
fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user