Compare commits
30 Commits
27badff570
...
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 |
0
fusion-plating/%{http_code}
Normal file
0
fusion-plating/%{http_code}
Normal file
@@ -3,9 +3,13 @@
|
||||
## Project
|
||||
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
|
||||
|
||||
## Recent Session Handoff — 2026-05-17
|
||||
## Recent Session Handoff — 2026-05-17 (Portal Redesign + Sub-A IA approved)
|
||||
|
||||
> **For the next Claude session.** All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
||||
> **For the next Claude session.** Portal dashboard + jobs + detail + configurator are LIVE on entech at `fusion_plating_portal 19.0.3.7.0`. Sub-A (Portal IA + Sidebar) is brainstormed, spec'd, planned, NOT yet executed — pick up there. Full handoff (live state, decisions, gotchas, how-to-deploy, what's deferred): **[`docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md`](docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md)**. Approved plan ready to execute: **[`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md)** (11 tasks, 4 phases). Don't re-brainstorm; just execute.
|
||||
|
||||
### Previous handoff (pre-portal redesign — superseded but kept for context)
|
||||
|
||||
> All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
||||
|
||||
### Shipped this session
|
||||
|
||||
@@ -23,6 +27,11 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
|
||||
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
|
||||
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
|
||||
| **CoC + thickness = ONE cert (page 2 merge)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only** — the thickness data is delivered as page 2 of the CoC PDF via `_fp_merge_thickness_into_pdf`, not as a separate `thickness_report` cert. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. | `fusion_plating_jobs`, `fusion_plating_certificates` |
|
||||
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
|
||||
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
|
||||
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `'` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records, write a one-shot post-migration or update via `odoo shell`. | any code scripting `mail.template.body_html` |
|
||||
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
|
||||
|
||||
### Pending — IN PROGRESS when this session ended
|
||||
|
||||
@@ -170,6 +179,12 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
||||
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (–), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'–', '-')...` in `report_fp_wo_sticker_inner`.
|
||||
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
|
||||
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py` — `portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.
|
||||
17. **Test scaffolding — creating account.move in tests**: Two custom gates block direct invoice creation in tests:
|
||||
- `fusion_plating_jobs` blocks all `out_invoice`/`out_refund`/`out_receipt` creates unless `context.get('fp_from_so_invoice')` is set or `invoice_origin` matches an SO name. Bypass: `self.env['account.move'].with_context(fp_from_so_invoice=True).create(...)`.
|
||||
- `fusion_plating_invoicing` blocks `action_post()` when `invoice_payment_term_id` is unset. Bypass: pass `'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id` in the create vals.
|
||||
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
||||
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
||||
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Portal Redesign — Session Handoff (2026-05-17)
|
||||
|
||||
> **Read this first.** This session ran long; the next session picks up here. Everything below is intentionally short. Authoritative details live in the linked spec / plan files.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Customer-portal redesign across two long sessions. Dashboard + jobs + detail page + configurator are LIVE on entech. The next step (sidebar nav + page audit + Account Summary view) has an APPROVED PLAN ready to execute — do not re-brainstorm, just execute.
|
||||
|
||||
**Immediately actionable:** execute [`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](../plans/2026-05-17-portal-ia-sidebar-plan.md) via `superpowers:subagent-driven-development` or `superpowers:executing-plans`. User was offered both at handoff time and chose subagent-driven (preferred). 11 tasks across 4 phases.
|
||||
|
||||
## Live state on entech (2026-05-17)
|
||||
|
||||
| Module | Version live | Notes |
|
||||
|---|---|---|
|
||||
| `fusion_plating_portal` | `19.0.3.7.0` | Dashboard, job cards, configurator, detail page, doc downloads, repeat order, animations — all shipped |
|
||||
| `fusion_plating_jobs` | `19.0.10.8.0` + write-hook + create-init | fp.job → fp.portal.job state-sync hook on write, initial state derive on create |
|
||||
| `fusion_plating_reports` | `19.0.11.15.0` | Customer Acceptance / Authorized Representative signature blocks removed from `report_fp_sale_portrait/landscape` |
|
||||
| All 5 portal unit tests green | | `--test-tags=fp_portal` |
|
||||
|
||||
Branch: `main`. Local repo is many commits ahead of `origin/main`; user has not been asked to push (per system-prompt safety default). Run `git log --oneline origin/main..HEAD` at session start to see what's outstanding.
|
||||
|
||||
## What shipped this session (high-level)
|
||||
|
||||
1. **Dashboard rebuild** — `/my/home` → jobs-forward layout (KPI tiles → Active Work Orders hero → 5 secondary panels). Welcome line summarises status in plain words. EN Plating teal brand palette with gradient CTAs.
|
||||
2. **Job card upgrade** — shared `fp_portal_job_card` macro (used by `/my/home` + `/my/jobs`). Wrap div + inner anchor + sibling actions footer (4 doc download chips + Repeat Order POST form). Part info + ship-to address pulled inline. Pulse animation on the active step circle + matching detail-page timeline dot.
|
||||
3. **Detail page** — V2 stepper + V3 timestamps + 5-group document panel (From You / Specifications / Work Order / Quality / Shipping). Sales Order Confirmation, Work Order Detail, CoC, Packing Slip all sudo-render from the FP custom reports. Hero shows part + ship-to.
|
||||
4. **Configurator fixes** — `/my/configurator/coating` 500 fixed (`fp.coating.config` → `fusion.plating.process.type`). Manual measurements hidden in step 1. Split single-file upload into Drawing (PDF) + 3D Model.
|
||||
5. **Sale report cleanup** — Customer Acceptance / Authorized Representative signature block removed.
|
||||
6. **Misc** — `/my` route added, button sizing normalised, hover-underline suppressed globally, sidebar of legacy stuff redirected, dashboard expanded to 5 panels (Quote Requests + Purchase Orders added).
|
||||
|
||||
24+ commits this session, all on `main`. Browse `git log --oneline -30` for the full sweep.
|
||||
|
||||
## What's queued for execution
|
||||
|
||||
**Sub-A (Portal IA + Sidebar):** plan ready, not yet executed. Brainstorm decisions baked in:
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Sidebar shape | **B** — Dashboard top, then grouped Activity / Documents / Account sections |
|
||||
| Account Summary tabs | 3 (Invoices / Credit Memos / Statements) + Open Balance pill in header |
|
||||
| Statements V1 | Placeholder card ("Coming soon") — real statement generation deferred |
|
||||
| Legacy URL redirects | `/my/fp_invoices` → `/my/account_summary`; `/my/purchase_orders` → `/my/orders` (Odoo default); `/my/quote_requests/new` GET → `/my/configurator/new` |
|
||||
| Future Users / Search slots | Omit from V1 (no "coming soon" placeholders); add when sub-B/sub-C ship |
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md`](../specs/2026-05-17-portal-ia-sidebar-design.md)
|
||||
|
||||
## What's deferred (do NOT re-litigate in next session)
|
||||
|
||||
These were explicitly scoped OUT during brainstorming. Open new brainstorm sessions for each when their turn comes:
|
||||
|
||||
- **Sub-B Multi-user account management** — invite teammates, role per user, per-action ACLs. Will add a Users item under the Account section of the sidebar.
|
||||
- **Sub-C Portal search** — global search across jobs / quotes / invoices / certs. Search input slot above Dashboard in the sidebar.
|
||||
- **Saved drafts (RFQ)** — user mentioned wanting drafts during configurator. Three scoping options proposed (minimal/medium/big); awaiting user direction. Not part of sub-A.
|
||||
- **Real Statements generation** — account.followup integration OR cron-precomputed monthly PDFs. Decide during sub-A Phase 3 implementation or defer to its own follow-up.
|
||||
- **Top Recurring Parts / Favorites / SerialNumber Lookup** — competitor-style features; deferred until customer demand confirmed.
|
||||
- **RMA customer portal** — sub-12 RMA backend exists; portal exposure is its own sub-project.
|
||||
|
||||
## Gotchas that bit us this session
|
||||
|
||||
Future Claude will hit these too unless documented. Most are already inline in CLAUDE.md or MEMORY.md. Worth a re-skim before touching the portal:
|
||||
|
||||
1. **`fp.coating.config` is retired** (Sub-11 cleanup). Use `fusion.plating.process.type` as the customer-facing coating taxonomy. Multiple `*.py` files still reference the dead model in COMMENTS — don't pattern-match from those.
|
||||
2. **Portal users can't read `fp.job` directly.** Controllers that return `fp.portal.job` records to a template MUST `sudo()` the search if the template traverses `job.x_fc_job_id`. Same pattern is already used for `sale.order`, `account.move`, `stock.picking`. Domain still filters to commercial partner tree.
|
||||
3. **`sale_pdf_quote_builder` gates on `report_name == 'sale.report_saleorder'`** (already in MEMORY.md). For customer-facing SO PDFs on the portal, render the FP custom `fusion_plating_reports.report_fp_sale_portrait` instead, and use a dedicated portal route that sudo-renders so the QWeb template can walk into `fp.part.catalog` etc.
|
||||
4. **Forms inside anchors is invalid HTML.** When making a whole card clickable AND embedding a Repeat-Order form inside, use a wrap div + inner anchor (main click target) + sibling actions footer (form lives here). Don't nest `<form>` inside `<a>`.
|
||||
5. **Groups list indexing drift.** `_fp_group_documents` builds the docs panel by appending to `groups[N]`. If you reorder the initial list or insert a new group mid-helper, every `groups[N]` reference shifts. The code has an inline warning comment now; respect it.
|
||||
6. **Per-stage timestamps are NULL on records created before the write hook deployed.** `_fp_get_stage_timeline` has a Date-fallback chain (received_date → received_at; actual_ship_date → shipped_at) plus linear interpolation for middle stages. Records created post-hook get real datetimes from the `fp.job.write()` mirror.
|
||||
7. **Stepper SCSS — `.o_fp_step_line` MUST stay nested inside `.o_fp_stepper`** (inline comment in the SCSS warns about this). When `flex:1` isn't applied because the rule slipped outside the parent, circles cluster on the left of the row.
|
||||
8. **Stepper labels align via absolute positioning per-unit** (not as a separate flex container). Wider labels like "Inspected" overflow equally to both sides of their circle's centre. Don't revert to the dual-container approach.
|
||||
9. **`fp.portal.job` state-sync map** uses `_FP_JOB_STATE_TO_PORTAL_STATE` in `fusion_plating_jobs/models/fp_job.py`. `on_hold` and `cancelled` deliberately NOT mirrored to the customer-facing state. Manager decision what to surface.
|
||||
|
||||
## How to deploy (entech LXC 111 on pve-worker5)
|
||||
|
||||
Same recipe used 20+ times this session. Per file:
|
||||
|
||||
```bash
|
||||
cat K:/Github/Odoo-Modules/fusion_plating/<module>/<path> | \
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<module>/<path>'"
|
||||
```
|
||||
|
||||
Then upgrade module + 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_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && \
|
||||
systemctl start odoo'"
|
||||
```
|
||||
|
||||
Bust asset cache for SCSS/JS changes:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||
\"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
|
||||
```
|
||||
|
||||
Service status / version check:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl is-active odoo'"
|
||||
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||
\"psql -d admin -t -c \\\"SELECT latest_version FROM ir_module_module \
|
||||
WHERE name='fusion_plating_portal';\\\"\""
|
||||
```
|
||||
|
||||
## How to start the next session
|
||||
|
||||
1. Open Claude Code in `K:\Github\Odoo-Modules\fusion-plating` (or `K:\Github\Odoo-Modules\fusion_plating` — both work, the dash dir has only 3 modules but it's the active working dir for the user's terminal).
|
||||
2. First message: "Resume the portal sub-A IA work — execute the approved plan from this session."
|
||||
3. New session should:
|
||||
- Read `CLAUDE.md` (auto-loaded) — the "Recent Session Handoff" section at the top points back to this file
|
||||
- Read this handoff doc
|
||||
- Read the plan: `docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`
|
||||
- Invoke `superpowers:subagent-driven-development` (or `executing-plans` for inline mode)
|
||||
- Execute the 11 tasks across 4 phases
|
||||
4. Optional but useful: re-run the existing test suite first to confirm starting from green: `--test-tags=fp_portal --stop-after-init`.
|
||||
|
||||
## Brainstorm artifacts
|
||||
|
||||
Visual companion mockups for this session live in `.superpowers/brainstorm/*/content/` (gitignored). Useful for visual comparison if needed:
|
||||
- `design-direction.html` — Modern SaaS / Corporate / Industrial picker
|
||||
- `saas-refinements.html` — V1/V2/V3 card variants
|
||||
- `dashboard-layout.html` — 6-card grid vs jobs-forward
|
||||
- `job-detail.html`, `branded-job-detail.html` — detail page mockups
|
||||
- `branded-dashboard.html` — final brand-applied dashboard
|
||||
- `sidebar-structure.html` — flat vs grouped vs hybrid (chose grouped)
|
||||
|
||||
Brainstorm server idles out after 30 min. Restart command:
|
||||
|
||||
```bash
|
||||
"C:/Users/gur_p/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh" \
|
||||
--project-dir "K:/Github/Odoo-Modules/fusion_plating"
|
||||
```
|
||||
|
||||
(Run in background; URL appears in `.superpowers/brainstorm/*/state/server-info`.)
|
||||
|
||||
## Critical files modified this session
|
||||
|
||||
If the next session needs to read context fast:
|
||||
- `fusion_plating_portal/controllers/portal.py` — most changes here
|
||||
- `fusion_plating_portal/controllers/portal_configurator.py` — coating model swap + dual upload
|
||||
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — jobs-forward layout
|
||||
- `fusion_plating_portal/views/fp_portal_templates.xml` — jobs list + detail rewrites
|
||||
- `fusion_plating_portal/views/fp_portal_macros.xml` — `fp_portal_job_card`, `fp_portal_stepper`, `fp_portal_status_badge`, `fp_portal_doc_chip`, `fp_portal_doc_group`
|
||||
- `fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss` — brand tokens
|
||||
- `fusion_plating_portal/static/src/scss/fp_portal_*.scss` — 7 partials (buttons, badges, cards, stepper, timeline, dashboard, legacy catch-all)
|
||||
- `fusion_plating_portal/models/fp_portal_job.py` — per-stage Datetime fields + write/create snapshot hooks
|
||||
- `fusion_plating_jobs/models/fp_job.py` — fp.job → fp.portal.job state-sync hook
|
||||
- `fusion_plating_portal/tests/test_portal_dashboard.py` — 5 tests, all green
|
||||
|
||||
## What user feedback is still outstanding
|
||||
|
||||
Nothing concrete waiting on user. Last thing the user did was approve the plan and say "create a handsoff script so i start a new session" — i.e., they want to pause here. Next session resumes execution.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# Customer Portal — Information Architecture + Sidebar Nav
|
||||
|
||||
**Module**: `fusion_plating_portal` (touches `portal.portal_layout` inherit)
|
||||
**Date**: 2026-05-17
|
||||
**Status**: Design locked, awaiting implementation plan
|
||||
**Surface**: every `/my/*` page on `https://enplating.com`
|
||||
**Sub-project**: A (of A/B/C); B = multi-user, C = portal search — deferred to separate brainstorms.
|
||||
|
||||
## Problem
|
||||
|
||||
The post-2026-05-17 portal redesign gave us a credible dashboard + jobs-detail page, but the navigation between pages is still "scroll the standard Odoo portal cards and hope you find the right entry point." Eight distinct customer surfaces (`/my/home`, `/my/jobs`, `/my/quote_requests`, `/my/configurator`, `/my/purchase_orders`, `/my/fp_invoices`, `/my/deliveries`, `/my/certifications`) and there's no persistent way to move between them. The customer's competitor screenshot (Mobility Specialties Inc / Drive Medical) shows the right pattern: a sticky left sidebar that lists every section, current page highlighted, secondary "Company Account" group at the bottom.
|
||||
|
||||
This spec restructures the portal around that sidebar pattern, audits the existing pages (replace thin custom pages with Odoo defaults where the default is better), and adds one missing page — a consolidated **Account Summary** with tabbed Invoices / Credit Memos / Statements + an Open Balance pill — that the existing thin `/my/fp_invoices` page doesn't deliver.
|
||||
|
||||
## User stories
|
||||
|
||||
1. **As a returning customer**, I want a persistent sidebar showing every section so I can jump between Quote Requests and Work Orders without going through the dashboard.
|
||||
2. **As an accounting clerk**, when I open the portal I want a single Account Summary page with Open Balance + filterable invoices + credit memos + downloadable monthly statements — without hunting through three separate menu items.
|
||||
3. **As any customer**, I want the active page visually marked so I always know where I am.
|
||||
4. **As a mobile user**, the sidebar should collapse to a hamburger so the page content gets the screen.
|
||||
|
||||
## Locked design decisions (from brainstorming 2026-05-17)
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Decomposition | A first (IA), B (multi-user) + C (search) deferred to separate brainstorms | Sidebar + pages are the foundation; building search before pages exist or a Users tab before the sidebar shape is locked would be rework. |
|
||||
| Sidebar shape | Option B — Dashboard at top, then 3 grouped sections (Activity / Documents / Account) | 10 items needs grouping to scan; matches how the redesigned dashboard already groups (KPI tiles → jobs hero → secondary panels). |
|
||||
| Account Summary tabs | 3 tabs: Invoices · Credit Memos · Statements, plus an "Open Balance: $X" pill in the page header | Mirrors competitor; one summary number front-of-mind, three drilldowns. |
|
||||
| Future placeholders | NEITHER "Users (soon)" nor a search input shown in the sidebar today | Empty placeholders add visual noise; ship them when sub-B / sub-C land. |
|
||||
| Sidebar persistence | Sticky on scroll; visible on every `/my/*` page (including Odoo defaults via `portal.portal_layout` inherit); sub-pages keep their parent highlighted | Industry standard. Consistency means the customer never loses their place. |
|
||||
| Mobile collapse | Below 768px the sidebar collapses to a hamburger button in the page header; opens as a slide-in drawer | Standard portal pattern, no content rearrangement needed. |
|
||||
| Single quote-creation path | `/my/quote_requests/new` redirects to `/my/configurator/new` | Two paths to the same outcome confuses customers; the configurator is the more complete flow. |
|
||||
| Sign Out placement | Bottom of sidebar, separated by a hairline border | Matches competitor; gets sign-out off the page chrome. |
|
||||
|
||||
## Scope
|
||||
|
||||
**IN SCOPE — pages restructured / new:**
|
||||
|
||||
- `/my/home` — keep dashboard, gets sidebar
|
||||
- `/my/jobs` — keep list, gets sidebar
|
||||
- `/my/jobs/<id>` — keep detail, gets sidebar (highlight parent)
|
||||
- `/my/quote_requests` — keep list, gets sidebar
|
||||
- `/my/quote_requests/<id>` — keep detail, gets sidebar
|
||||
- `/my/quote_requests/new` — **REDIRECT** to `/my/configurator/new`
|
||||
- `/my/configurator` — keep landing, gets sidebar
|
||||
- `/my/configurator/new`, `.../coating`, `.../estimate` — keep wizard, gets sidebar
|
||||
- `/my/purchase_orders` — **REDIRECT** to Odoo default `/my/orders`; controller + template deleted
|
||||
- `/my/fp_invoices` — **REDIRECT** to new `/my/account_summary`; controller + template deleted
|
||||
- `/my/account_summary` — **NEW** tabbed page (this spec)
|
||||
- `/my/deliveries` — keep, gets sidebar
|
||||
- `/my/certifications` — keep, gets sidebar
|
||||
- `/my/account` — Odoo default, gets sidebar
|
||||
- `/my/orders/<id>` — Odoo default, gets sidebar
|
||||
|
||||
**IN SCOPE — chrome:**
|
||||
|
||||
- New `fp_portal_shell` template that inherits `portal.portal_layout` and wraps every `o_portal` page body with a sticky 240px sidebar on the left.
|
||||
- Sidebar SCSS partial (`fp_portal_sidebar.scss`) — brand-teal active state, mint gradient highlight, hairline section dividers.
|
||||
- Mobile breakpoint: hamburger toggle + slide-in drawer below 768px.
|
||||
- All Odoo default portal pages (`/my/account`, `/my/orders`, `/my/orders/<id>`, `/my/invoices/<id>`, etc.) get the sidebar via the `portal.portal_layout` inherit — zero per-page edits.
|
||||
|
||||
**OUT OF SCOPE — deferred to other sub-projects:**
|
||||
|
||||
- Multi-user account management (sub-project B): Users tab in sidebar, invitation flow, per-action ACLs.
|
||||
- Portal search (sub-project C): global search input above Dashboard, search-result page.
|
||||
- Saved drafts (separate brainstorm — needs its own scoping).
|
||||
- Top Recurring Parts / Favorites / SerialNumber Lookup (defer until customer demand confirmed).
|
||||
- RMA customer portal (sub-project after RMA backend ships).
|
||||
|
||||
**OUT OF SCOPE — explicit non-goals:**
|
||||
|
||||
- Top-bar navigation, breadcrumbs redesign, footer changes — none of these are part of A.
|
||||
- Restyling Odoo default `/my/account` or `/my/orders/<id>` page BODIES. We give them the sidebar via the layout inherit, but their content stays Odoo-standard.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Sidebar shell
|
||||
|
||||
```
|
||||
fusion_plating_portal/views/fp_portal_shell.xml
|
||||
└── inherits portal.portal_layout
|
||||
└── injects .o_fp_portal_shell wrapper that contains:
|
||||
├── <aside class="o_fp_portal_sidebar"> (sticky, 240px)
|
||||
│ └── partner header + 4 sections + sign-out
|
||||
└── <main class="o_fp_portal_main"> (existing portal body)
|
||||
```
|
||||
|
||||
Per Odoo's `portal.portal_layout` extension pattern, we inherit and use `<xpath expr="//div[@id='wrap']" position="replace">` (or `position="inside"` on the right anchor — TBD during implementation) to wrap the existing layout. The sidebar is a single shared template (`fp_portal_sidebar`) rendered above the existing portal page body.
|
||||
|
||||
Active-state marker: each sidebar `<a>` reads the current `page_name` from the template context (already set by every FP route — `fp_dashboard`, `fp_jobs`, etc.) and applies `o_fp_sidebar_active` when matched. Falls back to URL prefix match for Odoo default pages (`/my/orders` → Purchase Orders highlighted, `/my/account` → Profile highlighted).
|
||||
|
||||
### Sidebar items (final list)
|
||||
|
||||
```
|
||||
ACME AEROSPACE <-- partner.commercial_partner_id.name
|
||||
─────────────────────────────────────────
|
||||
🏠 Dashboard /my/home
|
||||
ACTIVITY
|
||||
📄 Quote Requests /my/quote_requests
|
||||
+ Get a Quote /my/configurator
|
||||
🛒 Purchase Orders /my/orders (Odoo)
|
||||
⚙️ Work Orders /my/jobs
|
||||
DOCUMENTS
|
||||
📑 Certifications /my/certifications
|
||||
📦 Packing Slips /my/deliveries
|
||||
💰 Account Summary /my/account_summary (NEW)
|
||||
ACCOUNT
|
||||
👤 Profile /my/account (Odoo)
|
||||
─────────────────────────────────────────
|
||||
↪ Sign Out /web/session/logout
|
||||
```
|
||||
|
||||
Section headers (`ACTIVITY` / `DOCUMENTS` / `ACCOUNT`) are display-only, not links. The whole list is rendered from a single Python data structure in the template context (passed by a small helper on `FpCustomerPortal`), so adding the future Users / Drafts / Search items is a one-line addition.
|
||||
|
||||
### Account Summary page
|
||||
|
||||
**URL**: `/my/account_summary`
|
||||
**Controller method**: `portal_account_summary(self, **kw)` on `FpCustomerPortal`
|
||||
**Template**: `portal_my_account_summary` in `fp_portal_account_summary.xml` (new file)
|
||||
|
||||
**Page structure:**
|
||||
|
||||
```
|
||||
[ Account Summary ] Open Balance: $4,820.00
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
[ Invoices ] [ Credit Memos ] [ Statements ]
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Showing: Open · Closed · All [Search PO or #__________ ] [Sort ▾]
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
# | Status | Posted On | PO # | Due Date | Balance | View PDF
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
0035180274 | ● Open | May 13, 2026 | 53469 | Jun 12, 2026 | C$305.73 | View PDF
|
||||
...
|
||||
◀ Prev 1 2 3 4 5 Next ▶
|
||||
```
|
||||
|
||||
**Data sources (per tab):**
|
||||
|
||||
| Tab | Model + domain | Notes |
|
||||
|---|---|---|
|
||||
| Invoices | `account.move` where `move_type='out_invoice'`, `partner_id child_of commercial`, `state='posted'` | Today's `/my/fp_invoices` already does this; relocated here. |
|
||||
| Credit Memos | `account.move` where `move_type='out_refund'`, `partner_id child_of commercial`, `state='posted'` | Surfaces RMA credits when sub-12 RMA flow runs. Tab shows empty state with "No credits yet" when partner has none. |
|
||||
| Statements | Generated PDF per month via `account.followup` or a custom QWeb cron — **decided during implementation; preferred = use account.followup report directly per-customer with date filter** | Tab UI: month picker + Download button. |
|
||||
|
||||
**Open Balance pill** = sum of `amount_residual` across all open `out_invoice` records (regardless of tab). Computed in the controller, shown in the page header.
|
||||
|
||||
**Search box** = case-insensitive substring match on `name` (invoice number) OR `ref` (customer PO). Server-side filter, not JS.
|
||||
|
||||
**Sort options:** Newest → Oldest (default), Oldest → Newest, Largest balance, Smallest balance.
|
||||
|
||||
**Filter pills:** `Open` (residual > 0) / `Closed` (residual = 0) / `All`.
|
||||
|
||||
**Pagination:** 10 per page, server-side via `portal_pager`.
|
||||
|
||||
Invoice detail = existing Odoo `/my/invoices/<id>` page (no rewrite); the table's "View PDF" link goes to `/my/invoices/<id>?report_type=pdf&download=true` per Odoo's standard portal pattern.
|
||||
|
||||
### Mobile behavior
|
||||
|
||||
```scss
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_portal_sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
z-index: 1040;
|
||||
}
|
||||
.o_fp_portal_sidebar.o_fp_open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.o_fp_portal_hamburger { display: inline-flex; }
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.o_fp_portal_hamburger { display: none; }
|
||||
}
|
||||
```
|
||||
|
||||
Hamburger button lives in the page header (above the main content). Click toggles `o_fp_open` on the sidebar via 5-line vanilla JS (no framework). Backdrop click closes the drawer.
|
||||
|
||||
## Files
|
||||
|
||||
**NEW:**
|
||||
|
||||
- `fusion_plating_portal/views/fp_portal_shell.xml` — `portal.portal_layout` inherit + sidebar markup
|
||||
- `fusion_plating_portal/views/fp_portal_account_summary.xml` — `portal_my_account_summary` template
|
||||
- `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss` — sidebar styling (sticky, active state, sections, mobile drawer)
|
||||
- `fusion_plating_portal/static/src/js/fp_portal_sidebar.js` — hamburger toggle (vanilla JS, no OWL)
|
||||
|
||||
**MODIFY:**
|
||||
|
||||
- `fusion_plating_portal/controllers/portal.py`
|
||||
- NEW route `portal_account_summary` at `/my/account_summary`
|
||||
- DELETE route `portal_my_fp_invoices` (the thin invoice list at `/my/fp_invoices`)
|
||||
- REPLACE route `portal_my_purchase_orders` body with `return request.redirect('/my/orders')`
|
||||
- REPLACE the GET handler for `portal_my_quote_request_new` with `return request.redirect('/my/configurator/new')` (or delete entirely if the configurator already exposes the equivalent form)
|
||||
- NEW helper `_fp_sidebar_items(self)` returning the sidebar data structure (consumed by `fp_portal_sidebar` template via inherited `_prepare_portal_layout_values`)
|
||||
- Extend `_prepare_portal_layout_values()` to inject `fp_sidebar_items` + `fp_partner_display_name` into every portal page's context so the sidebar renders correctly on Odoo default pages too.
|
||||
- `fusion_plating_portal/views/fp_portal_templates.xml` — delete `portal_my_fp_invoices` template body (route is gone). Remaining templates (jobs list, jobs detail, deliveries, certifications) get the sidebar **for free** via the `portal.portal_layout` inherit; no per-template edits.
|
||||
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — dashboard template gets the sidebar via the layout inherit; no edits needed.
|
||||
- `fusion_plating_portal/__manifest__.py` — version bump + register the new XML/SCSS/JS files. Add `fp_portal_shell.xml` near the TOP of the `data` list (loaded before any template that uses sidebar variables).
|
||||
|
||||
**DELETE (or stub):**
|
||||
|
||||
- The `portal_my_fp_invoices` template body and the `portal_my_purchase_orders` template body. Routes redirected, templates unused. Keep route stubs so existing bookmarks 302 cleanly instead of 404.
|
||||
|
||||
## Migration / backward compatibility
|
||||
|
||||
| Old URL | New behavior |
|
||||
|---|---|
|
||||
| `/my/fp_invoices` | 302 → `/my/account_summary` |
|
||||
| `/my/purchase_orders` | 302 → `/my/orders` |
|
||||
| `/my/quote_requests/new` | 302 → `/my/configurator/new` |
|
||||
|
||||
No DB migration. No template namespace changes that break inherits. The page audit removes routes from the controller and templates from the data list; Odoo's module-upgrade cycle handles the ORM-side cleanup.
|
||||
|
||||
## Open items to verify during implementation
|
||||
|
||||
1. **`portal.portal_layout` extension pattern** — confirm the cleanest xpath for injecting the sidebar wrapper without breaking Odoo's existing portal CSS (`#wrap`, `.o_portal`). Likely `position="before"` on the main content slot. If unclear, fall back to inheriting at the `website.layout` level and writing a wholly new shell template.
|
||||
2. **Statements tab data source** — decide between (a) inline render of `account.followup` report per requested month, vs (b) precomputed monthly statement PDFs stored as attachments. Latter is simpler for V1; cron generates last-month statement on the 1st.
|
||||
3. **Mobile hamburger placement** — header anchor: a small button at the top-left of the main content area (above the page title) on mobile only. Confirm during Phase 4 visual pass.
|
||||
4. **Page-name → active-item mapping** — most FP routes set a clean `page_name` (e.g., `fp_jobs`, `fp_dashboard`). Odoo defaults don't; we'll match by URL prefix (`/my/orders` → `purchase_orders` item). One-helper `_fp_resolve_active_sidebar_item(url, page_name)` keeps the mapping in one place.
|
||||
5. **Account Summary Statements scope** — confirm whether monthly statements are something EN Plating currently generates, or if this is a new artifact we need to define a template for. If the latter, that's a separate small spec.
|
||||
|
||||
## What ships in a "done" state
|
||||
|
||||
- Every `/my/*` page (FP + Odoo default) shows the new sidebar.
|
||||
- Active page is visually marked.
|
||||
- Sidebar collapses to hamburger drawer below 768px.
|
||||
- `/my/account_summary` exists with 3 tabs, Open Balance pill, search + filter pills + sort + pagination.
|
||||
- 3 legacy URLs (`/my/fp_invoices`, `/my/purchase_orders`, `/my/quote_requests/new`) 302-redirect to their new homes.
|
||||
- Unit tests cover the new account_summary controller (3 tabs return the right counts, filter/search produce the right subset, Open Balance sums residuals correctly).
|
||||
- Module version bumped, deployed to entech, all 5 existing portal tests still green plus 3+ new tests for Account Summary.
|
||||
|
||||
---
|
||||
|
||||
*Sub-projects B (multi-user) and C (portal search) are tracked separately — they'll consume the sidebar slot conventions (insertion under ACCOUNT for Users, above DASHBOARD for the search input) defined here.*
|
||||
@@ -0,0 +1,196 @@
|
||||
# Certificate Creation Timing + Data Completeness Gates
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering incident:** WO-30040 marked done with no CoC produced — chatter showed `Cert auto-create (coc) failed: name 'coating' is not defined` (regression in `fusion_plating_jobs/models/fp_job.py:1706` where `coating` was referenced but never bound).
|
||||
|
||||
## Goal
|
||||
|
||||
Two things, decided as one unit of work:
|
||||
|
||||
1. **Fix the broken cert-creation path** so jobs marked done always produce the expected draft certs.
|
||||
2. **Harden the data-completeness gates** so a CoC cannot be issued with missing critical information.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the cert lifecycle timing (kept at `button_mark_done()`).
|
||||
- Wizard-based "Issue CoC" flow (Approach C, rejected).
|
||||
- SO-confirm cert-stub flow (Approach B, rejected).
|
||||
- Email delivery refactor — issuance still triggers existing `fp.notification.template` dispatch.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Cert creation stays at `fp.job.button_mark_done()` | All upstream data should be settled by then; existing architecture is sound — only the bug masks that. |
|
||||
| D2 | Receiving must close before job-done | qty_received blank or unreconciled blocks `button_mark_done`. Guarantees the cert always points to a closed receiving. |
|
||||
| D3 | Strict qty accounting | `qty_received ≡ qty_done + qty_scrapped + qty_visual_inspection_rejects`. NC qty on cert = `qty_scrapped + qty_visual_inspection_rejects`. |
|
||||
| D4 | Per-company default signer | New `res.company.x_fc_default_coc_signer_id`. Customer-spec signer_user_id wins if set. |
|
||||
| D5 | Per-partner default CoC contact | New `res.partner.x_fc_default_coc_contact_id`. Sales sets it once per customer. |
|
||||
| D6 | Mandatory fields at `action_issue()` | spec_reference (existing), process_description, certified_by_id, contact_partner_id with valid email, qty reconciliation. |
|
||||
| D7 | Backfill action for closed jobs missing certs | One-shot server action — walks `state='done'` jobs whose `_resolve_required_cert_types()` is non-empty and have no matching cert; calls `_fp_create_certificates()`. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ JOB EXECUTION ─────────────────────────────────────────────────┐
|
||||
│ Steps run → Bake → QC → Receiving closed │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ button_mark_done() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ qty_received present AND │
|
||||
│ qty_received ≡ qty_done + qty_scrapped + qty_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _fp_create_certificates() (bug fixed + richer prefill) │
|
||||
│ Resolved sources: │
|
||||
│ process_description ← job.recipe_id.name │
|
||||
│ certified_by_id ← customer_spec.signer_user_id │
|
||||
│ OR company.x_fc_default_coc_signer_id│
|
||||
│ contact_partner_id ← partner.x_fc_default_coc_contact_id │
|
||||
│ nc_quantity ← qty_scrapped + qty_visual_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Draft cert(s) — milestone advances to "Issue Certs" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ ISSUANCE ──────────────────────────────────────────────────────┐
|
||||
│ Manager opens cert → action_issue() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ process_description present │
|
||||
│ certified_by_id present │
|
||||
│ contact_partner_id present, with email │
|
||||
│ qty reconciliation (belt-and-suspenders vs Gate 1) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ state → issued, PDF generated, attached │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Schema changes (additive)
|
||||
|
||||
| Model | New field | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `res.company` | `x_fc_default_coc_signer_id` | M2O `res.users` | Default signing authority. Set once per facility. |
|
||||
| `res.partner` | `x_fc_default_coc_contact_id` | M2O `res.partner` (children of self) | Sales sets per customer. |
|
||||
|
||||
Both are additive — no data migration needed.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Version bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating` | 19.0.20.1.0 → 19.0.20.2.0 | `models/res_company.py`, `views/res_company_views.xml` (or settings view) |
|
||||
| `fusion_plating_certificates` | 19.0.6.1.0 → 19.0.6.2.0 | `models/res_partner.py`, `models/fp_certificate.py`, `views/res_partner_views.xml` |
|
||||
| `fusion_plating_jobs` | 19.0.10.8.0 → 19.0.10.9.0 | `models/fp_job.py` (mark_done gate + cert prefill bug fix + backfill action) |
|
||||
|
||||
## Gate logic — `button_mark_done()`
|
||||
|
||||
Inside the existing `if not skip_qty_gate and job.qty:` block, add:
|
||||
|
||||
```python
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received is blank. "
|
||||
"Close the receiving record for SO %s before completing this job."
|
||||
) % (job.name, job.sale_order_id.name if job.sale_order_id else '?'))
|
||||
|
||||
accounted_out = (job.qty_done or 0) + (job.qty_scrapped or 0) \
|
||||
+ (job.qty_visual_inspection_rejects or 0)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done (%g) + "
|
||||
"qty_scrapped (%g) + visual rejects (%g) = %g. "
|
||||
"Reconcile before closing."
|
||||
) % (job.name, job.qty_received, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, job.qty_visual_inspection_rejects or 0,
|
||||
accounted_out))
|
||||
```
|
||||
|
||||
Manager bypass: existing `fp_skip_qty_reconcile=True` context covers both.
|
||||
|
||||
## Cert prefill table (`_fp_create_certificates`)
|
||||
|
||||
| Cert field | Source |
|
||||
|---|---|
|
||||
| partner_id | `job.partner_id` (existing) |
|
||||
| sale_order_id | `job.sale_order_id` (existing) |
|
||||
| x_fc_job_id | `job.id` (existing) |
|
||||
| certificate_type | `_resolve_required_cert_types()` (existing) |
|
||||
| part_number | `job.part_catalog_id.part_number` (existing) |
|
||||
| entech_wo_number | `job.name` (existing) |
|
||||
| po_number | `job.sale_order_id.x_fc_po_number` (existing) |
|
||||
| customer_job_no | `job.sale_order_id.x_fc_customer_job_number` (existing) |
|
||||
| spec_reference | from `customer_spec.code [+ " Rev " + revision]` (existing) |
|
||||
| customer_spec_id | `job.customer_spec_id` (existing) |
|
||||
| quantity_shipped | `qty_done - qty_scrapped` (existing) |
|
||||
| **nc_quantity** | **`qty_scrapped + qty_visual_inspection_rejects`** (NEW) |
|
||||
| **process_description** | **`job.recipe_id.name`** (NEW; was broken — `coating` was undefined) |
|
||||
| **certified_by_id** | **`customer_spec.signer_user_id` OR `company.x_fc_default_coc_signer_id`** (NEW) |
|
||||
| **contact_partner_id** | **`partner.x_fc_default_coc_contact_id`** (NEW) |
|
||||
|
||||
## Gate logic — `action_issue()` (added in sequence before `state = 'issued'`)
|
||||
|
||||
1. **process_description present** — raise with hint to set coating-config / fill manually.
|
||||
2. **certified_by_id present** — raise with hint to set company default.
|
||||
3. **contact_partner_id present AND `email` non-empty** — raise with specific hint.
|
||||
4. **qty reconciliation** — defensive; reads `x_fc_job_id` if linked.
|
||||
|
||||
Order: cheapest checks first; first failure wins.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Job has no recipe_id | `process_description = False` → action_issue blocks → manager fills manually. |
|
||||
| Company has no default signer | `certified_by_id` blank → action_issue blocks. |
|
||||
| Partner has no default contact | `contact_partner_id` blank → action_issue blocks. |
|
||||
| Contact has no email | Action_issue blocks specifically on email. |
|
||||
| Customer-spec overrides company signer | `customer_spec.signer_user_id` wins (already used by signature unification). |
|
||||
| Multi-line SO with different recipes | First line with a recipe wins for process_description; manager can override. |
|
||||
| Re-running `_fp_create_certificates` | Idempotent by (job_id, certificate_type); NEW fields only set on initial create. |
|
||||
| Older jobs with NULL `qty_visual_inspection_rejects` | Coerce to 0; no migration needed. |
|
||||
| Receiving never existed (internal rework) | Mark_done blocks; manager bypass via `fp_skip_qty_reconcile=True`. |
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- WO-30040 itself (already `done`, no cert) is not auto-fixed by this change.
|
||||
- New server action **"Generate missing certs for closed jobs"** walks `fp.job` records where `state='done'` AND `_resolve_required_cert_types()` is non-empty AND no matching cert exists. Surfaced in the Jobs menu so the user can run once after deploy.
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** (in `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` and new `fusion_plating_certificates/tests/test_action_issue_gates.py`):
|
||||
|
||||
- `test_mark_done_blocks_on_blank_qty_received`
|
||||
- `test_mark_done_blocks_on_qty_received_mismatch`
|
||||
- `test_mark_done_passes_with_clean_qty_reconcile`
|
||||
- `test_mark_done_bypass_skips_qty_received_check`
|
||||
- `test_create_cert_resolves_recipe_name` (replaces "coating" wording)
|
||||
- `test_create_cert_handles_job_with_no_recipe`
|
||||
- `test_create_cert_prefills_signer_from_company`
|
||||
- `test_create_cert_prefills_signer_from_customer_spec`
|
||||
- `test_create_cert_prefills_contact_from_partner`
|
||||
- `test_create_cert_computes_nc_quantity`
|
||||
- `test_create_cert_handles_null_visual_rejects`
|
||||
- `test_action_issue_blocks_on_missing_process_description`
|
||||
- `test_action_issue_blocks_on_missing_certified_by`
|
||||
- `test_action_issue_blocks_on_missing_contact`
|
||||
- `test_action_issue_blocks_on_contact_without_email`
|
||||
- `test_action_issue_blocks_on_qty_mismatch`
|
||||
- `test_action_issue_passes_when_all_data_present`
|
||||
- `test_create_cert_idempotency`
|
||||
|
||||
**Manual verification on entech (post-deploy):**
|
||||
|
||||
1. Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs.
|
||||
2. Try `action_issue` → expect blockers for unset defaults.
|
||||
3. Configure defaults; retry → cert issues, PDF renders, attaches.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Push to `K:/Github/Odoo-Modules/fusion_plating/` (git path).
|
||||
- Mirror to docker mount as needed.
|
||||
- Update on entech LXC 111 via the deploy commands in `project_entech_session_handoff.md`.
|
||||
- Module install order: `fusion_plating` → `fusion_plating_certificates` → `fusion_plating_jobs`.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase A — Shipping Carrier Foundation
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Full shipping integration (Phases A–F). This spec covers Phase A only — the field-level foundation linking plating records to `fusion_shipping`'s existing shipment infrastructure.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the free-text `carrier_name` on `fp.receiving` with a proper M2O to `delivery.carrier`, and link both `fp.receiving` and `fp.delivery` to the `fusion.shipment` model that already exists in `fusion_shipping`. After Phase A, the receiver can pick a carrier from a 15-option dropdown and create a draft outbound shipment record — wiring is in place for Phase B (manual label entry) and Phase E (auto-label generation at receiving time).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Weight, dimensions, label PDF, tracking number on `fp.receiving` / `fp.delivery` themselves — these live on the linked `fusion.shipment` record (already implemented by `fusion_shipping`).
|
||||
- Bridge module (Phase C), Purolator integration (Phase D), at-receiving auto-label (Phase E), printer hookup (Phase F).
|
||||
- Modifying `fusion_shipping`'s existing models — Phase A is additive on the plating side only.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Carrier field: M2O to `delivery.carrier` (not Selection) | Matches `fusion_shipping`'s framework; allows API integration on the same record without conversion. |
|
||||
| D2 | Architecture: mirror fields on both `fp.receiving` and `fp.delivery`, auto-sync at delivery creation | Self-contained records; loose coupling; shipping crew can override per-stage. |
|
||||
| D3 | Source of truth for weight / dims / label / tracking: `fusion.shipment`, NOT mirrored on plating records | Shipment model already has every field; avoid duplicating + the sync logic. |
|
||||
| D4 | 15 carriers seeded as `delivery.carrier` data records (XML), all `delivery_type='fixed'` initially | Phase D will flip Purolator (and any others added) to their integration types. Manual carriers (Customer Pickup etc.) stay `fixed` permanently. |
|
||||
| D5 | Existing `carrier_name` (Char) and `carrier_tracking` (Char) kept as legacy | Migration populates `x_fc_carrier_id` by name match; unmatched text stays for operator review. |
|
||||
| D6 | `fusion_plating_receiving` + `fusion_plating_logistics` gain hard `fusion_shipping` dependency | The M2O to `fusion.shipment` requires the model to exist; no conditional compilation. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ fp.receiving ─────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer (smart-button counter) │
|
||||
│ Existing: carrier_name (Char) — legacy, populated by migration │
|
||||
│ Existing: carrier_tracking (Char) — legacy │
|
||||
│ │
|
||||
│ ACTION: action_create_outbound_shipment() │
|
||||
│ → creates fusion.shipment with sale_order_id + carrier_id │
|
||||
│ → idempotent: returns existing if already linked │
|
||||
│ ACTION: action_view_outbound_shipment() │
|
||||
│ → opens linked fusion.shipment in form view │
|
||||
│ ONCHANGE: x_fc_carrier_id propagates to linked shipment │
|
||||
│ → only if shipment.status == 'draft' │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
copy at delivery creation (fp.job._fp_create_delivery)
|
||||
│
|
||||
▼
|
||||
|
||||
┌─ fp.delivery ──────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer │
|
||||
│ Same ACTIONs and propagation as fp.receiving │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ delivery.carrier (seed data) ─────────────────────────────────────┐
|
||||
│ Already on entech: Standard delivery, Canada Post, Customer Pickup│
|
||||
│ Phase A adds (delivery_type='fixed', product_id=delivery. │
|
||||
│ product_product_delivery): │
|
||||
│ UPS, FedEx, USPS, DHL, Purolator, CCT, Canpar Express, │
|
||||
│ GLS Canada, Loomis Express, Day & Ross, Dicom Transportation, │
|
||||
│ Customer Drop-off, Local Delivery │
|
||||
│ Idempotent: XML uses noupdate=1 + record ids check existing names │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Field details
|
||||
|
||||
**On `fp.receiving`:**
|
||||
```python
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
help='The shipment record carrying weight, dimensions, label PDF, '
|
||||
'and tracking. Created via the "Create Outbound Shipment" '
|
||||
'button.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
```
|
||||
|
||||
Identical pair on `fp.delivery`.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_receiving` | 19.0.3.9.0 → 19.0.3.10.0 | manifest (+depends), `models/fp_receiving.py`, `views/fp_receiving_views.xml`, `data/delivery_carrier_seed_data.xml` (NEW), `migrations/19.0.3.10.0/post-migrate.py` (NEW), `tests/test_carrier_fields.py` (NEW) |
|
||||
| `fusion_plating_logistics` | bump | manifest (+depends), `models/fp_delivery.py`, `views/fp_delivery_views.xml`, `tests/test_delivery_shipping_fields.py` (NEW) |
|
||||
| `fusion_plating_jobs` | bump | `models/fp_job.py` (mirror at `_fp_create_delivery`), extend existing milestone-cascade test class |
|
||||
|
||||
## Migration logic (post-migrate)
|
||||
|
||||
```python
|
||||
def migrate(cr, version):
|
||||
# Name-match existing carrier_name text → delivery.carrier.name
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving r
|
||||
SET x_fc_carrier_id = dc.id
|
||||
FROM delivery_carrier dc
|
||||
WHERE r.carrier_name IS NOT NULL
|
||||
AND r.carrier_name <> ''
|
||||
AND r.x_fc_carrier_id IS NULL
|
||||
AND LOWER(TRIM(r.carrier_name)) =
|
||||
LOWER(TRIM((dc.name->>'en_US')))
|
||||
""")
|
||||
```
|
||||
|
||||
`delivery.carrier.name` is jsonb in Odoo 19 (translatable). The migration strips to `en_US` for the match.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Receiving has no SO link | Shipment creation works without `sale_order_id` (set_null on shipment side). |
|
||||
| Carrier picked but no shipment yet | Smart button reads "Create Outbound Shipment" → one click creates + opens. |
|
||||
| User changes carrier on receiving after shipment exists | Onchange propagates only when `shipment.status == 'draft'`. Confirmed/shipped shipments are left alone. |
|
||||
| Two receivings on same SO (split deliveries) | Each has its own `x_fc_outbound_shipment_id`. Mirror picks first one; user can change. |
|
||||
| Migration finds ambiguous name | Case-insensitive exact match only. Unmatched stays in `carrier_name` text. |
|
||||
| Shipment is deleted | `ondelete='set null'` — receiving keeps carrier but smart button reverts to "Create". |
|
||||
| `fusion_shipping` not installed | Manifest dependency fails fast on module load — correct failure mode. |
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** in `fusion_plating_receiving/tests/test_carrier_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_receiving`
|
||||
- `test_outbound_shipment_id_field_exists_on_receiving`
|
||||
- `test_action_create_outbound_shipment_creates_draft`
|
||||
- `test_action_create_outbound_shipment_idempotent`
|
||||
- `test_carrier_id_change_propagates_to_draft_shipment`
|
||||
- `test_carrier_id_change_does_not_propagate_to_confirmed_shipment`
|
||||
|
||||
**Unit tests** in `fusion_plating_logistics/tests/test_delivery_shipping_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_delivery`
|
||||
- `test_outbound_shipment_id_field_exists_on_delivery`
|
||||
|
||||
**Unit tests** extending `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
|
||||
- `test_create_delivery_mirrors_carrier_from_receiving`
|
||||
- `test_create_delivery_mirrors_outbound_shipment`
|
||||
- `test_create_delivery_no_receiving_no_mirror`
|
||||
|
||||
**Manual verification post-deploy:**
|
||||
1. Open RCV-30041 → carrier dropdown shows 15 options.
|
||||
2. Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
|
||||
3. Confirm `x_fc_outbound_shipment_id` is populated on RCV-30041.
|
||||
4. Confirm same fields/buttons on a fresh fp.delivery record auto-created via mark-done.
|
||||
|
||||
## Deployment
|
||||
|
||||
- 3 module upgrades: `fusion_plating_receiving`, `fusion_plating_logistics`, `fusion_plating_jobs`.
|
||||
- `fusion_shipping` is already installed — no action needed.
|
||||
- Migration runs automatically; spot-check by querying `fp_receiving.x_fc_carrier_id` post-deploy.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Phase C — Generate Label End-to-End
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Shipping integration phase 3 of 5 (after Phase A foundation; Phase B was merged in as a fallback path).
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the at-receiving outbound-label workflow: receiver enters weight + dimensions + picks carrier, clicks one button, system generates the carrier's shipping label PDF + tracking number (API when available, manual fallback when not). Operator prints the label, ships the box, customer gets the tracking link by email and on the portal.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
[Receiver] enters weight + dims + picks carrier on RECEIVING FORM
|
||||
↓
|
||||
Click "Generate Outbound Label"
|
||||
↓
|
||||
Carrier has API integration?
|
||||
├─ YES → carrier.send_shipping([picking]) → label PDF + tracking
|
||||
│ saved to fusion.shipment
|
||||
│
|
||||
└─ NO/API FAILS → open manual entry wizard
|
||||
operator pastes PDF + types tracking
|
||||
saved to fusion.shipment
|
||||
↓
|
||||
[Shipping] "Print Label" button → opens PDF in browser print dialog
|
||||
↓
|
||||
[Notification] fp.notification.template fires (event: shipment_labeled)
|
||||
with tracking_number + tracking_url placeholders
|
||||
↓
|
||||
[Portal] Job page renders tracking_number as clickable link to
|
||||
carrier.tracking_url template
|
||||
```
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Weight + dimensions live on fp.receiving as `related=` fields → fusion.shipment | Receiver enters them on the receiving form (their workflow); shipment stays as source of truth. |
|
||||
| D2 | One button: "Generate Outbound Label". API path is primary; manual is fallback | One UX, two branches inside. No separate "Manual Label Entry" flow surfaced to operator. |
|
||||
| D3 | Manual fallback opens automatically on API failure OR when carrier has no API integration | Operator never has to think about which path to take. |
|
||||
| D4 | Adapter approach: synthesize a stock.picking just for the API call (locked Phase C question) | Max reuse of existing fusion_shipping methods; picking is hidden from operator UIs. |
|
||||
| D5 | Notification trigger fires whenever tracking_number gets set (API OR manual), not at label generation | Same downstream behavior regardless of how the label was obtained. |
|
||||
| D6 | Portal renders tracking as `<a href="...">` using delivery.carrier.tracking_url template | Standard Odoo carrier tracking URL pattern. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Purolator integration (Phase D — independent).
|
||||
- Auto-print to a network printer (Phase F).
|
||||
- Multi-package shipments (single package per shipment in Phase C).
|
||||
- Rate quote / carrier shopping (just label generation).
|
||||
- Job sticker auto-print at same moment (Phase F).
|
||||
- Return labels (different API call; can come later).
|
||||
|
||||
## Files changing
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_receiving/models/fp_receiving.py` | NEW related fields: `x_fc_weight`, `x_fc_weight_uom`, `x_fc_length`, `x_fc_width`, `x_fc_height`, `x_fc_dim_uom` (related to fusion.shipment / fusion.order.package). NEW `x_fc_shipping_picking_id` (M2O stock.picking, back-link). NEW `action_generate_outbound_label()`. NEW `action_print_label()`. NEW helper `_fp_build_shipping_picking()`. |
|
||||
| `fusion_plating_receiving/wizards/__init__.py` (NEW) | Wizard module init. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard.py` (NEW) | Transient model: `receiving_id`, `label_pdf` (Binary), `label_filename` (Char), `tracking_number` (Char), `note` (Char — context why manual fallback). `action_confirm()` writes to fusion.shipment + closes wizard. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml` (NEW) | Wizard form view. |
|
||||
| `fusion_plating_receiving/views/fp_receiving_views.xml` | Add weight + dimensions group (Reception group). Add header buttons "Generate Outbound Label" + "Print Label". |
|
||||
| `fusion_plating_receiving/__manifest__.py` | Bump 19.0.3.10.0 → 19.0.3.11.0. Register new wizard files. Add `stock`, `delivery` to depends. |
|
||||
| `fusion_plating_receiving/security/ir.model.access.csv` | ACLs for the new wizard models. |
|
||||
| `fusion_plating_notifications/data/notification_templates.xml` (EXISTING — extend) | Add `shipment_labeled` trigger entry with default template. |
|
||||
| `fusion_plating_portal/views/fp_portal_templates.xml` (EXISTING — extend) | Render tracking_number as `<a>` link on job page. |
|
||||
| Tests | Three new files + extensions. |
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Related fields on fp.receiving
|
||||
|
||||
```python
|
||||
x_fc_weight = fields.Float(
|
||||
related='x_fc_outbound_shipment_id.weight',
|
||||
readonly=False, store=False,
|
||||
)
|
||||
# Similar for length/width/height — these come from fusion.order.package, not fusion.shipment directly.
|
||||
# Decision: write to the shipment's first package (auto-create if absent).
|
||||
```
|
||||
|
||||
Wait — `fusion.shipment.weight` exists, but length/width/height live on `fusion.order.package`. The shipment has a one2many relationship via `sale_order_id.package_ids`. For Phase C, the simplest path: store dimensions on the shipment by adding them as fields, OR auto-create a package per shipment.
|
||||
|
||||
**Resolved:** Phase C reads/writes weight + dimensions on the shipment record directly. If `fusion.shipment` doesn't have dimension fields, we add them via inheritance from this side (this is in fusion_shipping's model — would require touching it). Alternative: store on a synthetic fusion.order.package.
|
||||
|
||||
**Decision for spec:** add length/width/height + dim_uom as new fields directly on `fusion.shipment` via inheritance from `fusion_plating_receiving` (or move to fusion_shipping if appropriate during implementation). Cleaner than the package indirection for a single-package flow.
|
||||
|
||||
### action_generate_outbound_label
|
||||
|
||||
```python
|
||||
def action_generate_outbound_label(self):
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs() # carrier, weight, recipient addr, shipment exists
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('Carrier "%s" has no API integration. Enter the '
|
||||
'label PDF and tracking number manually.') % carrier.name,
|
||||
)
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping([picking]) # standard Odoo call
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except Exception as e:
|
||||
_logger.warning("Label gen failed for %s: %s", self.name, e)
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('API call failed: %s\n\nEnter the label manually below.') % e,
|
||||
)
|
||||
return self._fp_open_outbound_shipment_action() # smart-button target
|
||||
```
|
||||
|
||||
### Manual fallback wizard
|
||||
|
||||
Small transient model `fp.label.manual.wizard` with:
|
||||
- `receiving_id` (M2O fp.receiving, required)
|
||||
- `label_pdf` (Binary, required at confirm time)
|
||||
- `label_filename` (Char)
|
||||
- `tracking_number` (Char, required at confirm time)
|
||||
- `note` (Char, readonly — explanatory message)
|
||||
|
||||
`action_confirm()`:
|
||||
- Validate label + tracking present.
|
||||
- Write to the receiving's linked fusion.shipment: `label_attachment_id` (create ir.attachment) + `tracking_number` + `status='confirmed'`.
|
||||
- Close wizard, post chatter to receiving.
|
||||
|
||||
### Synthetic stock.picking
|
||||
|
||||
```python
|
||||
def _fp_build_shipping_picking(self):
|
||||
self.ensure_one()
|
||||
Picking = self.env['stock.picking']
|
||||
warehouse = self.env['stock.warehouse'].search([
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
picking_type = warehouse.out_type_id
|
||||
so = self.sale_order_id
|
||||
return Picking.create({
|
||||
'partner_id': so.partner_shipping_id.id,
|
||||
'picking_type_id': picking_type.id,
|
||||
'origin': so.name,
|
||||
'sale_id': so.id,
|
||||
'carrier_id': self.x_fc_carrier_id.id,
|
||||
# Synthetic single move from a generic shipping product:
|
||||
'move_ids': [(0, 0, {
|
||||
'name': 'Outbound Shipment %s' % self.name,
|
||||
'product_id': self.env.ref('product.product_product_4').id, # default service-type
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': self.env.ref('uom.product_uom_unit').id,
|
||||
'location_id': picking_type.default_location_src_id.id,
|
||||
'location_dest_id': picking_type.default_location_dest_id.id,
|
||||
})],
|
||||
'x_fc_fp_receiving_id': self.id, # back-link, defined on stock.picking
|
||||
})
|
||||
```
|
||||
|
||||
Then immediately after `send_shipping` succeeds:
|
||||
- `picking.action_confirm()` + `picking.action_assign()` + `picking.button_validate()` to take the picking to 'done' state (so it doesn't sit as draft in operator views).
|
||||
|
||||
### Notification trigger
|
||||
|
||||
Add event `shipment_labeled` to fp.notification.template selection. Default email template:
|
||||
```
|
||||
Subject: Your order is ready to ship — Tracking #{{ tracking_number }}
|
||||
Body: Hi {{ partner_name }},
|
||||
Your order for SO {{ sale_order_name }} has shipped.
|
||||
Tracking number: {{ tracking_number }}
|
||||
Track here: {{ tracking_url }}
|
||||
```
|
||||
|
||||
Fired by an `on_write` hook on `fusion.shipment` when `tracking_number` transitions from empty to non-empty.
|
||||
|
||||
### Portal display
|
||||
|
||||
In `fusion_plating_portal/views/fp_portal_templates.xml`, locate the job-card / job-detail rendering. Wherever tracking_ref is shown, replace with:
|
||||
```xml
|
||||
<t t-if="job.delivery_id and job.delivery_id.x_fc_outbound_shipment_id">
|
||||
<a t-att-href="job.delivery_id.x_fc_outbound_shipment_id.tracking_url"
|
||||
target="_blank">
|
||||
<t t-esc="job.delivery_id.x_fc_outbound_shipment_id.tracking_number"/>
|
||||
</a>
|
||||
</t>
|
||||
```
|
||||
`tracking_url` is a computed field on `fusion.shipment` that resolves the `delivery.carrier.tracking_url` template (already exists in Odoo).
|
||||
|
||||
## Test plan
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_generate_label_blocks_when_no_carrier` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_shipment` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_weight` | UserError raised |
|
||||
| `test_generate_label_routes_manual_for_fixed_carrier` | Wizard opens, no API call made |
|
||||
| `test_generate_label_calls_api_for_integrated_carrier` | carrier.send_shipping called once (mocked) |
|
||||
| `test_generate_label_writes_result_to_shipment_on_success` | tracking_number + label_attachment populated |
|
||||
| `test_generate_label_falls_back_to_wizard_on_api_failure` | Mock raises → wizard opens with note |
|
||||
| `test_manual_wizard_confirm_writes_shipment` | label + tracking saved; status confirmed |
|
||||
| `test_print_label_returns_attachment_action` | Action dict points to the label PDF |
|
||||
| `test_notification_fires_when_tracking_set` | fp.notification.template._dispatch called with shipment_labeled event |
|
||||
| `test_portal_renders_tracking_link` | Render contains `<a href="...">` with tracking URL |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| No warehouse configured | UserError: "No warehouse for the company — configure one in Settings > Warehouse." |
|
||||
| sale_order.partner_shipping_id missing | Falls back to `sale_order.partner_id`. |
|
||||
| Multi-package SO (rare) | Phase C single-package only. Multi-package raises with a "Phase E" note. |
|
||||
| Carrier API timeout | Caught as `Exception` in the try block; manual wizard opens with error in note. |
|
||||
| Operator generates label twice | Second call sees existing tracking, refuses and prompts to void/regenerate. |
|
||||
| Customer changes weight after label generated | Block weight edit when shipment.status == 'confirmed'. Manager can void shipment to re-generate. |
|
||||
|
||||
## Deployment
|
||||
|
||||
3 modules upgraded: `fusion_plating_receiving` (main), `fusion_plating_notifications` (trigger), `fusion_plating_portal` (link).
|
||||
|
||||
Manual verification on entech:
|
||||
1. Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
|
||||
2. Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has `delivery_type='fixed'` — manual wizard opens.
|
||||
3. Paste a sample PDF + tracking number in wizard. Confirm.
|
||||
4. Verify fusion.shipment has the label and tracking saved.
|
||||
5. Verify Print Label button works (opens PDF).
|
||||
6. (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Receiving Gate on Step Start / Finish
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering observation:** WO-30040 closed with `qty_received` blank and chatter warnings on Post-plate Inspection / Final Inspection ("Step started before parts were received"). The existing soft chatter warning is not strong enough — operators ignore it and the job still completes.
|
||||
|
||||
## Goal
|
||||
|
||||
Block step transitions (start AND finish) on any non-Contract-Review step until the SO's receiving record is closed. Future-proof for custom steps added later. Allow manager bypass via the existing `fp_skip_*` context-flag pattern.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Scope: all step kinds EXCEPT Contract Review | CR is paperwork — doesn't need parts on the floor. Every other step (including future custom steps) involves physical work. |
|
||||
| D2 | Timing: both `button_start` AND `button_finish` | Strongest. Operator can't begin OR complete physical work without receiving closed. Catches both "started too early" and "started before parts arrived, completed before they did". |
|
||||
| D3 | Threshold: `sale_order.x_fc_receiving_status == 'received'` | Post-Sub-8 (and the 2026-05-18 cleanup), `received` is the terminal receiving state. `not_received` and `partial` block. |
|
||||
| D4 | Manager bypass: `fp_skip_receiving_gate=True` context flag | Matches existing `fp_skip_*` pattern (qty_reconcile, qc_gate, step_gate, bake_gate). Auditor trail via chatter on the state transition. |
|
||||
| D5 | Implementation: single helper called from both buttons | Mirrors existing `_fp_check_contract_review_complete` pattern. DRY — same code tested once. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Receiving model's state machine (already correct post-Sub-8).
|
||||
- The `_update_so_receiving_status` mapping (already maps `closed → received`).
|
||||
- Other gates (qty_reconcile, qc_gate, bake_gate) — untouched.
|
||||
- Schema changes — pure behavior change.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
fp.job.step.button_start fp.job.step.button_finish
|
||||
1. Sequential-order gate (existing) 1. _fp_check_contract_review_complete (existing)
|
||||
2. _fp_check_receiving_gate() ← NEW 2. _fp_check_receiving_gate() ← NEW
|
||||
3. Contract Review auto-open (existing) 3. super().button_finish() + downstream (existing)
|
||||
4. Racking auto-open (existing)
|
||||
5. Standard path + serial promote (existing)
|
||||
[old soft chatter warning removed]
|
||||
```
|
||||
|
||||
## Helper method
|
||||
|
||||
```python
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review. Fires from both
|
||||
button_start and button_finish. Manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue # internal rework — gate doesn't apply
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
continue # defensive: configurator not installed
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(so.x_fc_receiving_status, so.x_fc_receiving_status or 'unknown')
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received yet '
|
||||
'(SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > Receiving) '
|
||||
'before starting or finishing work on this step. A '
|
||||
'manager can bypass this gate for documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
```
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs` | 19.0.10.12.0 → 19.0.10.13.0 | `models/fp_job_step.py` (helper + 2 callers + remove soft warning); `tests/test_fp_job_milestone_cascade.py` (new TestReceivingGate class) |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Step on job with no SO link (internal rework) | Gate doesn't fire — `continue`. |
|
||||
| Configurator module not installed (`x_fc_receiving_status` field absent) | Gate doesn't fire — `continue`. |
|
||||
| Contract Review step on `not_received` SO | Gate exempt; step proceeds (paperwork). |
|
||||
| Step on `partial` SO | Blocks — `partial` is not `received`. Operator waits for all boxes to land. |
|
||||
| Manager bypass via context | All gates skipped uniformly. Audit trail preserved via state-transition tracking. |
|
||||
|
||||
## Test plan
|
||||
|
||||
8 unit tests in new `TestReceivingGate` class in `test_fp_job_milestone_cascade.py`:
|
||||
|
||||
- `test_start_blocks_when_not_received`
|
||||
- `test_start_allows_when_received`
|
||||
- `test_start_skips_contract_review`
|
||||
- `test_start_bypass_via_context`
|
||||
- `test_finish_blocks_when_not_received`
|
||||
- `test_finish_allows_when_received`
|
||||
- `test_finish_skips_contract_review`
|
||||
- `test_finish_bypass_via_context`
|
||||
|
||||
**Manual verification on entech post-deploy:**
|
||||
1. Open SO-30041 (currently `not_received`) → fp.job → try `button_start` on first non-CR step → UserError raised.
|
||||
2. Close the receiving record (counted → staged → closed) → SO flips to `received`.
|
||||
3. Re-try `button_start` → succeeds.
|
||||
4. Repeat the start/finish flow with `fp_skip_receiving_gate=True` from a shell to verify bypass.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- The old soft chatter warning at fp_job_step.py:894-907 is removed. The information is no longer useful — it was a soft warning for a behavior we're now hard-blocking. The job's chatter still tracks the state transition via Odoo's tracking.
|
||||
- Jobs already in `in_progress` on `not_received` SOs at deploy time: any future button_finish will block. Manager must either close receiving OR use bypass.
|
||||
- No DB migration needed.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Single-module deploy to entech LXC 111 (`fusion_plating_jobs`).
|
||||
- No restart of dependent modules required.
|
||||
- Verify with manual flow above.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.13.0.2',
|
||||
'version': '19.0.13.0.3',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -420,14 +420,16 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'partial' or recv_status == 'received':
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state.
|
||||
# Inspection happens in the recipe's racking step, not
|
||||
# in receiving.
|
||||
if not so.x_fc_assigned_manager_id:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
# Manager assigned, MOs exist → in production
|
||||
so.x_fc_workflow_stage = 'in_production'
|
||||
continue
|
||||
|
||||
@@ -450,17 +452,23 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving as accepted; this unlocks manager assignment."""
|
||||
"""Mark receiving as accepted; this unlocks manager assignment.
|
||||
|
||||
Sub 8: receiving's terminal state is 'closed' (post-Sub-8) or
|
||||
'accepted' (legacy). Either maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state == 'inspecting':
|
||||
rec.state = 'accepted'
|
||||
# flip SO receiving status to 'inspected' if possible
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.6.1.0',
|
||||
'version': '19.0.6.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -37,6 +37,7 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_certificates_menu.xml',
|
||||
'wizards/fp_cert_void_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -88,6 +88,24 @@ class FpCertificate(models.Model):
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
|
||||
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
# linked QC check. That works when the operator uploaded it via the
|
||||
# tablet, but managers issuing certs after the fact don't want to
|
||||
# navigate to the QC. This pair of fields gives them a direct upload
|
||||
# path on the cert form. When set, _fp_merge_thickness_into_pdf uses
|
||||
# this in preference to the QC-side upload.
|
||||
x_fc_local_thickness_pdf = fields.Binary(
|
||||
string='Fischerscope PDF (Upload Here)',
|
||||
attachment=True,
|
||||
help='Drop the Fischerscope / XDAL 600 XRF export PDF here. '
|
||||
'When the cert is issued it will be appended as page 2 of '
|
||||
'the CoC. Overrides any PDF on the linked QC check.',
|
||||
)
|
||||
x_fc_local_thickness_pdf_filename = fields.Char(
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# ---- Material traceability (T2.3) ----
|
||||
batch_ids = fields.Many2many(
|
||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
||||
@@ -330,6 +348,23 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Lazy-fill from partner defaults BEFORE running the gates.
|
||||
# Without this, a cert created before partner.x_fc_default_*
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
)
|
||||
if (not rec.certified_by_id
|
||||
and rec.company_id
|
||||
and 'x_fc_owner_user_id' in rec.company_id._fields
|
||||
and rec.company_id.x_fc_owner_user_id):
|
||||
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
@@ -340,24 +375,127 @@ class FpCertificate(models.Model):
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Aerospace / Nadcap customers: actual thickness readings
|
||||
# must be on file BEFORE the cert is issued. The flag lives
|
||||
# on the partner so commercial customers aren't blocked.
|
||||
if (rec.partner_id
|
||||
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_strict_thickness_required
|
||||
and rec.certificate_type == 'coc'):
|
||||
if not rec.thickness_reading_ids:
|
||||
# Process description (what was done to the parts). Without
|
||||
# it the cert PDF just shows blank process text — customer
|
||||
# has no idea what they paid for. Auto-filled from the
|
||||
# recipe at create time; manager can override before issuing.
|
||||
if not rec.process_description:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Process '
|
||||
'Description is blank.\n\nFill it manually (e.g. '
|
||||
'"ELECTROLESS NICKEL PLATING PER AMS 2404") or '
|
||||
'assign a recipe to the job so it auto-fills.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Signing authority — the human who attests the work. Auto-
|
||||
# filled from per-spec signer_user_id, falling back to
|
||||
# company.x_fc_owner_user_id. If neither is configured, the
|
||||
# manager must pick before issuing.
|
||||
if not rec.certified_by_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Certified By '
|
||||
'is not set.\n\nPick the signing authority, or have '
|
||||
'an admin configure the company\'s Certificate Owner '
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||
)
|
||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
||||
if not (has_readings or has_qc_fischer_pdf or has_local_pdf):
|
||||
type_label = (
|
||||
_('Thickness Report')
|
||||
if rec.certificate_type == 'thickness_report'
|
||||
else _('CoC')
|
||||
)
|
||||
raise UserError(_(
|
||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||
'requires actual thickness readings on every CoC '
|
||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||
'against the job for SO %(so)s via the Tablet Station '
|
||||
'before issuing.'
|
||||
'Cannot issue %(type)s "%(name)s" — customer '
|
||||
'"%(cust)s" requires thickness data on every '
|
||||
'%(type)s. No readings, no Fischerscope PDF on '
|
||||
'the linked QC, and no local Fischerscope upload '
|
||||
'on this cert.\n\nUse the Issue Certs wizard '
|
||||
'from the work order to upload the Fischerscope '
|
||||
'report, or log readings against the job for '
|
||||
'SO %(so)s via the Tablet Station.'
|
||||
) % {
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': partner.name if partner else '?',
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
})
|
||||
# Defensive qty reconciliation — should already be guaranteed
|
||||
# by fp.job.button_mark_done's gate, but re-checked here so
|
||||
# certs created outside the job flow (manual, scripts) still
|
||||
# can't issue with a mismatched job. No bypass — qty integrity
|
||||
# is non-negotiable at issue.
|
||||
job = (rec.x_fc_job_id
|
||||
if 'x_fc_job_id' in rec._fields else False)
|
||||
if job and job.qty_received:
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted) > 0.0001:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — job '
|
||||
'%(job)s qty mismatch (received %(r)g vs '
|
||||
'accounted-out %(a)g). Reconcile job '
|
||||
'quantities before issuing.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name,
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
'job': job.name,
|
||||
'r': job.qty_received,
|
||||
'a': accounted,
|
||||
})
|
||||
rec.state = 'issued'
|
||||
# Generate the CoC PDF and attach it so action_send_to_customer
|
||||
@@ -445,35 +583,48 @@ class FpCertificate(models.Model):
|
||||
self.ensure_one()
|
||||
if self.certificate_type != 'coc':
|
||||
return None
|
||||
# Find the linked job. fp.certificate has either x_fc_job_id
|
||||
# (preferred — added by fusion_plating_jobs) or job_id (older).
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
# Find a passed QC on this job with an uploaded Fischerscope PDF.
|
||||
# Prefer state=passed; fall through to any with a PDF.
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
# Resolution order for the source of the Fischerscope bytes:
|
||||
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
|
||||
# dropped it directly on the cert form
|
||||
# 2. Linked QC's thickness_report_pdf_id — operator uploaded
|
||||
# via the tablet during inspection
|
||||
# Either path yields the same merged-PDF outcome.
|
||||
fischer_bytes = b''
|
||||
qc = False
|
||||
if self.x_fc_local_thickness_pdf:
|
||||
try:
|
||||
fischer_bytes = _b64.b64decode(
|
||||
self.x_fc_local_thickness_pdf or b''
|
||||
)
|
||||
except Exception:
|
||||
fischer_bytes = b''
|
||||
if not fischer_bytes:
|
||||
# Fall through to the QC-side PDF.
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
if not fischer_bytes:
|
||||
return None
|
||||
# Merge — pypdf is the modern name; PyPDF2 still works on older
|
||||
@@ -519,9 +670,13 @@ class FpCertificate(models.Model):
|
||||
'CoC-only.', self.name,
|
||||
)
|
||||
return None
|
||||
source = (
|
||||
_('cert upload') if self.x_fc_local_thickness_pdf
|
||||
else _('QC %s') % (qc.name if qc else '?')
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Fischerscope thickness report from QC %s appended to CoC PDF.'
|
||||
) % qc.name)
|
||||
'Fischerscope thickness report (%s) appended to CoC PDF.'
|
||||
) % source)
|
||||
return merged
|
||||
|
||||
def action_void(self):
|
||||
@@ -533,6 +688,33 @@ class FpCertificate(models.Model):
|
||||
rec.state = 'voided'
|
||||
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
|
||||
|
||||
def action_open_void_wizard(self):
|
||||
"""Open the void-reason wizard. Bound to the Void header button
|
||||
instead of action_void directly so the manager always supplies a
|
||||
written reason (the underlying action_void still blocks on a
|
||||
blank reason as a defensive last-line check)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.state)
|
||||
Wizard = self.env.get('fp.cert.void.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Void wizard not available. Reinstall '
|
||||
'fusion_plating_certificates.'
|
||||
))
|
||||
wiz = Wizard.create({'cert_id': self.id})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Void %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_traceability(self):
|
||||
"""Show the batches (and their chemistry logs) that produced
|
||||
these parts — auditor's dream, customer's RMA friend."""
|
||||
|
||||
@@ -98,3 +98,18 @@ class ResPartner(models.Model):
|
||||
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
)
|
||||
|
||||
@@ -5,3 +5,5 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_action_issue_gates
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issuance-gate tests for fp.certificate.action_issue.
|
||||
|
||||
Covers the 2026-05-18 hardening that adds blocking checks for
|
||||
process_description, certified_by_id, contact_partner_id (with email),
|
||||
and qty reconciliation. See
|
||||
docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md.
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestActionIssueGates(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Signer',
|
||||
'login': 'signer_certissue',
|
||||
'email': 'signer@example.com',
|
||||
})
|
||||
cls.contact_with_email = cls.env['res.partner'].create({
|
||||
'name': 'Anne Recipient',
|
||||
'email': 'anne@cust.example',
|
||||
})
|
||||
cls.contact_no_email = cls.env['res.partner'].create({
|
||||
'name': 'Carl NoEmail',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'IssueCust',
|
||||
'is_company': True,
|
||||
})
|
||||
cls.contact_with_email.parent_id = cls.partner.id
|
||||
cls.contact_no_email.parent_id = cls.partner.id
|
||||
|
||||
def _make_cert(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
|
||||
# ---- the existing gate still works (spec_reference) ----
|
||||
|
||||
def test_blocks_on_missing_spec_reference(self):
|
||||
cert = self._make_cert(spec_reference=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
|
||||
# ---- new gate: process_description ----
|
||||
|
||||
def test_blocks_on_missing_process_description(self):
|
||||
cert = self._make_cert(process_description=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: certified_by_id ----
|
||||
|
||||
def test_blocks_on_missing_certified_by(self):
|
||||
cert = self._make_cert(certified_by_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Certified By', str(exc.exception))
|
||||
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
|
||||
# ---- happy path ----
|
||||
|
||||
def test_passes_when_all_data_present(self):
|
||||
cert = self._make_cert()
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
# ---- order: spec_reference still wins (cheapest first) ----
|
||||
|
||||
def test_gate_order_spec_reference_first(self):
|
||||
# Multiple missing → spec_reference message surfaces first.
|
||||
cert = self._make_cert(
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
# And NOT the process_description message (gate hit first).
|
||||
self.assertNotIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: thickness_report cert needs thickness data ----
|
||||
|
||||
def test_blocks_thickness_report_with_no_data(self):
|
||||
"""A thickness_report cert with zero readings and no Fischerscope
|
||||
PDF is empty paper — must block at issue."""
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('thickness data', str(exc.exception).lower())
|
||||
|
||||
def test_thickness_report_passes_with_readings(self):
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
self.env['fp.thickness.reading'].create({
|
||||
'certificate_id': cert.id,
|
||||
'nip_mils': 0.4,
|
||||
})
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
def test_coc_does_not_require_thickness_data_by_default(self):
|
||||
"""Commercial CoC (no strict_thickness flag) should still pass
|
||||
even without readings — only thickness_report type is gated."""
|
||||
cert = self._make_cert(certificate_type='coc')
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
@@ -42,7 +42,7 @@
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_void" string="Void"
|
||||
<button name="action_open_void_wizard" string="Void"
|
||||
type="object" class="btn-danger"
|
||||
invisible="state != 'issued'"/>
|
||||
<button name="action_send_to_customer" string="Send to Customer"
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<field name="x_fc_send_bol" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
<p class="text-muted">
|
||||
Boilerplate text printed in the "Certification Statement"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_cert_void_wizard
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Void Certificate Wizard.
|
||||
|
||||
Opened from an issued cert's "Void" button. Prompts the manager for a
|
||||
written reason, then calls action_void on the cert with the reason
|
||||
populated. The cert's chatter records the void event with the reason
|
||||
inline via the existing _logger / message_post in action_void.
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCertVoidWizard(models.TransientModel):
|
||||
_name = 'fp.cert.void.wizard'
|
||||
_description = 'Fusion Plating — Void Certificate Wizard'
|
||||
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
void_reason = fields.Text(
|
||||
string='Void Reason',
|
||||
help='Why this certificate is being voided. Printed on the '
|
||||
'cert chatter and visible in audit trails. Required for '
|
||||
'AS9100 / Nadcap document control. Validation happens at '
|
||||
'confirm time so the wizard can open empty.',
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not (self.void_reason or '').strip():
|
||||
raise UserError(_(
|
||||
'Please enter a void reason before voiding. The reason '
|
||||
'is logged to the cert chatter and printed on the audit '
|
||||
'trail (AS9100 / Nadcap requirement).'
|
||||
))
|
||||
if self.cert_id.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.cert_id.state)
|
||||
# Write the reason FIRST so the cert's action_void gate passes.
|
||||
self.cert_id.void_reason = self.void_reason
|
||||
self.cert_id.action_void()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_void_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.void.wizard.form</field>
|
||||
<field name="model">fp.cert.void.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Void Certificate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Void Certificate <field name="cert_name"
|
||||
readonly="1"
|
||||
nolabel="1"
|
||||
class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
Voiding marks this certificate as no longer
|
||||
valid. The audit trail keeps the record visible
|
||||
but flagged. Required for AS9100 / Nadcap
|
||||
document control.
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="void_reason"
|
||||
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Void Certificate"
|
||||
class="btn-danger"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.4.0',
|
||||
'version': '19.0.21.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the 'inspected' value from sale_order.x_fc_receiving_status.
|
||||
|
||||
Sub 8 (2026-04-22) moved part inspection out of receiving and into the
|
||||
recipe's racking step. The SO-level receiving status no longer needs
|
||||
'inspected' as a terminal value — 'received' (boxes counted/staged/
|
||||
closed) is now the final state.
|
||||
|
||||
This migration flips any existing rows with the obsolete value to the
|
||||
new terminal value. On a freshly-installed instance there are zero rows;
|
||||
the migration is defensive for instances that had pre-Sub-8 records.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE sale_order
|
||||
SET x_fc_receiving_status = 'received'
|
||||
WHERE x_fc_receiving_status = 'inspected'
|
||||
""")
|
||||
@@ -74,8 +74,12 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
x_fc_receiving_status = fields.Selection(
|
||||
[('not_received', 'Not Received'), ('partial', 'Partial'),
|
||||
('received', 'Received'), ('inspected', 'Inspected')],
|
||||
('received', 'Received')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
help='State of the linked fp.receiving record(s). Inspection is '
|
||||
"no longer a receiving state — Sub 8 moved part inspection "
|
||||
'into the recipe (racking step), so receiving stops at '
|
||||
'"received" (boxes counted, staged, closed).',
|
||||
)
|
||||
|
||||
# ---- Direct Order rewrite (Phase A) ----
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.8.0',
|
||||
'version': '19.0.10.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -67,9 +67,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'views/fp_step_priority_views.xml',
|
||||
'views/jobs_in_shopfloor_menu.xml',
|
||||
'views/legacy_menu_hide.xml',
|
||||
'views/fp_job_cert_backfill.xml',
|
||||
'views/res_users_views.xml',
|
||||
'wizards/fp_job_step_move_wizard_views.xml',
|
||||
'wizards/fp_job_step_input_wizard_views.xml',
|
||||
'wizards/fp_cert_issue_wizard_views.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
'report/report_fp_job_traveller.xml',
|
||||
'report/report_fp_job_wo_detail.xml',
|
||||
|
||||
@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
|
||||
'x_fc_local_thickness_pdf')
|
||||
def _compute_fischer_visibility(self):
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
||||
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
if QC is not None and rec.x_fc_job_id:
|
||||
# Cert-local upload wins over QC-side PDF (matches the
|
||||
# merge resolution order in fp_certificate.py).
|
||||
if rec.x_fc_local_thickness_pdf:
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
elif QC is not None and rec.x_fc_job_id:
|
||||
# Same lookup the merge method uses — passed-first,
|
||||
# then any QC with a PDF.
|
||||
qc = QC.sudo().search([
|
||||
|
||||
@@ -189,6 +189,15 @@ class FpJob(models.Model):
|
||||
back to partner-level send_coc / send_thickness_report flags.
|
||||
'none' returns empty (commercial customer, no paperwork).
|
||||
Unknown requirement codes default to {'coc'} as a safety net.
|
||||
|
||||
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
|
||||
wanted AND thickness is wanted, the thickness data is delivered
|
||||
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
|
||||
so we return ONE cert ({'coc'}) instead of two. A standalone
|
||||
thickness_report cert is only produced when thickness is wanted
|
||||
WITHOUT a CoC — a rare edge case kept for completeness.
|
||||
Action_issue's thickness-data gate enforces actual readings or
|
||||
a Fischerscope PDF on the merged CoC.
|
||||
"""
|
||||
self.ensure_one()
|
||||
req = (
|
||||
@@ -196,16 +205,17 @@ class FpJob(models.Model):
|
||||
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
|
||||
want_coc = bool(self.partner_id.x_fc_send_coc)
|
||||
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
|
||||
if want_coc:
|
||||
return {'coc'} # thickness gets merged in
|
||||
if want_thickness:
|
||||
return {'thickness_report'}
|
||||
return set()
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
'coc_thickness': {'coc'}, # bundled — thickness on page 2
|
||||
}.get(req, {'coc'})
|
||||
|
||||
next_milestone_action = fields.Selection(
|
||||
@@ -308,9 +318,29 @@ class FpJob(models.Model):
|
||||
return fn()
|
||||
|
||||
def _action_open_draft_certs(self):
|
||||
"""Open the cert list filtered to draft certs for this job.
|
||||
Manager reviews each in turn and clicks Issue per-cert."""
|
||||
"""Open the Issue Certs wizard for this job's draft certs.
|
||||
|
||||
The wizard prompts for a Fischerscope upload + readings per cert
|
||||
that needs thickness data (bundled CoC or standalone thickness
|
||||
report). Pure CoC certs (no thickness needed) appear in the
|
||||
wizard too and just need a Confirm click. Cleaner than the old
|
||||
"list view → open each cert → click Issue" flow.
|
||||
|
||||
Falls back to the cert list view if the wizard model isn't
|
||||
installed (defensive — should always exist when this module is).
|
||||
"""
|
||||
self.ensure_one()
|
||||
Wizard = self.env.get('fp.cert.issue.wizard')
|
||||
if Wizard is not None:
|
||||
try:
|
||||
return Wizard.open_for_job(self)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: cert issue wizard failed (%s) — "
|
||||
"falling back to cert list.", self.name, e,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Draft Certificates — %s') % self.name,
|
||||
@@ -1521,6 +1551,37 @@ class FpJob(models.Model):
|
||||
job.name, job.qty, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, accounted, job.qty,
|
||||
))
|
||||
# Receiving reconciliation: parts must be physically
|
||||
# received before the job can close, and the count must
|
||||
# match what came out (done + scrapped + visual rejects).
|
||||
# Without this guard a job ships with the wrong cert qty,
|
||||
# or worse, with no closed receiving for the auditor to
|
||||
# trace back to. Same bypass flag covers both checks.
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received "
|
||||
"is blank. Close the receiving record for SO %s "
|
||||
"before completing this job."
|
||||
) % (
|
||||
job.name,
|
||||
job.sale_order_id.name if job.sale_order_id else '?',
|
||||
))
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted_out = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done "
|
||||
"(%g) + qty_scrapped (%g) + visual rejects (%g) "
|
||||
"= %g. Reconcile before closing."
|
||||
) % (
|
||||
job.name, job.qty_received,
|
||||
job.qty_done or 0, job.qty_scrapped or 0,
|
||||
rejects, accounted_out,
|
||||
))
|
||||
# QC gate: customers flagged x_fc_requires_qc must have a
|
||||
# passed QC before the job closes. AS9100 / Nadcap compliance.
|
||||
if QC and not skip_qc_gate \
|
||||
@@ -1596,6 +1657,10 @@ class FpJob(models.Model):
|
||||
refund auto-link, and the legacy notification dispatch all
|
||||
look up by job_ref. Setting both ends keeps every consumer
|
||||
happy.
|
||||
|
||||
Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id
|
||||
from the linked receiving so the delivery carries the shipping
|
||||
choices made at receipt time. Shipping crew can override later.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.delivery_id:
|
||||
@@ -1612,6 +1677,25 @@ class FpJob(models.Model):
|
||||
"Job %s: fusion.plating.delivery has no job link field; "
|
||||
"delivery created without job back-reference.", self.name,
|
||||
)
|
||||
# Mirror outbound carrier + shipment from the SO's first
|
||||
# receiving record. If there are multiple receivings (split
|
||||
# shipments), the shipping crew can change either field on the
|
||||
# delivery form. Defensive: skip when fields aren't present
|
||||
# (older instance) or no receiving exists.
|
||||
if (self.sale_order_id
|
||||
and 'x_fc_receiving_ids' in self.sale_order_id._fields
|
||||
and self.sale_order_id.x_fc_receiving_ids):
|
||||
recv = self.sale_order_id.x_fc_receiving_ids[:1]
|
||||
if 'x_fc_carrier_id' in Delivery._fields \
|
||||
and 'x_fc_carrier_id' in recv._fields \
|
||||
and recv.x_fc_carrier_id:
|
||||
vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id
|
||||
if 'x_fc_outbound_shipment_id' in Delivery._fields \
|
||||
and 'x_fc_outbound_shipment_id' in recv._fields \
|
||||
and recv.x_fc_outbound_shipment_id:
|
||||
vals['x_fc_outbound_shipment_id'] = (
|
||||
recv.x_fc_outbound_shipment_id.id
|
||||
)
|
||||
try:
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
@@ -1626,13 +1710,20 @@ class FpJob(models.Model):
|
||||
on a job that already has a CoC won't create another one.
|
||||
|
||||
Each cert is pre-populated with everything action_issue needs
|
||||
(partner, spec_reference, part_number, quantity_shipped, po,
|
||||
(partner, spec_reference, process_description, certified_by,
|
||||
contact_partner, part_number, quantity_shipped, NC qty, PO,
|
||||
SO link, job link) so the manager just reviews and clicks Issue.
|
||||
|
||||
Replaces the single-CoC implementation: now honours
|
||||
part.certificate_requirement (coc / coc_thickness / none /
|
||||
inherit) and partner-level send_coc / send_thickness_report
|
||||
flags. Closes spec gap C-G1.
|
||||
Resolution sources for the new prefill fields:
|
||||
- process_description ← recipe.name (the job's process root)
|
||||
- certified_by_id ← customer_spec.signer_user_id, falling
|
||||
back to company.x_fc_owner_user_id
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
none / inherit) and partner-level send_coc /
|
||||
send_thickness_report flags. Closes spec gap C-G1.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
@@ -1645,6 +1736,25 @@ class FpJob(models.Model):
|
||||
# Spec drives the cert spec_reference. The customer.spec was
|
||||
# auto-filled onto the job at confirm time (sale_order.py).
|
||||
spec = self.customer_spec_id
|
||||
# Recipe drives the process description on the cert. Was previously
|
||||
# sourced from sale_order.x_fc_coating_config_id (since retired);
|
||||
# recipe.name is the human-readable replacement.
|
||||
recipe = self.recipe_id
|
||||
# Signer resolution: per-spec override wins, company default fills.
|
||||
signer = False
|
||||
if spec and 'signer_user_id' in spec._fields:
|
||||
signer = spec.signer_user_id
|
||||
if not signer and 'x_fc_owner_user_id' in self.company_id._fields:
|
||||
signer = self.company_id.x_fc_owner_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
+ (self.qty_visual_inspection_rejects or 0)
|
||||
)
|
||||
for cert_type in sorted(required):
|
||||
# Idempotency per type.
|
||||
existing_dom = [('certificate_type', '=', cert_type)]
|
||||
@@ -1691,6 +1801,8 @@ class FpJob(models.Model):
|
||||
(self.qty_done or self.qty or 0)
|
||||
- (self.qty_scrapped or 0)
|
||||
)
|
||||
if 'nc_quantity' in Cert._fields:
|
||||
vals['nc_quantity'] = nc_qty
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = (
|
||||
@@ -1703,8 +1815,12 @@ class FpJob(models.Model):
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
if 'process_description' in Cert._fields and recipe:
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
@@ -1728,6 +1844,107 @@ class FpJob(models.Model):
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backfill — closed jobs missing certs, plus cleanup of legacy
|
||||
# duplicate thickness_report certs created before the bundling rule.
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot management action for jobs that closed BEFORE the
|
||||
# _fp_create_certificates bug fix (e.g. WO-30040). Two passes:
|
||||
# 1. CREATE any missing draft cert per the (updated) resolver
|
||||
# 2. VOID legacy duplicate thickness_report certs that have a
|
||||
# paired CoC on the same job — the bundling rule says the
|
||||
# CoC carries the thickness data on page 2
|
||||
# Both passes are idempotent — safe to re-run.
|
||||
@api.model
|
||||
def action_backfill_missing_certs(self):
|
||||
Cert = self.env.get('fp.certificate')
|
||||
if Cert is None:
|
||||
raise UserError(_(
|
||||
'fp.certificate model is not installed. Install '
|
||||
'fusion_plating_certificates before running this action.'
|
||||
))
|
||||
candidate_jobs = self.search([('state', '=', 'done')])
|
||||
scanned = 0
|
||||
backfilled_jobs = self.env['fp.job']
|
||||
created_count = 0
|
||||
voided_count = 0
|
||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||
for job in candidate_jobs:
|
||||
required = job._resolve_required_cert_types()
|
||||
if not required:
|
||||
continue
|
||||
scanned += 1
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else
|
||||
(Cert.sudo().search([
|
||||
('sale_order_id', '=', job.sale_order_id.id),
|
||||
]) if job.sale_order_id else Cert.browse())
|
||||
)
|
||||
existing_types = set(existing_certs.mapped('certificate_type'))
|
||||
|
||||
# ---- Pass 1: create missing certs --------------------------
|
||||
missing = required - existing_types
|
||||
if missing:
|
||||
before = len(existing_certs)
|
||||
job._fp_create_certificates()
|
||||
# Re-read to get the freshly-created ones for pass 2.
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else existing_certs
|
||||
)
|
||||
delta = max(len(existing_certs) - before, 0)
|
||||
if delta:
|
||||
backfilled_jobs |= job
|
||||
created_count += delta
|
||||
|
||||
# ---- Pass 2: void duplicate thickness_report certs ---------
|
||||
# Bundling rule (CLAUDE.md): when CoC + thickness are both
|
||||
# wanted, the CoC absorbs the thickness data. A leftover
|
||||
# draft thickness_report cert on the same job is now noise
|
||||
# and should not be issued. Void it with a clear reason so
|
||||
# the audit trail tells the story.
|
||||
if 'coc' in required and 'coc' in existing_types:
|
||||
dup_thickness = existing_certs.filtered(
|
||||
lambda c: (c.certificate_type == 'thickness_report'
|
||||
and c.state == 'draft')
|
||||
)
|
||||
for cert in dup_thickness:
|
||||
cert.sudo().write({
|
||||
'state': 'voided',
|
||||
'void_reason': (
|
||||
'Auto-voided: bundling rule — thickness '
|
||||
'data is delivered as page 2 of the paired '
|
||||
'CoC, not as a separate cert.'
|
||||
),
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Auto-voided by cleanup: bundling rule routes '
|
||||
'thickness data to the CoC.'
|
||||
))
|
||||
voided_count += 1
|
||||
backfilled_jobs |= job
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cert backfill + cleanup complete'),
|
||||
'message': _(
|
||||
'Scanned %(s)d closed jobs. Created %(c)d draft '
|
||||
'cert(s); voided %(v)d duplicate thickness_report '
|
||||
'cert(s) across %(j)d job(s).'
|
||||
) % {
|
||||
's': scanned,
|
||||
'c': created_count,
|
||||
'v': voided_count,
|
||||
'j': len(backfilled_jobs),
|
||||
},
|
||||
'sticky': True,
|
||||
'type': 'success' if (created_count or voided_count) else 'warning',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
"""Phase 7 — adds the migration idempotency key on fp.job.step.
|
||||
|
||||
|
||||
@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
|
||||
'state': state_label,
|
||||
})
|
||||
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review (paperwork — doesn't
|
||||
need parts on the floor). Fires from both button_start and
|
||||
button_finish so an operator can't begin OR complete physical
|
||||
work before the receiving record is closed.
|
||||
|
||||
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
|
||||
pattern as the qty / QC / bake gates. Audit trail is preserved
|
||||
via the state-transition tracking on chatter.
|
||||
|
||||
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
|
||||
that's the terminal state (inspection moved into the recipe's
|
||||
racking step; ``'inspected'`` was dropped in the 2026-05-18
|
||||
cleanup).
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
# Internal rework / no SO — gate doesn't apply.
|
||||
continue
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
# Defensive: configurator module not installed.
|
||||
continue
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(
|
||||
so.x_fc_receiving_status,
|
||||
so.x_fc_receiving_status or 'unknown',
|
||||
)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received '
|
||||
'yet (SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > '
|
||||
'Receiving) before starting or finishing work on '
|
||||
'this step. A manager can bypass this gate for '
|
||||
'documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
"""Single source of truth for step start:
|
||||
1. Sub 13 predecessor gate (raise UserError if blocking)
|
||||
2. Policy B Contract Review auto-open (route to QA-005)
|
||||
3. Sub 8 Racking auto-open (route to racking inspection)
|
||||
4. super().button_start() + receiving soft check + serial
|
||||
promotion for the standard path
|
||||
2. Receiving gate (raise UserError if parts not received)
|
||||
3. Policy B Contract Review auto-open (route to QA-005)
|
||||
4. Sub 8 Racking auto-open (route to racking inspection)
|
||||
5. super().button_start() + serial promotion for the standard
|
||||
path
|
||||
|
||||
Manager bypasses available via context:
|
||||
fp_skip_predecessor_check=True skips the Sub 13 gate
|
||||
fp_skip_receiving_gate=True skips the receiving gate
|
||||
"""
|
||||
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
|
||||
),
|
||||
))
|
||||
|
||||
# ---- 2. Policy B Contract Review auto-open -----------------------
|
||||
# ---- 2. Receiving gate -------------------------------------------
|
||||
# Hard block (replaces the prior soft chatter warning). The
|
||||
# helper exempts Contract Review steps internally, so contract
|
||||
# review can still auto-open below regardless of receiving state.
|
||||
self._fp_check_receiving_gate()
|
||||
|
||||
# ---- 3. Policy B Contract Review auto-open -----------------------
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
action = step._fp_open_contract_review()
|
||||
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 3. Sub 8 Racking auto-open ----------------------------------
|
||||
# ---- 4. Sub 8 Racking auto-open ----------------------------------
|
||||
for step in self:
|
||||
if step._fp_is_racking_step():
|
||||
action = step._fp_open_racking_inspection()
|
||||
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 4. Standard path: start + receiving check + serial promote --
|
||||
# ---- 5. Standard path: start + serial promote --------------------
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
recv = so.x_fc_receiving_status if (
|
||||
'x_fc_receiving_status' in so._fields
|
||||
) else None
|
||||
if recv in (False, None, 'not_received'):
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(step)s" started before parts were received '
|
||||
'(SO %(so)s — receiving status: %(status)s). '
|
||||
'Confirm the parts are physically on the floor before '
|
||||
'continuing.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '',
|
||||
'status': recv or 'unknown',
|
||||
})
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
|
||||
@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status in ('partial', 'received'):
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state (no
|
||||
# more separate 'inspected'). Parts are on the floor;
|
||||
# inspection happens inside the recipe's racking step.
|
||||
if not so.x_fc_assigned_manager_id and not jobs:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving accepted; flip SO receiving status to inspected."""
|
||||
"""Mark receiving complete; flip SO receiving status to received.
|
||||
|
||||
Sub 8 (2026-04-22) moved inspection out of receiving and into the
|
||||
recipe's racking step. Receiving's terminal state is now 'closed'
|
||||
(or legacy 'accepted'), which maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
# Push receiving to its terminal state — 'closed' is the
|
||||
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
|
||||
# only for old records still in pre-Sub-8 states.
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state in ('inspecting',):
|
||||
rec.state = 'accepted'
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -20,3 +20,9 @@ access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_sup,fp.cert.issue.wiz.l.supervisor,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_mgr,fp.cert.issue.wiz.l.manager,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_sup,fp.cert.issue.wiz.r.supervisor,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_mgr,fp.cert.issue.wiz.r.manager,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -589,3 +589,367 @@ class TestQtyGate(TransactionCase):
|
||||
with self.assertRaises(UserError) as exc:
|
||||
wiz.action_commit()
|
||||
self.assertIn('at least 1', str(exc.exception))
|
||||
|
||||
|
||||
class TestCertCreationAndGates(TransactionCase):
|
||||
"""2026-05-18 — cert creation bug fix + gate hardening.
|
||||
|
||||
Covers the fixes for the WO-30040 incident where
|
||||
_fp_create_certificates raised NameError on `coating` and the cert
|
||||
was never created. Also covers the new qty_received gate on
|
||||
button_mark_done and the auto-fill of certified_by_id /
|
||||
contact_partner_id / nc_quantity / process_description.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Quality Manager',
|
||||
'login': 'qa_mgr_certtest',
|
||||
'email': 'qa@example.com',
|
||||
})
|
||||
cls.contact = cls.env['res.partner'].create({
|
||||
'name': 'Bob Receiver',
|
||||
'email': 'bob@cust.example',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'CertWidget',
|
||||
})
|
||||
cls.part = cls.env['fp.part.catalog'].create({
|
||||
'name': 'CertPart',
|
||||
'part_number': 'CP-001',
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_requirement': 'coc',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part.id,
|
||||
'qty': 1.0,
|
||||
'qty_done': 1.0,
|
||||
'qty_received': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ---------------- bug fix regression -------------------------------
|
||||
|
||||
def test_create_cert_handles_job_with_no_recipe(self):
|
||||
"""Regression for the `coating` NameError: cert must create
|
||||
even when the job has no recipe and no coating config."""
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.recipe_id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertFalse(certs.process_description)
|
||||
|
||||
# ---------------- prefill -----------------------------------------
|
||||
|
||||
def test_create_cert_prefills_signer_from_company(self):
|
||||
self.env.company.x_fc_owner_user_id = self.signer.id
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.certified_by_id, self.signer)
|
||||
|
||||
def test_create_cert_prefills_contact_from_partner(self):
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
|
||||
qty_visual_inspection_rejects=0,
|
||||
)
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.nc_quantity, 1)
|
||||
|
||||
# ---------------- mark_done qty_received gate ----------------------
|
||||
|
||||
def test_mark_done_blocks_on_blank_qty_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('Quantity Received', str(exc.exception))
|
||||
|
||||
def test_mark_done_blocks_on_qty_received_mismatch(self):
|
||||
from odoo.exceptions import UserError
|
||||
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
|
||||
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
|
||||
qty_received=5, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
|
||||
# rebalance so it passes the first check and fails the new one:
|
||||
job.qty = 4
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('qty mismatch', str(exc.exception).lower())
|
||||
|
||||
def test_mark_done_passes_with_clean_reconcile(self):
|
||||
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
|
||||
qty_received=4, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
def test_mark_done_bypass_skips_qty_received_check(self):
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(
|
||||
fp_skip_qty_reconcile=True,
|
||||
fp_skip_qc_gate=True,
|
||||
).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
# ---------------- backfill action ---------------------------------
|
||||
|
||||
def test_backfill_creates_missing_certs(self):
|
||||
"""A closed job with no cert gets one when the backfill runs."""
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
# Sanity: no cert exists
|
||||
self.assertFalse(self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]))
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
self.assertEqual(self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]), 1)
|
||||
|
||||
def test_backfill_idempotent(self):
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
before = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
after = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
class TestReceivingGate(TransactionCase):
|
||||
"""2026-05-18 — Hard gate on button_start / button_finish blocking
|
||||
step transitions until SO receiving status = 'received'. Contract
|
||||
Review steps are exempt; manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`. See
|
||||
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_so(self, recv_status='not_received'):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
if 'x_fc_receiving_status' in so._fields:
|
||||
so.x_fc_receiving_status = recv_status
|
||||
return so
|
||||
|
||||
def _make_job_with_step(self, recv_status='not_received',
|
||||
step_state='ready', is_cr=False):
|
||||
"""Build a job tied to an SO with the given receiving status,
|
||||
plus a single step in the given state. Returns (job, step)."""
|
||||
so = self._make_so(recv_status=recv_status)
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
step_vals = {
|
||||
'job_id': job.id,
|
||||
'name': 'Plate',
|
||||
'state': step_state,
|
||||
}
|
||||
# If a step_kind model is available, set CR vs not via kind.
|
||||
StepKind = self.env.get('fp.step.kind')
|
||||
if StepKind is not None and is_cr:
|
||||
cr_kind = StepKind.search(
|
||||
[('code', '=', 'contract_review')], limit=1,
|
||||
)
|
||||
if cr_kind:
|
||||
step_vals['step_kind_id'] = cr_kind.id
|
||||
step = self.env['fp.job.step'].create(step_vals)
|
||||
return job, step
|
||||
|
||||
# ---- button_start gate ------------------------------------------------
|
||||
|
||||
def test_start_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_start()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_start_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(recv_status='received')
|
||||
# Should not raise; step transitions to in_progress via super().
|
||||
step.button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
def test_start_skips_contract_review(self):
|
||||
# CR step exempt regardless of receiving status.
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', is_cr=True,
|
||||
)
|
||||
# button_start may return an action (CR auto-open) — must not raise.
|
||||
try:
|
||||
step.button_start()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
# Other failures (e.g. CR auto-open quirks in test env) are
|
||||
# not the gate — accept them.
|
||||
|
||||
def test_start_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
step.with_context(fp_skip_receiving_gate=True).button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
# ---- button_finish gate -----------------------------------------------
|
||||
|
||||
def test_finish_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_finish()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_finish_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='received', step_state='in_progress',
|
||||
)
|
||||
step.button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
def test_finish_skips_contract_review(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
is_cr=True,
|
||||
)
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
|
||||
def test_finish_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
step.with_context(fp_skip_receiving_gate=True).button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
|
||||
class TestCreateDeliveryShippingMirror(TransactionCase):
|
||||
"""Phase A — _fp_create_delivery mirrors shipping fields from the
|
||||
linked receiving onto the auto-created fp.delivery."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
|
||||
def _make_so_with_receiving(self, carrier=None, shipment=None):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'x_fc_carrier_id': carrier.id if carrier else False,
|
||||
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
|
||||
})
|
||||
return so, recv
|
||||
|
||||
def _make_job(self, so):
|
||||
return self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
|
||||
def test_create_delivery_mirrors_carrier_from_receiving(self):
|
||||
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
|
||||
|
||||
def test_create_delivery_mirrors_outbound_shipment(self):
|
||||
shipment = self.env['fusion.shipment'].create({
|
||||
'sale_order_id': False,
|
||||
'carrier_id': self.carrier_ups.id,
|
||||
'status': 'draft',
|
||||
})
|
||||
so, recv = self._make_so_with_receiving(
|
||||
carrier=self.carrier_ups, shipment=shipment,
|
||||
)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertEqual(
|
||||
job.delivery_id.x_fc_outbound_shipment_id, shipment,
|
||||
)
|
||||
|
||||
def test_create_delivery_no_receiving_no_mirror(self):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_carrier_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)
|
||||
|
||||
@@ -64,14 +64,15 @@
|
||||
as page 2 — open the Certificate PDF tab to verify.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
style="margin-top:0;">
|
||||
<i class="fa fa-exclamation-triangle" title="Warning"
|
||||
aria-label="Warning"/>
|
||||
<strong> No Fischerscope PDF on the linked QC.</strong>
|
||||
If this customer expects an XRF report with the CoC,
|
||||
have the operator upload the Fischerscope PDF on the
|
||||
QC check before issuing.
|
||||
<strong> No Fischerscope PDF available.</strong>
|
||||
Drop the PDF into the <em>Thickness Report
|
||||
(Fischerscope)</em> tab below, or upload it on the
|
||||
linked QC check, before issuing. Thickness Report
|
||||
certs cannot issue without thickness data.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -80,8 +81,7 @@
|
||||
<!-- Fischerscope file before merging into the cert. -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf"
|
||||
invisible="not x_fc_job_id">
|
||||
name="thickness_pdf">
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
@@ -94,25 +94,23 @@
|
||||
widget="many2one_binary"
|
||||
invisible="not x_fc_thickness_pdf_id"/>
|
||||
</group>
|
||||
<separator string="Upload Fischerscope PDF here"/>
|
||||
<group>
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness PDF has been
|
||||
uploaded on the linked QC yet. The CoC will
|
||||
be issued without an appended thickness
|
||||
report. To attach one:
|
||||
uploaded yet. The CoC will be issued without
|
||||
an appended thickness report. Either drop the
|
||||
PDF into the upload field above, OR upload it
|
||||
on the linked QC check and re-open this cert.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Open the linked Plating Job (smart
|
||||
button above)</li>
|
||||
<li>Click into the auto-spawned Quality
|
||||
Check</li>
|
||||
<li>Go to the <em>Thickness Report</em> tab
|
||||
and upload the PDF from the Fischerscope
|
||||
/ XDAL 600 export</li>
|
||||
<li>Pass the QC, then come back here and
|
||||
click Issue</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
@@ -120,8 +118,8 @@
|
||||
<i class="fa fa-arrow-up" title="Action"
|
||||
aria-label="Action"/>
|
||||
Click <strong>Issue</strong> in the header
|
||||
and the Fischerscope PDF above will be
|
||||
merged into page 2 of the CoC.
|
||||
and the Fischerscope PDF will be merged into
|
||||
page 2 of the CoC.
|
||||
</p>
|
||||
</div>
|
||||
</page>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
One-shot backfill for closed jobs that never produced a CoC because
|
||||
of the `coating` NameError regression (fixed 2026-05-18). Surfaced
|
||||
as a Settings > Technical menu item so the user can click once after
|
||||
deploying the fix.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_fp_job_backfill_missing_certs" model="ir.actions.server">
|
||||
<field name="name">Generate Missing Certs for Closed Jobs</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = env['fp.job'].action_backfill_missing_certs()</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
from . import fp_cert_issue_wizard
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issue Certs Wizard.
|
||||
|
||||
Opened from a job's "Issue Certs" milestone button. Walks each draft
|
||||
cert on the job, lets the manager upload the Fischerscope/XDAL output
|
||||
(PDF or .docx) per cert that needs thickness data, and tries to parse
|
||||
the .docx to pre-populate the readings table. Manager can edit/add
|
||||
readings before confirming. On confirm:
|
||||
|
||||
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
|
||||
of the issued CoC).
|
||||
- .docx uploads are attached as ir.attachment on the cert (evidence)
|
||||
and the parsed readings are written as fp.thickness.reading rows.
|
||||
- cert.action_issue() is called for each cert.
|
||||
|
||||
The wizard is a convenience layer — it does NOT replace the per-cert
|
||||
Issue button on the cert form, which stays as the fallback path.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Fischerscope XDAL 600 reading line, e.g.
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
_FISCHER_READING_RE = re.compile(
|
||||
r'n\s*=\s*(\d+)'
|
||||
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
|
||||
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
|
||||
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
|
||||
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_docx(raw_bytes):
|
||||
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
|
||||
|
||||
Returns dict:
|
||||
{
|
||||
'readings': [(nip_mils, ni_pct, p_pct), ...],
|
||||
'calibration': str or '',
|
||||
'operator': str or '',
|
||||
'date_str': str or '',
|
||||
'time_str': str or '',
|
||||
'raw_text': str (the extracted document body, for chatter),
|
||||
}
|
||||
|
||||
Soft-fails to an empty dict-like result when python-docx isn't
|
||||
installed or the bytes don't parse — the wizard still works, the
|
||||
operator just has to type readings manually.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '', 'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
try:
|
||||
import docx # python-docx
|
||||
except ImportError:
|
||||
_logger.info(
|
||||
'python-docx not installed — Fischerscope auto-parse '
|
||||
'skipped. Operator will enter readings manually.'
|
||||
)
|
||||
return empty
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(raw_bytes))
|
||||
except Exception as e:
|
||||
_logger.warning('Fischerscope .docx parse failed: %s', e)
|
||||
return empty
|
||||
# Pull text from paragraphs AND tables (Fischerscope reports
|
||||
# sometimes lay the readings inside a table cell).
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for tbl in doc.tables:
|
||||
for row in tbl.rows:
|
||||
for cell in row.cells:
|
||||
parts.append(cell.text)
|
||||
text = '\n'.join(parts)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)), # nip mils
|
||||
float(m.group(3)), # Ni %
|
||||
float(m.group(4)), # P %
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
calib = ''
|
||||
m = _FISCHER_CALIB_RE.search(text)
|
||||
if m:
|
||||
calib = m.group(1).strip()
|
||||
operator = ''
|
||||
m = _FISCHER_OPERATOR_RE.search(text)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
date_str = ''
|
||||
m = _FISCHER_DATE_RE.search(text)
|
||||
if m:
|
||||
date_str = m.group(1).strip()
|
||||
time_str = ''
|
||||
m = _FISCHER_TIME_RE.search(text)
|
||||
if m:
|
||||
time_str = m.group(1).strip()
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': calib,
|
||||
'operator': operator,
|
||||
'date_str': date_str,
|
||||
'time_str': time_str,
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizard(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job', string='Job', required=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
|
||||
)
|
||||
has_blocking_lines = fields.Boolean(
|
||||
compute='_compute_has_blocking_lines',
|
||||
help='True when at least one line is missing data the gate '
|
||||
'requires (no readings, no file, etc.). Used to disable '
|
||||
'the Confirm button.',
|
||||
)
|
||||
|
||||
@api.depends('line_ids', 'line_ids.is_ready')
|
||||
def _compute_has_blocking_lines(self):
|
||||
for w in self:
|
||||
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
|
||||
|
||||
@api.model
|
||||
def open_for_job(self, job):
|
||||
"""Factory — create a wizard pre-populated with one line per
|
||||
draft cert on the job. Returns an action dict that opens the
|
||||
wizard form."""
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
certs = Cert.search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if not certs:
|
||||
raise UserError(_(
|
||||
'No draft certificates on %s to issue.'
|
||||
) % job.name)
|
||||
wiz = self.create({
|
||||
'job_id': job.id,
|
||||
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Issue Certs — %s') % job.name,
|
||||
'res_model': self._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply every line's file + readings, then issue each cert.
|
||||
|
||||
Order matters: write the file/readings BEFORE calling action_issue
|
||||
so the gate sees the populated data. If a single cert raises on
|
||||
issue, the whole wizard rolls back (transactional).
|
||||
"""
|
||||
self.ensure_one()
|
||||
issued = []
|
||||
for ln in self.line_ids:
|
||||
ln._apply_to_cert()
|
||||
cert = ln.cert_id
|
||||
if cert.state == 'draft':
|
||||
cert.action_issue()
|
||||
issued.append(cert.name)
|
||||
if not issued:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Certs Issued'),
|
||||
'message': _('%d cert(s) issued: %s') % (
|
||||
len(issued), ', '.join(issued),
|
||||
),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizardLine(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.line'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
cert_type = fields.Selection(
|
||||
related='cert_id.certificate_type', readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
needs_thickness = fields.Boolean(
|
||||
compute='_compute_needs_thickness', store=False,
|
||||
)
|
||||
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||
fischer_filename = fields.Char(string='Filename')
|
||||
parsed_summary = fields.Text(
|
||||
string='Parsed Summary', readonly=True,
|
||||
help='Output of the .docx parser. Populated when you attach a '
|
||||
'Fischerscope .docx; the readings table below is auto-'
|
||||
'filled from the same parse. Empty for PDF uploads.',
|
||||
)
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
|
||||
)
|
||||
is_ready = fields.Boolean(
|
||||
compute='_compute_is_ready',
|
||||
help='True when this cert has enough data to issue: thickness '
|
||||
'data present if needed.',
|
||||
)
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
def _compute_needs_thickness(self):
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
'cert_id.thickness_reading_ids',
|
||||
'cert_id.x_fc_local_thickness_pdf')
|
||||
def _compute_is_ready(self):
|
||||
for ln in self:
|
||||
if not ln.needs_thickness:
|
||||
ln.is_ready = True
|
||||
continue
|
||||
ln.is_ready = bool(
|
||||
ln.fischer_file
|
||||
or ln.reading_line_ids
|
||||
or ln.cert_id.thickness_reading_ids
|
||||
or ln.cert_id.x_fc_local_thickness_pdf
|
||||
)
|
||||
|
||||
@api.onchange('fischer_file', 'fischer_filename')
|
||||
def _onchange_fischer_file(self):
|
||||
"""Try to parse .docx on upload; prefill the readings + summary."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
if not name.endswith('.docx'):
|
||||
self.parsed_summary = _(
|
||||
'Non-.docx upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
readings = parsed.get('readings') or []
|
||||
if readings:
|
||||
self.reading_line_ids = [(5, 0, 0)] + [
|
||||
(0, 0, {
|
||||
'sequence': i + 1,
|
||||
'nip_mils': nip,
|
||||
'ni_percent': ni,
|
||||
'p_percent': p,
|
||||
})
|
||||
for i, (nip, ni, p) in enumerate(readings)
|
||||
]
|
||||
self.parsed_summary = _(
|
||||
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
|
||||
'Operator: %(o)s · Date: %(d)s %(t)s'
|
||||
) % {
|
||||
'n': len(readings),
|
||||
'c': parsed.get('calibration') or '—',
|
||||
'o': parsed.get('operator') or '—',
|
||||
'd': parsed.get('date_str') or '—',
|
||||
't': parsed.get('time_str') or '',
|
||||
}
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert."""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
if name.endswith('.pdf'):
|
||||
# Drop the PDF into the cert-local field — merges into page 2.
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.fischer_file,
|
||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||
})
|
||||
else:
|
||||
# .doc / .docx / anything else — attach as evidence.
|
||||
self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert()
|
||||
|
||||
def _push_readings_to_cert(self):
|
||||
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||
Skips when no rows. Does not deduplicate against existing
|
||||
readings — the manager has just told us this is the new data."""
|
||||
self.ensure_one()
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if Reading is None or not self.reading_line_ids:
|
||||
return
|
||||
for r in self.reading_line_ids:
|
||||
vals = {
|
||||
'certificate_id': self.cert_id.id,
|
||||
'nip_mils': r.nip_mils,
|
||||
'ni_percent': r.ni_percent,
|
||||
'p_percent': r.p_percent,
|
||||
}
|
||||
if 'reading_number' in Reading._fields:
|
||||
vals['reading_number'] = r.sequence
|
||||
Reading.sudo().create(vals)
|
||||
|
||||
|
||||
class FpCertIssueWizardReading(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.reading'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
|
||||
_order = 'sequence, id'
|
||||
|
||||
line_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=1)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
p_percent = fields.Float(string='P %', digits=(6, 3))
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.issue.wizard.form</field>
|
||||
<field name="model">fp.cert.issue.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Issue Certs">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Issue Certs —
|
||||
<field name="job_id" readonly="1" nolabel="1"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not has_blocking_lines">
|
||||
<i class="fa fa-info-circle"/>
|
||||
At least one cert still needs thickness data
|
||||
(Fischerscope file or readings). Fill it in
|
||||
below before confirming.
|
||||
</div>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="false" delete="false">
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness" readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="is_ready" widget="boolean_toggle"
|
||||
readonly="1"
|
||||
decoration-success="is_ready"
|
||||
decoration-danger="not is_ready"/>
|
||||
</list>
|
||||
<form>
|
||||
<header>
|
||||
<field name="is_ready" widget="statusbar"
|
||||
statusbar_visible="True,False"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness"
|
||||
readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Fischerscope File"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"/>
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="not needs_thickness or not parsed_summary">
|
||||
<field name="parsed_summary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<separator string="Thickness Readings"
|
||||
invisible="not needs_thickness"/>
|
||||
<field name="reading_line_ids"
|
||||
invisible="not needs_thickness">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-primary"
|
||||
invisible="has_blocking_lines"/>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-secondary"
|
||||
invisible="not has_blocking_lines"
|
||||
disabled="1"
|
||||
help="One or more certs still need thickness data."/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.3.8.0',
|
||||
'version': '19.0.3.9.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
@@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
|
||||
'fusion_shipping',
|
||||
'hr',
|
||||
'mail',
|
||||
],
|
||||
|
||||
@@ -123,6 +123,86 @@ class FpDelivery(models.Model):
|
||||
'ir.attachment',
|
||||
string='Packing List',
|
||||
)
|
||||
|
||||
# ---- Phase A — outbound carrier + shipment link ----------------------
|
||||
# Mirrors the fields on fp.receiving. Populated by
|
||||
# fp.job._fp_create_delivery from the linked receiving when this
|
||||
# delivery is auto-created on job-done; shipping crew can override
|
||||
# at ship time.
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Carrier picked at receiving time; can be overridden by '
|
||||
'the shipping crew before issuing the label.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
help='The shipment record carrying weight, dimensions, label '
|
||||
'PDF, and tracking. Usually the same shipment that was '
|
||||
'created at receiving time.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id')
|
||||
def _compute_x_fc_outbound_shipment_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_outbound_shipment_count = (
|
||||
1 if rec.x_fc_outbound_shipment_id else 0
|
||||
)
|
||||
|
||||
@api.onchange('x_fc_carrier_id')
|
||||
def _onchange_x_fc_carrier_id(self):
|
||||
for rec in self:
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
|
||||
ship.carrier_id = rec.x_fc_carrier_id.id
|
||||
|
||||
def action_create_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if self.x_fc_outbound_shipment_id:
|
||||
return self.action_view_outbound_shipment()
|
||||
if 'fusion.shipment' not in self.env:
|
||||
raise UserError(_(
|
||||
'fusion_shipping module is not installed. '
|
||||
'Cannot create an outbound shipment.'
|
||||
))
|
||||
SO = self.env['sale.order'].sudo()
|
||||
so = False
|
||||
if self.job_ref:
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is not None:
|
||||
job = Job.sudo().search(
|
||||
[('name', '=', self.job_ref)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
vals = {
|
||||
'sale_order_id': so.id if so else False,
|
||||
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
|
||||
'status': 'draft',
|
||||
}
|
||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||
self.x_fc_outbound_shipment_id = shipment.id
|
||||
self.message_post(body=_(
|
||||
'Outbound shipment <b>%s</b> created (draft).'
|
||||
) % shipment.name)
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def action_view_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_outbound_shipment_id.name,
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': self.x_fc_outbound_shipment_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_delivery_shipping_fields
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase A — mirror carrier + outbound shipment fields on fp.delivery."""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestDeliveryShippingFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
|
||||
|
||||
def test_carrier_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_carrier_id', delivery._fields)
|
||||
|
||||
def test_outbound_shipment_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_outbound_shipment_id', delivery._fields)
|
||||
@@ -59,6 +59,16 @@
|
||||
statusbar_visible="draft,scheduled,en_route,delivered"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_create_outbound_shipment"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-truck">
|
||||
<field name="x_fc_outbound_shipment_count"
|
||||
widget="statinfo"
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
@@ -84,7 +94,9 @@
|
||||
<field name="vehicle_id"/>
|
||||
<field name="tdg_required" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group string="Documents">
|
||||
<group string="Outbound Shipping">
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="coc_attachment_id"/>
|
||||
<field name="packing_list_attachment_id"/>
|
||||
<field name="pod_id" readonly="1"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Notifications',
|
||||
'version': '19.0.6.4.0',
|
||||
'version': '19.0.6.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -22,6 +22,7 @@
|
||||
'fusion_plating_invoicing',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_reports',
|
||||
'fusion_shipping',
|
||||
'sale_management',
|
||||
'account',
|
||||
'mail',
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipment_labeled" model="fp.notification.template">
|
||||
<field name="name">Shipping Label Generated</field>
|
||||
<field name="trigger_event">shipment_labeled</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_shipment_labeled"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipped" model="fp.notification.template">
|
||||
<field name="name">Shipped / Delivered</field>
|
||||
<field name="trigger_event">shipped</field>
|
||||
|
||||
@@ -184,6 +184,70 @@
|
||||
fp.notification.template's `job_complete` trigger, defined
|
||||
in fp_notification_template_data.xml. -->
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4b. Shipping Label Generated (Info, #2B6CB0) -->
|
||||
<!-- Fires when fusion.shipment.tracking_number first lands. -->
|
||||
<!-- Customer gets the tracking link BEFORE the package goes -->
|
||||
<!-- out the door, so they can monitor from pickup. -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_shipment_labeled" model="mail.template">
|
||||
<field name="name">FP: Shipping Label Generated</field>
|
||||
<field name="model_id" ref="fusion_shipping.model_fusion_shipment"/>
|
||||
<field name="subject">Tracking #{{ object.tracking_number }} — your order is being prepared for shipment</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ (object.sale_order_id and object.sale_order_id.partner_id.email) or '' }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
Hi <t t-out="object.sale_order_id.partner_id.name or ''"/>, the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Shipment</th>
|
||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Sale Order</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.sale_order_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||
<td style="padding: 8px 4px;">Carrier</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.carrier_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Tracking Number</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace; font-weight: bold;"><t t-out="object.tracking_number or '—'"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div t-if="object.x_fc_tracking_url" style="margin: 24px 0; text-align: center;">
|
||||
<a t-att-href="object.x_fc_tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display: inline-block; padding: 12px 28px; background-color: #2B6CB0; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Track Shipment
|
||||
</a>
|
||||
</div>
|
||||
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
|
||||
<strong>What's next:</strong> Once the carrier collects the package, you'll receive a Shipped confirmation with the Certificate of Conformance attached.
|
||||
</div>
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -15,3 +15,4 @@ from . import account_payment
|
||||
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
||||
# from . import mrp_production
|
||||
from . import fp_delivery
|
||||
from . import fusion_shipment
|
||||
|
||||
@@ -16,6 +16,7 @@ TRIGGER_EVENTS = [
|
||||
('mo_complete', 'Manufacturing Complete'), # legacy, fired by mrp; kept for back-compat
|
||||
('job_confirmed', 'Plating Job Confirmed'), # Sub 11 — fp.job lifecycle
|
||||
('job_complete', 'Plating Job Complete'), # Sub 11 — fp.job.button_mark_done
|
||||
('shipment_labeled', 'Shipping Label Generated'), # Phase C — fired when tracking_number lands on fusion.shipment
|
||||
('shipped', 'Shipped / Delivered'),
|
||||
('invoice_posted', 'Invoice Posted'),
|
||||
('payment_received', 'Payment Received'),
|
||||
@@ -36,6 +37,7 @@ FP_TRIGGER_STREAM = {
|
||||
'mo_complete': 'qc',
|
||||
'job_confirmed': 'qc',
|
||||
'job_complete': 'qc',
|
||||
'shipment_labeled': 'certs',
|
||||
'shipped': 'certs',
|
||||
'invoice_posted': 'invoices',
|
||||
'payment_received': 'invoices',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase C — fire 'shipment_labeled' notification when tracking_number
|
||||
lands on a fusion.shipment for the first time.
|
||||
|
||||
Triggers regardless of how tracking got set: live API call or manual
|
||||
fallback wizard. Customer gets the tracking link as soon as the label
|
||||
is generated, not after the package physically ships (that's the
|
||||
existing 'shipped' event on fp.delivery)."""
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionShipment(models.Model):
|
||||
_inherit = 'fusion.shipment'
|
||||
|
||||
def write(self, vals):
|
||||
# Identify shipments that gain a tracking number for the first
|
||||
# time. Done BEFORE super().write so we can compare before/after.
|
||||
will_fire = self.browse()
|
||||
if 'tracking_number' in vals and vals.get('tracking_number'):
|
||||
will_fire = self.filtered(lambda s: not s.tracking_number)
|
||||
res = super().write(vals)
|
||||
if not will_fire:
|
||||
return res
|
||||
Dispatch = self.env.get('fp.notification.template')
|
||||
if Dispatch is None:
|
||||
return res
|
||||
for ship in will_fire:
|
||||
partner = (
|
||||
ship.sale_order_id.partner_id
|
||||
if ship.sale_order_id else False
|
||||
)
|
||||
if not partner:
|
||||
continue
|
||||
try:
|
||||
Dispatch._dispatch(
|
||||
'shipment_labeled',
|
||||
ship,
|
||||
partner,
|
||||
sale_order=ship.sale_order_id or False,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Shipment %s: shipment_labeled dispatch failed: %s',
|
||||
ship.name, e,
|
||||
)
|
||||
return res
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.3.5.0',
|
||||
'version': '19.0.4.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
@@ -55,10 +55,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/fp_portal_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_sequence_data.xml',
|
||||
'views/fp_portal_shell.xml',
|
||||
'views/fp_portal_macros.xml',
|
||||
'views/fp_quote_request_views.xml',
|
||||
'views/fp_portal_dashboard.xml',
|
||||
'views/fp_portal_templates.xml',
|
||||
'views/fp_portal_account_summary.xml', # NEW — Task 10
|
||||
'views/fp_portal_configurator_templates.xml',
|
||||
'views/fp_portal_breadcrumbs.xml',
|
||||
'views/fp_sale_order_portal.xml',
|
||||
@@ -76,9 +78,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
|
||||
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
|
||||
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
|
||||
'fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss', # NEW — Task 5
|
||||
# Catch-all legacy rules (last)
|
||||
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
|
||||
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
||||
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW — Task 5
|
||||
'fusion_plating_portal/static/src/js/fp_portal_account_summary.js', # NEW — Task 10 fix
|
||||
'fusion_plating_portal/static/src/js/fp_portal_list_search.js', # list search + sort
|
||||
],
|
||||
},
|
||||
'demo': [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,12 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
[('partner_id', 'child_of', partner.commercial_partner_id.id)],
|
||||
order='create_date desc', limit=10,
|
||||
)
|
||||
return request.render('fusion_plating_portal.portal_configurator_landing', {
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'fp_configurator',
|
||||
'quotes': quotes,
|
||||
})
|
||||
return request.render('fusion_plating_portal.portal_configurator_landing', values)
|
||||
|
||||
# ======================================================================
|
||||
# Step 1 — Upload part or enter manual measurements
|
||||
@@ -53,42 +55,58 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
'part_name': kw.get('part_name', ''),
|
||||
'part_number': kw.get('part_number', ''),
|
||||
'substrate_material': kw.get('substrate_material', 'steel'),
|
||||
'geometry_source': kw.get('geometry_source', 'manual'),
|
||||
'geometry_source': kw.get('geometry_source', 'upload'),
|
||||
# Manual measurements are HIDDEN in the template per customer
|
||||
# feedback 2026-05-17 — kept as hidden 0s so the rest of the
|
||||
# flow doesn't error on missing keys. Backend computes them
|
||||
# if/when needed.
|
||||
'surface_area': float(kw.get('surface_area', 0) or 0),
|
||||
'dimensions_length': float(kw.get('dimensions_length', 0) or 0),
|
||||
'dimensions_width': float(kw.get('dimensions_width', 0) or 0),
|
||||
'dimensions_height': float(kw.get('dimensions_height', 0) or 0),
|
||||
# Multi-upload: customer may submit drawing + 3D + others.
|
||||
# Collect IDs in a list so they all flow through to the RFQ.
|
||||
'attachment_ids': [],
|
||||
'attachment_names': [],
|
||||
}
|
||||
# Handle file upload
|
||||
file_upload = kw.get('part_file')
|
||||
if file_upload and hasattr(file_upload, 'read'):
|
||||
file_data = file_upload.read()
|
||||
if file_data:
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': file_upload.filename,
|
||||
'datas': base64.b64encode(file_data),
|
||||
'res_model': 'fusion.plating.quote.request',
|
||||
'type': 'binary',
|
||||
})
|
||||
session_data['attachment_id'] = attachment.id
|
||||
session_data['attachment_name'] = file_upload.filename
|
||||
fname = file_upload.filename.lower()
|
||||
if fname.endswith(('.stl', '.stp', '.step', '.iges', '.igs')):
|
||||
session_data['geometry_source'] = '3d_model'
|
||||
else:
|
||||
session_data['geometry_source'] = 'pdf_drawing'
|
||||
|
||||
# Try to calculate surface area for STL files
|
||||
if fname.endswith('.stl'):
|
||||
try:
|
||||
import io
|
||||
import trimesh
|
||||
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
|
||||
# Convert mm^2 to sq in (1 sq in = 645.16 mm^2)
|
||||
session_data['surface_area'] = round(mesh.area / 645.16, 4)
|
||||
session_data['auto_calculated'] = True
|
||||
except Exception:
|
||||
_logger.info('Could not auto-calculate STL surface area (trimesh not available).')
|
||||
def _save_upload(file_upload, label):
|
||||
"""Persist a single uploaded file and append to session lists.
|
||||
Returns the attachment record or None."""
|
||||
if not (file_upload and hasattr(file_upload, 'read')):
|
||||
return None
|
||||
file_data = file_upload.read()
|
||||
if not file_data:
|
||||
return None
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': file_upload.filename,
|
||||
'datas': base64.b64encode(file_data),
|
||||
'res_model': 'fusion.plating.quote.request',
|
||||
'type': 'binary',
|
||||
})
|
||||
session_data['attachment_ids'].append(attachment.id)
|
||||
session_data['attachment_names'].append(
|
||||
'%s (%s)' % (file_upload.filename, label),
|
||||
)
|
||||
# STL surface-area auto-calc (silent — backend value only,
|
||||
# not surfaced in UI per the hidden-measurements decision).
|
||||
fname = file_upload.filename.lower()
|
||||
if fname.endswith('.stl'):
|
||||
try:
|
||||
import io
|
||||
import trimesh
|
||||
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
|
||||
# mm^2 -> sq in (1 sq in = 645.16 mm^2)
|
||||
session_data['surface_area'] = round(mesh.area / 645.16, 4)
|
||||
session_data['auto_calculated'] = True
|
||||
except Exception:
|
||||
_logger.info('STL surface-area auto-calc skipped (trimesh missing or bad mesh).')
|
||||
return attachment
|
||||
|
||||
_save_upload(kw.get('part_drawing'), 'drawing')
|
||||
_save_upload(kw.get('part_3d_model'), '3D model')
|
||||
# Back-compat: legacy single-file input still accepted.
|
||||
_save_upload(kw.get('part_file'), 'attachment')
|
||||
|
||||
request.session['fp_configurator'] = session_data
|
||||
return request.redirect('/my/configurator/coating')
|
||||
@@ -102,10 +120,12 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
('titanium', 'Titanium'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
return request.render('fusion_plating_portal.portal_configurator_step1', {
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'fp_configurator',
|
||||
'materials': materials,
|
||||
})
|
||||
return request.render('fusion_plating_portal.portal_configurator_step1', values)
|
||||
|
||||
# ======================================================================
|
||||
# Step 2 — Select coating configuration
|
||||
@@ -128,14 +148,19 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
request.session['fp_configurator'] = session_data
|
||||
return request.redirect('/my/configurator/estimate')
|
||||
|
||||
coatings = request.env['fp.coating.config'].sudo().search(
|
||||
[('active', '=', True)], order='sequence',
|
||||
# fp.coating.config retired post-Sub-11. Use process.type as the
|
||||
# customer-facing coating picker — its records (Hard Chrome, EN
|
||||
# Low-Phos, etc.) are exactly what a customer would select from.
|
||||
coatings = request.env['fusion.plating.process.type'].sudo().search(
|
||||
[('active', '=', True)], order='sequence, name',
|
||||
)
|
||||
return request.render('fusion_plating_portal.portal_configurator_step2', {
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'fp_configurator',
|
||||
'coatings': coatings,
|
||||
'session_data': session_data,
|
||||
})
|
||||
return request.render('fusion_plating_portal.portal_configurator_step2', values)
|
||||
|
||||
# ======================================================================
|
||||
# Step 3 — Estimate & submit
|
||||
@@ -147,21 +172,29 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
if not session_data or not session_data.get('coating_config_id'):
|
||||
return request.redirect('/my/configurator/new')
|
||||
|
||||
coating = request.env['fp.coating.config'].sudo().browse(
|
||||
coating = request.env['fusion.plating.process.type'].sudo().browse(
|
||||
session_data['coating_config_id'],
|
||||
)
|
||||
if not coating.exists():
|
||||
return request.redirect('/my/configurator/coating')
|
||||
|
||||
# Calculate estimated price from pricing rules
|
||||
estimated_price = self._estimate_price(session_data, coating)
|
||||
# Estimate price from pricing rules. Best-effort: returns
|
||||
# {'available': False} when rules don't match or process.type
|
||||
# lacks the data the rules expect (post-coating-retire transition).
|
||||
try:
|
||||
estimated_price = self._estimate_price(session_data, coating)
|
||||
except Exception:
|
||||
_logger.info('Skipping price estimate — pricing helper unavailable.', exc_info=True)
|
||||
estimated_price = {'min': 0, 'max': 0, 'available': False}
|
||||
|
||||
return request.render('fusion_plating_portal.portal_configurator_step3', {
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'fp_configurator',
|
||||
'session_data': session_data,
|
||||
'coating': coating,
|
||||
'estimated_price': estimated_price,
|
||||
})
|
||||
return request.render('fusion_plating_portal.portal_configurator_step3', values)
|
||||
|
||||
# ======================================================================
|
||||
# Submit — create quote request
|
||||
@@ -177,7 +210,7 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
return request.redirect('/my/configurator/new')
|
||||
|
||||
partner = request.env.user.partner_id
|
||||
coating = request.env['fp.coating.config'].sudo().browse(
|
||||
coating = request.env['fusion.plating.process.type'].sudo().browse(
|
||||
session_data['coating_config_id'],
|
||||
)
|
||||
|
||||
@@ -213,69 +246,75 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
'special_instructions': kw.get('special_instructions', ''),
|
||||
}
|
||||
|
||||
# Link coating process type
|
||||
if coating.exists() and coating.process_type_id:
|
||||
vals['process_type_ids'] = [(4, coating.process_type_id.id)]
|
||||
# Link the selected process type (coating IS the process type now
|
||||
# that fp.coating.config is retired).
|
||||
if coating.exists():
|
||||
vals['process_type_ids'] = [(4, coating.id)]
|
||||
|
||||
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
|
||||
|
||||
# Attach uploaded file to the quote request
|
||||
attachment_id = session_data.get('attachment_id')
|
||||
if attachment_id:
|
||||
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
|
||||
# Re-key uploaded attachments onto the new quote request so they
|
||||
# appear on its chatter. Multi-upload — customer may have sent
|
||||
# both a drawing and a 3D model (or more).
|
||||
att_ids = session_data.get('attachment_ids') or []
|
||||
if not att_ids and session_data.get('attachment_id'):
|
||||
# Back-compat for old session shape (single attachment_id key).
|
||||
att_ids = [session_data['attachment_id']]
|
||||
for att_id in att_ids:
|
||||
attachment = request.env['ir.attachment'].sudo().browse(att_id)
|
||||
if attachment.exists():
|
||||
attachment.write({
|
||||
'res_model': 'fusion.plating.quote.request',
|
||||
'res_id': quote.id,
|
||||
})
|
||||
quote.drawing_attachment_ids = [(4, attachment.id)]
|
||||
if 'drawing_attachment_ids' in quote._fields:
|
||||
quote.drawing_attachment_ids = [(4, attachment.id)]
|
||||
|
||||
# Clear session
|
||||
request.session.pop('fp_configurator', None)
|
||||
|
||||
return request.render('fusion_plating_portal.portal_configurator_success', {
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'fp_configurator',
|
||||
'quote': quote,
|
||||
})
|
||||
return request.render('fusion_plating_portal.portal_configurator_success', values)
|
||||
|
||||
# ======================================================================
|
||||
# Pricing helper
|
||||
# ======================================================================
|
||||
def _estimate_price(self, session_data, coating):
|
||||
"""Calculate estimated price range from pricing rules.
|
||||
"""Best-effort price estimate. Returns {'min', 'max', 'available'}.
|
||||
|
||||
Returns a dict with ``min``, ``max``, and ``available`` keys.
|
||||
The range is deliberately wide (+/- 15-25%) because final quotes
|
||||
account for masking complexity, rack configuration, etc.
|
||||
Post-coating-retire (Sub-11) the rule schema still references
|
||||
fp.coating.config; ``coating`` here is now a process.type record.
|
||||
That means rules with a ``coating_config_id`` set won't match
|
||||
and we silently fall through to {'available': False} — which
|
||||
the template renders as 'Quote will be priced by EN Plating'.
|
||||
Estimate is non-essential; final price comes from EN's review.
|
||||
"""
|
||||
rules = request.env['fp.pricing.rule'].sudo().search(
|
||||
[('active', '=', True)], order='sequence',
|
||||
)
|
||||
Rule = request.env.get('fp.pricing.rule')
|
||||
if Rule is None:
|
||||
return {'min': 0, 'max': 0, 'available': False}
|
||||
rules = Rule.sudo().search([('active', '=', True)], order='sequence')
|
||||
area = float(session_data.get('surface_area', 0))
|
||||
qty = int(session_data.get('quantity', 1))
|
||||
substrate = session_data.get('substrate_material', '')
|
||||
cert_level = coating.certification_level if coating else 'commercial'
|
||||
|
||||
if not area or not rules:
|
||||
return {'min': 0, 'max': 0, 'available': False}
|
||||
|
||||
# Find best matching rule (same scoring as fp.quote.configurator)
|
||||
best = None
|
||||
best_score = -1
|
||||
for rule in rules:
|
||||
score = 0
|
||||
if rule.coating_config_id:
|
||||
if rule.coating_config_id.id != coating.id:
|
||||
continue
|
||||
score += 4
|
||||
if rule.substrate_material:
|
||||
# Skip any rule keyed to coating_config — model is gone.
|
||||
if 'coating_config_id' in rule._fields and rule.coating_config_id:
|
||||
continue
|
||||
if getattr(rule, 'substrate_material', None):
|
||||
if rule.substrate_material != substrate:
|
||||
continue
|
||||
score += 2
|
||||
if rule.certification_level:
|
||||
if rule.certification_level != cert_level:
|
||||
continue
|
||||
score += 1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = rule
|
||||
@@ -283,7 +322,6 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
if not best:
|
||||
return {'min': 0, 'max': 0, 'available': False}
|
||||
|
||||
# Calculate base price
|
||||
if best.pricing_method == 'per_sqin':
|
||||
unit = area * best.base_rate
|
||||
elif best.pricing_method == 'per_sqft':
|
||||
@@ -293,17 +331,10 @@ class FpPortalConfigurator(CustomerPortal):
|
||||
else:
|
||||
unit = best.base_rate
|
||||
|
||||
# Apply thickness factor (use min thickness from coating)
|
||||
thickness = coating.thickness_min or 1.0
|
||||
unit *= thickness * best.thickness_factor
|
||||
|
||||
base_total = unit * qty + best.setup_fee
|
||||
|
||||
# Apply minimum charge
|
||||
base_total = unit * qty + (best.setup_fee or 0)
|
||||
if best.minimum_charge and base_total < best.minimum_charge:
|
||||
base_total = best.minimum_charge
|
||||
|
||||
# Return a range (85% to 125%) to account for complexity, masking, etc.
|
||||
return {
|
||||
'min': round(base_total * 0.85, 2),
|
||||
'max': round(base_total * 1.25, 2),
|
||||
|
||||
@@ -102,6 +102,79 @@ class FpPortalJob(models.Model):
|
||||
tracking_ref = fields.Char(
|
||||
string='Tracking Reference',
|
||||
)
|
||||
x_fc_tracking_url = fields.Char(
|
||||
string='Tracking URL',
|
||||
compute='_compute_x_fc_tracking_url',
|
||||
help='Resolved carrier tracking URL with the tracking number '
|
||||
'substituted. Used by the portal template to render the '
|
||||
'tracking_ref as a clickable link. Walks portal job → '
|
||||
'fp.job → sale_order → fp.receiving → carrier.',
|
||||
)
|
||||
|
||||
@api.depends('tracking_ref')
|
||||
def _compute_x_fc_tracking_url(self):
|
||||
Job = self.env.get('fp.job')
|
||||
for rec in self:
|
||||
url = ''
|
||||
if rec.tracking_ref and Job is not None:
|
||||
job = Job.sudo().search(
|
||||
[('portal_job_id', '=', rec.id)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
recv = (
|
||||
so.x_fc_receiving_ids[:1]
|
||||
if so and 'x_fc_receiving_ids' in so._fields else False
|
||||
)
|
||||
carrier = (
|
||||
recv.x_fc_carrier_id
|
||||
if recv and 'x_fc_carrier_id' in recv._fields else False
|
||||
)
|
||||
tpl = (carrier.tracking_url or '') if carrier else ''
|
||||
if tpl:
|
||||
placeholder = '<shipmenttrackingnumber>'
|
||||
if placeholder in tpl:
|
||||
url = tpl.replace(placeholder, rec.tracking_ref)
|
||||
else:
|
||||
url = tpl + rec.tracking_ref
|
||||
rec.x_fc_tracking_url = url
|
||||
|
||||
# ---- Tracking history exposure ----------------------------------------
|
||||
# Pulls fusion.tracking.event records from the outbound shipment linked
|
||||
# via fp.job → fp.receiving → x_fc_outbound_shipment_id. Used by the
|
||||
# portal job page to render a timeline of carrier scan events.
|
||||
x_fc_tracking_event_ids = fields.Many2many(
|
||||
'fusion.tracking.event',
|
||||
string='Tracking Events',
|
||||
compute='_compute_x_fc_tracking_event_ids',
|
||||
)
|
||||
|
||||
@api.depends('tracking_ref')
|
||||
def _compute_x_fc_tracking_event_ids(self):
|
||||
Job = self.env.get('fp.job')
|
||||
Event = self.env.get('fusion.tracking.event')
|
||||
empty = self.env['fusion.tracking.event'] if Event is not None else None
|
||||
for rec in self:
|
||||
events = empty
|
||||
if Event is not None and Job is not None and rec.tracking_ref:
|
||||
job = Job.sudo().search(
|
||||
[('portal_job_id', '=', rec.id)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
recv = (
|
||||
so.x_fc_receiving_ids[:1]
|
||||
if so and 'x_fc_receiving_ids' in so._fields else False
|
||||
)
|
||||
ship = (
|
||||
recv.x_fc_outbound_shipment_id
|
||||
if recv and 'x_fc_outbound_shipment_id' in recv._fields
|
||||
else False
|
||||
)
|
||||
if ship:
|
||||
events = ship.tracking_event_ids.sorted(
|
||||
key=lambda e: e.event_datetime or fields.Datetime.now(),
|
||||
reverse=True,
|
||||
)
|
||||
rec.x_fc_tracking_event_ids = events
|
||||
coc_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Certificate of Conformance',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Fusion Plating — Portal Account Summary
|
||||
* Wires the sort dropdown change event to navigate to the option's value
|
||||
* (which is a fully-formed /my/account_summary URL). Replaces an inline
|
||||
* `onchange` attribute on the <select> so the template stays CSP-clean.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
document.querySelectorAll(".o_fp_account_summary select.o_fp_sort_select").forEach(function (sel) {
|
||||
sel.addEventListener("change", function () {
|
||||
if (sel.value) {
|
||||
window.location.href = sel.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Fusion Plating — portal list search + filter UI helper.
|
||||
*
|
||||
* Provides client-side, real-time multi-keyword filtering for any portal
|
||||
* list page that opts in via the markup contract below. Pure vanilla JS,
|
||||
* no framework, no debounce (client-side filter is <1ms even on 500 rows).
|
||||
*
|
||||
* Markup contract:
|
||||
* - One <input class="o_fp_list_search" data-fp-target="<container-selector>"/>
|
||||
* - One container matching <container-selector> with attribute data-fp-filterable
|
||||
* whose direct children are the filterable rows (e.g. <tr> in a tbody,
|
||||
* or <div class="o_fp_job_card_wrap"> children of #fp_jobs_list).
|
||||
* - Optional: <span class="o_fp_list_search_count"/> updates with N visible
|
||||
* of M total when a filter is active. Empty (no text) when search is empty.
|
||||
*
|
||||
* Each row's textContent is matched against the user's keywords using a
|
||||
* lowercase AND across whitespace-split tokens. Extra non-visible search
|
||||
* terms can be added per row as <span class="d-none" data-fp-search="..."/>.
|
||||
*
|
||||
* Sort dropdown: any <select class="o_fp_sort_select"> on the page navigates
|
||||
* to the selected option's value URL on change. This file wires ALL such
|
||||
* selects on any page, so the scope is not limited to .o_fp_account_summary.
|
||||
* fp_portal_account_summary.js limits itself to .o_fp_account_summary scope
|
||||
* to avoid double-firing on the Account Summary page. These two files are
|
||||
* safe to coexist as long as Account Summary wraps its select inside the
|
||||
* .o_fp_account_summary container (which it does).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initSearch() {
|
||||
document.querySelectorAll("input.o_fp_list_search").forEach(function (input) {
|
||||
var targetSelector = input.getAttribute("data-fp-target");
|
||||
if (!targetSelector) { return; }
|
||||
var container = document.querySelector(targetSelector);
|
||||
if (!container) { return; }
|
||||
|
||||
var countEl = document.querySelector(".o_fp_list_search_count");
|
||||
var totalRows = container.children.length;
|
||||
|
||||
function applyFilter() {
|
||||
var raw = (input.value || "").trim().toLowerCase();
|
||||
var tokens = raw.split(/\s+/).filter(Boolean);
|
||||
var visible = 0;
|
||||
Array.prototype.forEach.call(container.children, function (row) {
|
||||
var text = (row.textContent || "").toLowerCase();
|
||||
var match = tokens.length === 0 || tokens.every(function (t) {
|
||||
return text.indexOf(t) !== -1;
|
||||
});
|
||||
row.style.display = match ? "" : "none";
|
||||
if (match) { visible++; }
|
||||
});
|
||||
if (countEl) {
|
||||
countEl.textContent = tokens.length === 0
|
||||
? ""
|
||||
: visible + " of " + totalRows + " matching";
|
||||
// Toggle visibility
|
||||
countEl.classList.toggle("d-none", tokens.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
// Run once on load in case the input has a prefilled value
|
||||
applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
function initSortSelects() {
|
||||
// Wire ALL .o_fp_sort_select dropdowns that are NOT inside
|
||||
// .o_fp_account_summary (that page has its own handler in
|
||||
// fp_portal_account_summary.js to avoid double-firing).
|
||||
document.querySelectorAll(".o_fp_sort_select").forEach(function (sel) {
|
||||
if (sel.closest(".o_fp_account_summary")) { return; }
|
||||
sel.addEventListener("change", function () {
|
||||
if (sel.value) {
|
||||
window.location.href = sel.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
initSearch();
|
||||
initSortSelects();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Fusion Plating — Portal sidebar hamburger toggle.
|
||||
* Vanilla JS — no OWL / no jQuery. Loaded on every /my/* page.
|
||||
* Below 768px the sidebar is translateX(-100%); toggling
|
||||
* .o_fp_open on both sidebar + backdrop shows/hides it.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
var sidebar = document.querySelector(".o_fp_portal_sidebar");
|
||||
var hamburger = document.querySelector(".o_fp_portal_hamburger");
|
||||
var backdrop = document.querySelector(".o_fp_portal_backdrop");
|
||||
if (!sidebar || !hamburger || !backdrop) {
|
||||
return; // sidebar not on this page (logged-out, error pages, etc.)
|
||||
}
|
||||
|
||||
function toggleOpen(force) {
|
||||
var willOpen = (typeof force === "boolean")
|
||||
? force
|
||||
: !sidebar.classList.contains("o_fp_open");
|
||||
sidebar.classList.toggle("o_fp_open", willOpen);
|
||||
backdrop.classList.toggle("o_fp_open", willOpen);
|
||||
}
|
||||
|
||||
hamburger.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
toggleOpen();
|
||||
});
|
||||
backdrop.addEventListener("click", function () {
|
||||
toggleOpen(false);
|
||||
});
|
||||
// Close when navigating to a sidebar link on mobile
|
||||
sidebar.querySelectorAll("a.o_fp_sidebar_item").forEach(function (a) {
|
||||
a.addEventListener("click", function () {
|
||||
if (window.innerWidth < 769) {
|
||||
toggleOpen(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Resize safety: closing the drawer when the user crosses the desktop
|
||||
// breakpoint prevents the backdrop's display:block from leaking onto
|
||||
// the desktop layout. SCSS scopes the sidebar's drawer transform to
|
||||
// @media (max-width: 768px), but the backdrop's display rule isn't
|
||||
// media-scoped (intentionally — the JS owns that lifecycle).
|
||||
window.addEventListener("resize", function () {
|
||||
if (window.innerWidth > 768 && sidebar.classList.contains("o_fp_open")) {
|
||||
toggleOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -86,3 +86,35 @@
|
||||
// Size modifiers — match Bootstrap btn-sm / btn-lg sizing
|
||||
.o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
|
||||
.o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }
|
||||
|
||||
// ============================================================================
|
||||
// Globally suppress browser-default underline-on-hover for portal
|
||||
// surfaces. Bootstrap's Reboot puts `a:hover { text-decoration: underline }`
|
||||
// on every anchor; for our flat-aesthetic chips / cards / pill buttons that
|
||||
// reads as a buggy visual artifact. Hover signal lives in color + shadow
|
||||
// instead. Specificity is high enough to win without !important.
|
||||
// ============================================================================
|
||||
.o_fp_btn,
|
||||
.o_fp_btn_primary,
|
||||
.o_fp_btn_secondary,
|
||||
.o_fp_btn_ghost,
|
||||
.o_fp_btn_danger,
|
||||
.o_fp_btn_mint,
|
||||
.o_fp_dashboard a,
|
||||
.o_fp_job_detail a,
|
||||
.o_fp_job_card,
|
||||
.o_fp_doc_chip,
|
||||
.o_fp_doc_row,
|
||||
.o_fp_panel_view_all,
|
||||
.o_fp_panel_inline_cta,
|
||||
.o_fp_kpi_hint,
|
||||
.o_fp_view_all a,
|
||||
.o_fp_status_tab,
|
||||
.o_fp_related_links a {
|
||||
text-decoration: none;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,61 +91,131 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Job card: outer wrap is a plain div so we can place an interactive
|
||||
// actions footer (Repeat Order form, doc download links) as a SIBLING
|
||||
// of the main anchor — forms inside anchors are invalid HTML and
|
||||
// browser-buggy. Hover/lift effect lives on the wrap; click target is
|
||||
// the inner .o_fp_job_card_main anchor only.
|
||||
.o_fp_job_card {
|
||||
@extend .o_fp_card;
|
||||
padding: $fp-space-4;
|
||||
border-radius: $fp-radius-tile;
|
||||
margin-bottom: $fp-space-3;
|
||||
box-shadow: $fp-shadow-card;
|
||||
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
|
||||
|
||||
// Works for both <div> and <a> wrappers. When rendered as an anchor
|
||||
// the whole card becomes a click target (jobs list + dashboard).
|
||||
&:hover {
|
||||
box-shadow: $fp-shadow-card-hover;
|
||||
border-color: $fp-aqua;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_job_card_main {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: $fp-shadow-card-hover;
|
||||
border-color: $fp-aqua;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid $fp-teal;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_job_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $fp-space-3;
|
||||
.o_fp_job_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: .55rem;
|
||||
gap: .65rem;
|
||||
|
||||
.o_fp_job_ref {
|
||||
font-weight: 600;
|
||||
color: $fp-text;
|
||||
font-size: .98rem;
|
||||
}
|
||||
.o_fp_job_meta {
|
||||
color: $fp-muted;
|
||||
font-size: .8rem;
|
||||
margin-left: .65rem;
|
||||
}
|
||||
.o_fp_job_ref {
|
||||
font-weight: 600;
|
||||
color: $fp-text;
|
||||
font-size: .98rem;
|
||||
}
|
||||
|
||||
.o_fp_job_docs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
margin-top: $fp-space-3;
|
||||
padding-top: .6rem;
|
||||
border-top: 1px solid $fp-section-bg;
|
||||
.o_fp_job_meta {
|
||||
color: $fp-muted;
|
||||
font-size: .8rem;
|
||||
margin-left: .55rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Part name / number row under the header
|
||||
.o_fp_job_part {
|
||||
color: $fp-text-body;
|
||||
font-size: .78rem;
|
||||
margin-bottom: .2rem;
|
||||
.o_fp_job_part_icon { color: $fp-muted-light; margin-right: .3rem; }
|
||||
}
|
||||
// Shipping address row
|
||||
.o_fp_job_ship {
|
||||
color: $fp-muted;
|
||||
font-size: .76rem;
|
||||
margin-bottom: $fp-space-3;
|
||||
.o_fp_job_ship_icon { color: $fp-muted-light; margin-right: .3rem; }
|
||||
}
|
||||
|
||||
// Actions footer — siblings of the main anchor. Doc download chips on
|
||||
// the left, Repeat Order on the right. Border-top visually separates.
|
||||
.o_fp_job_card_actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $fp-space-3;
|
||||
margin-top: $fp-space-3;
|
||||
padding-top: .7rem;
|
||||
border-top: 1px solid $fp-section-bg;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fp_job_card_docs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
// Compact icon-only download chip. Tooltip via `title` attr.
|
||||
.o_fp_doc_quick_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .3rem .55rem;
|
||||
background: $fp-section-bg;
|
||||
color: $fp-teal;
|
||||
border: 1px solid $fp-card-border;
|
||||
border-radius: $fp-radius-chip;
|
||||
font-size: .72rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
&:hover {
|
||||
background: $fp-mint;
|
||||
color: $fp-teal-dark;
|
||||
text-decoration: none;
|
||||
}
|
||||
&.o_fp_doc_quick_btn_pending {
|
||||
background: $fp-card-bg;
|
||||
color: $fp-muted-light;
|
||||
border: 1px dashed $fp-card-border-dark;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
// Legacy: kept for any place still rendering chips below the stepper.
|
||||
.o_fp_job_docs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
margin-top: $fp-space-3;
|
||||
padding-top: .6rem;
|
||||
border-top: 1px solid $fp-section-bg;
|
||||
}
|
||||
|
||||
.o_fp_secondary_panels {
|
||||
display: grid;
|
||||
// Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at
|
||||
@@ -200,3 +270,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter pills used by Account Summary (also reusable elsewhere)
|
||||
.o_fp_filter_pill {
|
||||
display: inline-block;
|
||||
padding: .25rem .75rem;
|
||||
border-radius: $fp-radius-pill;
|
||||
background: $fp-section-bg;
|
||||
color: $fp-muted;
|
||||
font-size: .8rem;
|
||||
text-decoration: none;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
&:hover { background: $fp-mint; color: $fp-teal-dark; text-decoration: none; }
|
||||
&.o_fp_filter_pill_active {
|
||||
background: $fp-gradient-primary;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// ============================================================================
|
||||
// Fusion Plating — Portal · Sidebar shell
|
||||
// Sticky 240px left rail wrapping every /my/* page. Grouped sections
|
||||
// (Dashboard / ACTIVITY / DOCUMENTS / ACCOUNT). Active page = mint
|
||||
// gradient fill + brand teal left bar. Below 768px collapses to a
|
||||
// hamburger drawer with backdrop.
|
||||
// ============================================================================
|
||||
|
||||
.o_fp_portal_shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: $fp-space-5;
|
||||
background: $fp-page-bg;
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: $fp-space-4;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
padding: $fp-space-3;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_portal_sidebar {
|
||||
position: sticky;
|
||||
top: $fp-space-4;
|
||||
background: $fp-card-bg;
|
||||
border: 1px solid $fp-card-border;
|
||||
border-radius: $fp-radius-card;
|
||||
padding: .85rem .5rem;
|
||||
box-shadow: $fp-shadow-card;
|
||||
font-family: $fp-font;
|
||||
align-self: start;
|
||||
|
||||
.o_fp_sidebar_header {
|
||||
padding: .45rem .9rem .7rem;
|
||||
font-size: .62rem;
|
||||
color: $fp-muted;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid $fp-section-bg;
|
||||
}
|
||||
|
||||
.o_fp_sidebar_section_label {
|
||||
padding: .85rem .9rem .25rem;
|
||||
font-size: .62rem;
|
||||
color: $fp-muted-light;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.o_fp_sidebar_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
padding: .5rem .9rem;
|
||||
margin: .05rem .15rem;
|
||||
color: $fp-text-body;
|
||||
font-size: .85rem;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
|
||||
&:hover {
|
||||
background: $fp-section-bg;
|
||||
color: $fp-teal-dark;
|
||||
text-decoration: none;
|
||||
}
|
||||
&.o_fp_sidebar_active {
|
||||
background: linear-gradient(90deg, $fp-mint 0%, $fp-mint-pastel 100%);
|
||||
color: $fp-teal-dark;
|
||||
font-weight: 600;
|
||||
border-left: 3px solid $fp-teal;
|
||||
}
|
||||
|
||||
.o_fp_sidebar_icon {
|
||||
width: 1.15rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_sidebar_footer {
|
||||
border-top: 1px solid $fp-section-bg;
|
||||
margin: .7rem .15rem 0;
|
||||
padding-top: .5rem;
|
||||
}
|
||||
|
||||
// Mobile: slide-in drawer
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 1040;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
|
||||
&.o_fp_open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile hamburger button (above main content, hidden on desktop)
|
||||
.o_fp_portal_hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: $fp-card-bg;
|
||||
border: 1px solid $fp-card-border;
|
||||
border-radius: $fp-radius-button;
|
||||
color: $fp-teal;
|
||||
margin-bottom: $fp-space-3;
|
||||
cursor: pointer;
|
||||
transition: background .12s ease;
|
||||
|
||||
&:hover { background: $fp-section-bg; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
// Backdrop behind the open mobile drawer
|
||||
.o_fp_portal_backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 30, 30, .35);
|
||||
z-index: 1030;
|
||||
|
||||
&.o_fp_open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_portal_main {
|
||||
// Stretches with the grid row so the right column matches the
|
||||
// sidebar's height on short pages (empty list states, statements
|
||||
// tab, etc.) — uniform visual rhythm.
|
||||
min-height: 100%;
|
||||
// Bootstrap tables can grow wider than the grid track without this;
|
||||
// min-width: 0 lets the flex/grid child shrink and lets overflow-x
|
||||
// on inner .table-responsive containers do their job on Safari.
|
||||
min-width: 0;
|
||||
|
||||
// Neutralise Odoo's container pt-3 + templates' mt-3 on the first
|
||||
// child so the right column's top aligns flush with the sidebar's
|
||||
// top edge. !important is required because Bootstrap 5 spacing
|
||||
// utilities (.pt-3, .mt-3) ship with !important by default — without
|
||||
// matching specificity Bootstrap wins and the right column sits
|
||||
// ~32px (pt-3 + mt-3) lower than the sidebar.
|
||||
#wrap > .container {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
#wrap > .container > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
// Odoo's portal.portal_searchbar still renders an inline breadcrumb
|
||||
// inside its navbar when the page sets breadcrumbs_searchbar=True.
|
||||
// Now that our shell forces the outer breadcrumb to ALWAYS render
|
||||
// (via the fp_portal_shell xpath above), the inline copy would be
|
||||
// a visible duplicate. Hide it; the navbar's other content (title,
|
||||
// sort/filter dropdowns) stays visible.
|
||||
.o_portal_navbar > .breadcrumb,
|
||||
.o_portal_navbar > ol.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -117,8 +117,9 @@ class TestPortalDashboard(TransactionCase):
|
||||
statuses = [t['status'] for t in timeline]
|
||||
self.assertEqual(statuses, ['done', 'done', 'done', 'done', 'done'])
|
||||
|
||||
def test_group_documents_v1_returns_4_groups(self):
|
||||
"""V1 doc grouping returns 4 groups; quality populated when CoC set."""
|
||||
def test_group_documents_returns_5_groups(self):
|
||||
"""V1 doc grouping returns 5 groups: From You / Specs / Work Order /
|
||||
Quality / Shipping. Quality populated when CoC set."""
|
||||
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||
Job = self.env['fusion.plating.portal.job']
|
||||
att = self.env['ir.attachment'].create({
|
||||
@@ -133,13 +134,137 @@ class TestPortalDashboard(TransactionCase):
|
||||
'coc_attachment_id': att.id,
|
||||
})
|
||||
groups = FpCustomerPortal()._fp_group_documents(job)
|
||||
self.assertEqual(len(groups), 4)
|
||||
self.assertEqual(len(groups), 5)
|
||||
keys = [g['key'] for g in groups]
|
||||
self.assertEqual(keys, ['from_you', 'specs', 'quality', 'shipping'])
|
||||
self.assertEqual(keys, ['from_you', 'specs', 'work_order', 'quality', 'shipping'])
|
||||
# Quality group has the CoC populated (not pending)
|
||||
quality = next(g for g in groups if g['key'] == 'quality')
|
||||
self.assertTrue(any(d['label'] == 'Certificate of Conformance' and not d.get('pending')
|
||||
for d in quality['docs']))
|
||||
# From You + Specifications are placeholders in V1
|
||||
# From You is a placeholder when no SO link (test job has no x_fc_job_id)
|
||||
from_you = next(g for g in groups if g['key'] == 'from_you')
|
||||
self.assertTrue(all(d.get('pending') for d in from_you['docs']))
|
||||
# Work Order is placeholder without a backend fp.job link
|
||||
wo = next(g for g in groups if g['key'] == 'work_order')
|
||||
self.assertTrue(all(d.get('pending') for d in wo['docs']))
|
||||
|
||||
def test_account_summary_partitions_invoices_and_credits(self):
|
||||
"""Account Summary helper splits posted moves by move_type."""
|
||||
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||
# fp_from_so_invoice=True bypasses the fusion_plating_jobs enforcement
|
||||
# that normally requires invoices to originate from a Sale Order.
|
||||
# payment_term is required by fusion_plating_invoicing's action_post gate.
|
||||
# Both are test-data scaffolding only; they do not affect what is tested.
|
||||
pt = self.env.ref('account.account_payment_term_immediate')
|
||||
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||
inv = Move.create({
|
||||
'partner_id': self.partner.id,
|
||||
'move_type': 'out_invoice',
|
||||
'invoice_date': '2026-05-01',
|
||||
'invoice_payment_term_id': pt.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test plating',
|
||||
'quantity': 1,
|
||||
'price_unit': 250.00,
|
||||
})],
|
||||
})
|
||||
inv.action_post()
|
||||
cm = Move.create({
|
||||
'partner_id': self.partner.id,
|
||||
'move_type': 'out_refund',
|
||||
'invoice_date': '2026-05-02',
|
||||
'invoice_payment_term_id': pt.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test credit',
|
||||
'quantity': 1,
|
||||
'price_unit': 50.00,
|
||||
})],
|
||||
})
|
||||
cm.action_post()
|
||||
|
||||
controller = FpCustomerPortal()
|
||||
data = controller._fp_account_summary_data(
|
||||
self.partner.commercial_partner_id,
|
||||
tab='invoices',
|
||||
filter_state='all',
|
||||
search='',
|
||||
sort='date_desc',
|
||||
page=1,
|
||||
)
|
||||
# Tab=invoices -> only out_invoice
|
||||
names = data['records'].mapped('name')
|
||||
self.assertIn(inv.name, names)
|
||||
self.assertNotIn(cm.name, names)
|
||||
|
||||
data = controller._fp_account_summary_data(
|
||||
self.partner.commercial_partner_id,
|
||||
tab='credit_memos',
|
||||
filter_state='all',
|
||||
search='',
|
||||
sort='date_desc',
|
||||
page=1,
|
||||
)
|
||||
names = data['records'].mapped('name')
|
||||
self.assertIn(cm.name, names)
|
||||
self.assertNotIn(inv.name, names)
|
||||
|
||||
def test_account_summary_open_balance_sums_residuals(self):
|
||||
"""Open Balance pill = sum of amount_residual across open invoices."""
|
||||
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||
pt = self.env.ref('account.account_payment_term_immediate')
|
||||
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||
inv = Move.create({
|
||||
'partner_id': self.partner.id,
|
||||
'move_type': 'out_invoice',
|
||||
'invoice_date': '2026-05-01',
|
||||
'invoice_payment_term_id': pt.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Open inv',
|
||||
'quantity': 1,
|
||||
'price_unit': 750.00,
|
||||
})],
|
||||
})
|
||||
inv.action_post()
|
||||
|
||||
controller = FpCustomerPortal()
|
||||
open_balance = controller._fp_account_summary_open_balance(
|
||||
self.partner.commercial_partner_id,
|
||||
)
|
||||
# The 750 invoice has amount_residual = 750 until paid
|
||||
self.assertEqual(open_balance, 750.00)
|
||||
|
||||
def test_account_summary_search_matches_name_and_ref(self):
|
||||
"""Search box filters by invoice number OR customer PO (ref)."""
|
||||
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||
pt = self.env.ref('account.account_payment_term_immediate')
|
||||
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||
inv = Move.create({
|
||||
'partner_id': self.partner.id,
|
||||
'move_type': 'out_invoice',
|
||||
'invoice_date': '2026-05-01',
|
||||
'invoice_payment_term_id': pt.id,
|
||||
'ref': 'PO-CUSTOMER-99999',
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Sale',
|
||||
'quantity': 1,
|
||||
'price_unit': 100.0,
|
||||
})],
|
||||
})
|
||||
inv.action_post()
|
||||
controller = FpCustomerPortal()
|
||||
|
||||
# Search by ref (customer PO)
|
||||
data = controller._fp_account_summary_data(
|
||||
self.partner.commercial_partner_id,
|
||||
tab='invoices', filter_state='all',
|
||||
search='99999', sort='date_desc', page=1,
|
||||
)
|
||||
self.assertIn(inv, data['records'])
|
||||
|
||||
# Search that matches nothing
|
||||
data = controller._fp_account_summary_data(
|
||||
self.partner.commercial_partner_id,
|
||||
tab='invoices', filter_state='all',
|
||||
search='zzznotfoundzzz', sort='date_desc', page=1,
|
||||
)
|
||||
self.assertNotIn(inv, data['records'])
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<template id="portal_my_account_summary" name="Account Summary">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_fp_account_summary">
|
||||
|
||||
<!-- Page header: title + Open Balance pill -->
|
||||
<div class="d-flex justify-content-between align-items-baseline mb-3">
|
||||
<h3 class="mb-0" style="color: var(--fp-text, #111827)">Account Summary</h3>
|
||||
<div class="o_fp_badge o_fp_badge_paid" t-if="open_balance">
|
||||
<span class="o_fp_badge_dot"/>
|
||||
Open Balance:
|
||||
<span t-out="open_balance_display"/>
|
||||
</div>
|
||||
<span class="o_fp_badge" t-else=""
|
||||
style="background:#f3f7f6;color:#374151">
|
||||
Open Balance: $0.00
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab strip -->
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<t t-foreach="tabs" t-as="tab_entry">
|
||||
<li class="nav-item">
|
||||
<a t-attf-href="/my/account_summary?tab=#{tab_entry[0]}"
|
||||
t-attf-class="nav-link #{'active' if active_tab == tab_entry[0] else ''}"
|
||||
t-out="tab_entry[1]"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
|
||||
<!-- Filter pills + search + sort -->
|
||||
<t t-if="active_tab != 'statements'">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted small">Showing:</span>
|
||||
<t t-foreach="['open', 'closed', 'all']" t-as="fk">
|
||||
<a t-attf-href="/my/account_summary?tab=#{active_tab}&filter_state=#{fk}&sort=#{sort}&search=#{search}"
|
||||
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if filter_state == fk else ''}"
|
||||
t-out="fk.capitalize()"/>
|
||||
</t>
|
||||
</div>
|
||||
<form method="GET" action="/my/account_summary" class="d-flex gap-1 ms-auto m-0">
|
||||
<input type="hidden" name="tab" t-att-value="active_tab"/>
|
||||
<input type="hidden" name="filter_state" t-att-value="filter_state"/>
|
||||
<input type="hidden" name="sort" t-att-value="sort"/>
|
||||
<input type="text" name="search" t-att-value="search"
|
||||
placeholder="Search invoice # or PO #"
|
||||
class="form-control form-control-sm"
|
||||
style="max-width: 260px"/>
|
||||
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">Search</button>
|
||||
</form>
|
||||
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 200px">
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_desc&search=' + search"
|
||||
t-att-selected="sort == 'date_desc'">Newest first</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_asc&search=' + search"
|
||||
t-att-selected="sort == 'date_asc'">Oldest first</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_desc&search=' + search"
|
||||
t-att-selected="sort == 'amount_desc'">Largest amount</option>
|
||||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_asc&search=' + search"
|
||||
t-att-selected="sort == 'amount_asc'">Smallest amount</option>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Table -->
|
||||
<t t-if="active_tab == 'statements'">
|
||||
<div class="o_fp_card text-center text-muted" style="padding: 2rem">
|
||||
<p>Monthly statements coming soon.</p>
|
||||
<p class="small">
|
||||
For a copy in the meantime, contact your sales rep at EN Plating.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="not records">
|
||||
<div class="o_fp_card text-center text-muted" style="padding: 1.5rem">
|
||||
<t t-if="search">No results for "<t t-out="search"/>".</t>
|
||||
<t t-else="">No records in this tab.</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_card" style="padding: 0; overflow: hidden">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Posted On</th>
|
||||
<th>PO #</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Balance</th>
|
||||
<th class="text-end">View PDF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="records" t-as="move">
|
||||
<td t-out="move.name"/>
|
||||
<td>
|
||||
<t t-if="move.amount_residual == 0">
|
||||
<span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Closed</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="o_fp_badge o_fp_badge_in_progress"><span class="o_fp_badge_dot"/>Open</span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="move.invoice_date"
|
||||
t-field="move.invoice_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td t-out="move.ref or ''"/>
|
||||
<td>
|
||||
<span t-if="move.invoice_date_due"
|
||||
t-field="move.invoice_date_due"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="move.amount_residual"
|
||||
t-options='{"widget": "monetary", "display_currency": move.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a t-attf-href="/my/invoices/#{move.id}?report_type=pdf&download=true"
|
||||
class="o_fp_btn_ghost o_fp_btn_sm">View PDF</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pager -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-3"
|
||||
t-if="pager and pager.get('page_count', 0) > 1">
|
||||
<div class="text-muted small">
|
||||
Showing
|
||||
<t t-out="pager['offset'] + 1"/>–<t t-out="min(pager['offset'] + 10, total)"/>
|
||||
of <t t-out="total"/>
|
||||
</div>
|
||||
<ul class="pagination mb-0">
|
||||
<t t-foreach="pager.get('pages', [])" t-as="p">
|
||||
<li t-attf-class="page-item #{'active' if p['num'] == pager['page']['num'] else ''}">
|
||||
<a class="page-link" t-att-href="p['url']" t-out="p['num']"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -62,13 +62,13 @@
|
||||
<li t-if="page_name == 'fp_jobs'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
Parts Portal
|
||||
Work Orders
|
||||
</li>
|
||||
|
||||
<!-- Job detail -->
|
||||
<li t-if="page_name == 'fp_portal_job'"
|
||||
class="breadcrumb-item">
|
||||
<a href="/my/jobs">Parts Portal</a>
|
||||
<a href="/my/jobs">Work Orders</a>
|
||||
</li>
|
||||
<li t-if="page_name == 'fp_portal_job'"
|
||||
class="breadcrumb-item active"
|
||||
@@ -76,18 +76,11 @@
|
||||
<span t-out="job.name"/>
|
||||
</li>
|
||||
|
||||
<!-- Purchase Orders -->
|
||||
<li t-if="page_name == 'fp_purchase_orders'"
|
||||
<!-- Account Summary -->
|
||||
<li t-if="page_name == 'fp_account_summary'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
Purchase Orders
|
||||
</li>
|
||||
|
||||
<!-- Invoices -->
|
||||
<li t-if="page_name == 'fp_invoices'"
|
||||
class="breadcrumb-item active"
|
||||
aria-current="page">
|
||||
Invoices
|
||||
Account Summary
|
||||
</li>
|
||||
|
||||
<!-- Deliveries / Packing Slips -->
|
||||
|
||||
@@ -156,67 +156,49 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Part Drawing or 3D Model</label>
|
||||
<div class="o_fp_file_drop_zone p-4">
|
||||
<i class="fa fa-cloud-upload"/>
|
||||
<p class="mb-1 fw-semibold">
|
||||
Drag and drop your file here, or click to browse
|
||||
</p>
|
||||
<p class="small text-muted mb-2">
|
||||
Accepted: STL, STP, STEP, IGES, PDF (max 50 MB)
|
||||
</p>
|
||||
<input type="file" name="part_file" id="part_file"
|
||||
class="form-control"
|
||||
accept=".stl,.stp,.step,.iges,.igs,.pdf"/>
|
||||
<!-- File Uploads — separate drawing + 3D model.
|
||||
Customer can upload either or both. STL gets
|
||||
trimesh surface-area auto-calc server-side
|
||||
(not shown to customer — backend uses it for
|
||||
future pricing). -->
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<label class="form-label">Drawing (PDF)</label>
|
||||
<div class="o_fp_file_drop_zone p-3">
|
||||
<i class="fa fa-file-pdf-o"/>
|
||||
<p class="mb-1 fw-semibold">PDF drawing</p>
|
||||
<p class="small text-muted mb-2">
|
||||
2D / dimensioned drawing
|
||||
</p>
|
||||
<input type="file" name="part_drawing" id="part_drawing"
|
||||
class="form-control"
|
||||
accept=".pdf"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<label class="form-label">3D Model</label>
|
||||
<div class="o_fp_file_drop_zone p-3">
|
||||
<i class="fa fa-cube"/>
|
||||
<p class="mb-1 fw-semibold">STL / STP / STEP / IGES</p>
|
||||
<p class="small text-muted mb-2">
|
||||
Optional — speeds up estimation
|
||||
</p>
|
||||
<input type="file" name="part_3d_model" id="part_3d_model"
|
||||
class="form-control"
|
||||
accept=".stl,.stp,.step,.iges,.igs"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4"/>
|
||||
|
||||
<!-- Manual Measurements -->
|
||||
<h6 class="mb-3">
|
||||
<i class="fa fa-ruler-combined me-2"/>Manual Measurements
|
||||
<span class="text-muted small fw-normal ms-2">
|
||||
(if no 3D model uploaded)
|
||||
</span>
|
||||
</h6>
|
||||
|
||||
<input type="hidden" name="geometry_source" value="manual"/>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_length" class="form-label">Length (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_length" name="dimensions_length"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_width" class="form-label">Width (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_width" name="dimensions_width"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="dimensions_height" class="form-label">Height (in)</label>
|
||||
<input type="number" step="0.001" min="0"
|
||||
id="dimensions_height" name="dimensions_height"
|
||||
class="form-control" placeholder="0.000"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="surface_area" class="form-label">
|
||||
Surface Area (sq in)
|
||||
<span class="text-muted small fw-normal ms-1">
|
||||
-- auto-calculated if STL uploaded
|
||||
</span>
|
||||
</label>
|
||||
<input type="number" step="0.0001" min="0"
|
||||
id="surface_area" name="surface_area"
|
||||
class="form-control" placeholder="0.0000"/>
|
||||
</div>
|
||||
<!-- Manual measurements hidden per customer-feedback 2026-05-17:
|
||||
backend computes these (or doesn't) — not the
|
||||
customer's job. Fields kept as hidden inputs at 0
|
||||
so the controller doesn't error on missing keys. -->
|
||||
<input type="hidden" name="geometry_source" value="upload"/>
|
||||
<input type="hidden" name="dimensions_length" value="0"/>
|
||||
<input type="hidden" name="dimensions_width" value="0"/>
|
||||
<input type="hidden" name="dimensions_height" value="0"/>
|
||||
<input type="hidden" name="surface_area" value="0"/>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="d-flex justify-content-between">
|
||||
@@ -295,26 +277,13 @@
|
||||
<h6 class="card-title mb-2" style="color: var(--bs-body-color);">
|
||||
<t t-out="coat.name"/>
|
||||
</h6>
|
||||
<p t-if="coat.process_type_id" class="small text-muted mb-1">
|
||||
<p t-if="coat.code" class="small text-muted mb-1">
|
||||
<i class="fa fa-tag me-1"/>
|
||||
<t t-out="coat.code"/>
|
||||
</p>
|
||||
<p t-if="coat.process_family" class="small text-muted mb-1">
|
||||
<i class="fa fa-flask me-1"/>
|
||||
<t t-out="coat.process_type_id.name"/>
|
||||
</p>
|
||||
<p t-if="coat.spec_reference" class="small text-muted mb-1">
|
||||
<i class="fa fa-bookmark me-1"/>
|
||||
<t t-out="coat.spec_reference"/>
|
||||
</p>
|
||||
<p t-if="coat.thickness_min or coat.thickness_max" class="small text-muted mb-1">
|
||||
<i class="fa fa-arrows-v me-1"/>
|
||||
<t t-if="coat.thickness_min" t-out="coat.thickness_min"/>
|
||||
<t t-if="coat.thickness_min and coat.thickness_max"> - </t>
|
||||
<t t-if="coat.thickness_max" t-out="coat.thickness_max"/>
|
||||
<t t-out="coat.thickness_uom or 'mils'"/>
|
||||
</p>
|
||||
<p t-if="coat.certification_level and coat.certification_level != 'commercial'"
|
||||
class="small mb-0">
|
||||
<span class="badge text-bg-warning">
|
||||
<t t-out="dict(coat._fields['certification_level']._description_selection(coat.env)).get(coat.certification_level)"/>
|
||||
</span>
|
||||
<t t-out="dict(coat._fields['process_family']._description_selection(coat.env)).get(coat.process_family)"/>
|
||||
</p>
|
||||
<p t-if="coat.description" class="small text-muted mt-2 mb-0"
|
||||
t-out="coat.description"/>
|
||||
@@ -408,9 +377,9 @@
|
||||
<strong t-out="coating.name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="coating.spec_reference" class="row mb-3">
|
||||
<div class="col-sm-4 text-muted small fw-semibold">Spec</div>
|
||||
<div class="col-sm-8" t-out="coating.spec_reference"/>
|
||||
<div t-if="coating.code" class="row mb-3">
|
||||
<div class="col-sm-4 text-muted small fw-semibold">Code</div>
|
||||
<div class="col-sm-8" t-out="coating.code"/>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted small fw-semibold">Quantity</div>
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<a href="/my/quote_requests" class="o_fp_kpi_hint o_fp_hint_action">View quotes →</a>
|
||||
</div>
|
||||
<div class="o_fp_kpi_tile">
|
||||
<div class="o_fp_kpi_label">Active POs</div>
|
||||
<div class="o_fp_kpi_label">Active Sales Orders</div>
|
||||
<div class="o_fp_kpi_value" t-out="po_count"/>
|
||||
<a href="/my/purchase_orders" class="o_fp_kpi_hint">View POs →</a>
|
||||
<a href="/my/orders" class="o_fp_kpi_hint">View orders →</a>
|
||||
</div>
|
||||
<div class="o_fp_kpi_tile o_fp_kpi_hero">
|
||||
<div class="o_fp_kpi_label">In-Flight Jobs</div>
|
||||
@@ -67,49 +67,9 @@
|
||||
|
||||
<t t-if="recent_jobs">
|
||||
<t t-foreach="recent_jobs[:3]" t-as="job">
|
||||
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
|
||||
<div class="o_fp_job_header">
|
||||
<div>
|
||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||
<span class="o_fp_job_meta">
|
||||
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
||||
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
||||
</span>
|
||||
</div>
|
||||
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
||||
<t t-set="state" t-value="job.state"/>
|
||||
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- State -> active step index mapping.
|
||||
5 customer-visible stages: Received / Inspected / Plating / QC / Ship.
|
||||
state_idx >= 5 means all done (shipped or complete). -->
|
||||
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
||||
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
||||
<t t-set="steps" t-value="[
|
||||
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
||||
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
||||
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
||||
]"/>
|
||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
||||
|
||||
<!-- Doc chips: CoC + tracking (V1) -->
|
||||
<div class="o_fp_job_docs">
|
||||
<t t-if="job.coc_attachment_id">
|
||||
<span class="o_fp_doc_chip">📑 CoC</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="o_fp_doc_chip o_fp_doc_chip_pending">📑 CoC · pending</span>
|
||||
</t>
|
||||
<t t-if="job.tracking_ref">
|
||||
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
|
||||
</t>
|
||||
</div>
|
||||
</a>
|
||||
<t t-call="fusion_plating_portal.fp_portal_job_card">
|
||||
<t t-set="job" t-value="job"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-if="job_count > 3">
|
||||
@@ -150,11 +110,11 @@
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Orders -->
|
||||
<!-- Sales Orders -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">🛒</span> Recent Purchase Orders
|
||||
<a href="/my/purchase_orders" class="o_fp_panel_view_all">View all →</a>
|
||||
<span class="o_fp_panel_icon">🛒</span> Recent Sales Orders
|
||||
<a href="/my/orders" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_pos">
|
||||
<t t-foreach="recent_pos[:3]" t-as="po">
|
||||
@@ -165,7 +125,7 @@
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_panel_row text-muted">No purchase orders yet.</div>
|
||||
<div class="o_fp_panel_row text-muted">No sales orders yet.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -259,8 +219,8 @@
|
||||
<t t-set="placeholder_count" t-value="'fp_portal_job_count'"/>
|
||||
</t>
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Purchase Orders</t>
|
||||
<t t-set="url" t-value="'/my/purchase_orders'"/>
|
||||
<t t-set="title">Sales Orders</t>
|
||||
<t t-set="url" t-value="'/my/orders'"/>
|
||||
<t t-set="placeholder_count" t-value="'fp_purchase_order_count'"/>
|
||||
</t>
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
|
||||
@@ -78,6 +78,184 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Job card — shared between /my/home dashboard and /my/jobs list. -->
|
||||
<!-- Pass `job` (fusion.plating.portal.job). Renders a wrap div with -->
|
||||
<!-- inner anchor (whole card click target = detail page) and a sibling -->
|
||||
<!-- actions footer (doc download chips + Repeat Order form). Forms -->
|
||||
<!-- live OUTSIDE the anchor because forms-inside-anchors is invalid -->
|
||||
<!-- HTML and clicks would otherwise double-fire. -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="fp_portal_job_card" name="Portal: Job Card">
|
||||
<!-- Pull related backend records inline (cards are 3-5 per page,
|
||||
query cost is fine). Each `t-set` is a no-op if the field
|
||||
chain breaks. -->
|
||||
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
|
||||
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
|
||||
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
|
||||
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
|
||||
<t t-set="picking" t-value="so.picking_ids.filtered(lambda p: p.state == 'done')[:1] if so and 'picking_ids' in so._fields else False"/>
|
||||
|
||||
<!-- Stepper state mapping (same as detail page).
|
||||
received -> idx 0; in_progress -> 2; quality_check -> 3;
|
||||
ready_to_ship -> 4; shipped / complete -> 5 (all done). -->
|
||||
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
||||
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
||||
<t t-set="steps" t-value="[
|
||||
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
||||
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
||||
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
||||
]"/>
|
||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
||||
|
||||
<div class="o_fp_job_card">
|
||||
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card_main">
|
||||
<div class="o_fp_job_header">
|
||||
<div>
|
||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||
<span class="o_fp_job_meta">
|
||||
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
||||
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
||||
</span>
|
||||
</div>
|
||||
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
||||
<t t-set="state" t-value="job.state"/>
|
||||
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Part info: prefer the part catalog ref, fall back to
|
||||
process types listed on the portal job. -->
|
||||
<t t-if="part">
|
||||
<div class="o_fp_job_part">
|
||||
<span class="o_fp_job_part_icon">📦</span>
|
||||
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
|
||||
<t t-out="part.name or part.display_name"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="job.process_type_ids">
|
||||
<div class="o_fp_job_part">
|
||||
<span class="o_fp_job_part_icon">📦</span>
|
||||
<t t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Shipping address: customer may have multiple sites; the
|
||||
SO carries which one this job ships to. -->
|
||||
<t t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
|
||||
<div class="o_fp_job_ship">
|
||||
<span class="o_fp_job_ship_icon">📍</span>
|
||||
Ship to: <t t-out="ship_to.name"/>
|
||||
<t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
||||
</a>
|
||||
|
||||
<!-- Actions footer: doc quick-downloads + Repeat Order. Lives
|
||||
OUTSIDE the .o_fp_job_card_main anchor so the form button
|
||||
doesn't double-fire navigation + form submission. -->
|
||||
<div class="o_fp_job_card_actions">
|
||||
<div class="o_fp_job_card_docs">
|
||||
<t t-if="so">
|
||||
<a t-attf-href="/my/jobs/#{job.id}/so_confirmation"
|
||||
class="o_fp_doc_quick_btn" title="Sales Order Confirmation">
|
||||
📄 SO
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="backend_job">
|
||||
<a t-attf-href="/my/jobs/#{job.id}/wo_detail"
|
||||
class="o_fp_doc_quick_btn" title="Work Order Detail">
|
||||
🛠 WO
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="job.coc_attachment_id">
|
||||
<a t-attf-href="/my/jobs/#{job.id}/coc"
|
||||
class="o_fp_doc_quick_btn" title="Certificate of Conformance">
|
||||
📑 CoC
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="o_fp_doc_quick_btn o_fp_doc_quick_btn_pending"
|
||||
title="Will appear after QC completes">
|
||||
📑 CoC
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="job.packing_list_attachment_id">
|
||||
<a t-attf-href="/web/content/#{job.packing_list_attachment_id.id}?download=true"
|
||||
class="o_fp_doc_quick_btn" title="Packing Slip">
|
||||
📦 Packing
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<form t-attf-action="/my/jobs/#{job.id}/repeat" method="post" class="m-0">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">
|
||||
<i class="fa fa-repeat"/> Repeat Order
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Reusable filter-pill + search + sort strip for portal list pages. -->
|
||||
<!-- Render with t-call="fusion_plating_portal.fp_portal_list_controls" -->
|
||||
<!-- and t-set the following vars in the call: -->
|
||||
<!-- filters : list of (key, label) tuples for the pills -->
|
||||
<!-- active_filter : the current filter key -->
|
||||
<!-- sorts : list of (key, label) tuples for the sort dropdown-->
|
||||
<!-- active_sort : the current sort key -->
|
||||
<!-- search : current search string (for prefilling the input) -->
|
||||
<!-- url : base path (e.g. '/my/jobs') -->
|
||||
<!-- extra_qs : optional extra query-string suffix -->
|
||||
<!-- target : CSS selector of the data-fp-filterable container -->
|
||||
<!-- result_total : total record count (for the ">500" clip notice) -->
|
||||
<!-- clipped : True if results were capped at 500 -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="fp_portal_list_controls" name="FP Portal List Controls">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3 o_fp_list_controls">
|
||||
<!-- Filter pills -->
|
||||
<div class="d-flex align-items-center gap-2" t-if="filters">
|
||||
<span class="text-muted small">Showing:</span>
|
||||
<t t-foreach="filters" t-as="f">
|
||||
<a t-attf-href="#{url}?filter_state=#{f[0]}&sortby=#{active_sort}#{extra_qs or ''}"
|
||||
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if active_filter == f[0] else ''}"
|
||||
t-out="f[1]"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Search input — real-time client-side filtering, no submit -->
|
||||
<div class="ms-auto d-flex align-items-center gap-2"
|
||||
t-att-style="'flex: 1 1 auto; max-width: 360px;' + ('' if filters else 'margin-left: 0 !important')">
|
||||
<input type="search"
|
||||
class="form-control form-control-sm o_fp_list_search"
|
||||
placeholder="Search…"
|
||||
t-att-value="search or ''"
|
||||
t-att-data-fp-target="target"
|
||||
autocomplete="off"/>
|
||||
</div>
|
||||
|
||||
<!-- Sort dropdown — navigates on change (wired by fp_portal_list_search.js) -->
|
||||
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 180px" t-if="sorts">
|
||||
<t t-foreach="sorts" t-as="s">
|
||||
<option t-att-value="url + '?filter_state=' + (active_filter or 'all') + '&sortby=' + s[0] + (extra_qs or '')"
|
||||
t-att-selected="active_sort == s[0]"
|
||||
t-out="s[1]"/>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Clip notice: shown server-side when >500 records were found -->
|
||||
<div class="o_fp_list_search_meta small text-muted mb-2" t-if="clipped">
|
||||
Showing latest 500 of <span t-out="result_total"/> — refine your filter to narrow further.
|
||||
</div>
|
||||
<!-- Live count: shown by JS while the user is typing in the search box -->
|
||||
<div class="o_fp_list_search_count small text-muted mb-2 d-none"/>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
|
||||
<!-- {label, sub, url, icon_class, pending} -->
|
||||
|
||||
115
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml
Normal file
115
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Wraps every /my/* page (FP custom + Odoo default) in the new
|
||||
sidebar shell. Inherits portal.portal_layout so we don't have
|
||||
to edit every individual page template.
|
||||
|
||||
Implementation note (Approach D, $0 re-emit):
|
||||
|
||||
The plan originally proposed injecting an unbalanced main opening
|
||||
tag in one xpath block and its closing tag in another. QWeb parses
|
||||
each xpath payload as an independent XML fragment, so unbalanced
|
||||
tags are rejected at load time.
|
||||
|
||||
Instead we use position="replace" on //div[@id='wrap'] with $0
|
||||
inside the replacement payload. $0 is supported by Odoo 19's
|
||||
Python view inheritance engine (tools/template_inheritance.py,
|
||||
lines 162-169): any element whose text content is the literal
|
||||
string "$0" has its text cleared and the deep-copied original node
|
||||
appended as a child. This produces a fully balanced replacement tree
|
||||
that nests the original #wrap (and all its Odoo-managed content)
|
||||
inside our .o_fp_portal_main element.
|
||||
|
||||
Verified from portal_templates.xml line 155:
|
||||
div id="wrap" class="o_portal_wrap"
|
||||
div class="container pt-3 pb-5"
|
||||
t t-out="0" (Odoo content slot)
|
||||
/div
|
||||
/div
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="fp_portal_shell"
|
||||
name="FP Portal Shell — Sidebar Wrap"
|
||||
inherit_id="portal.portal_layout"
|
||||
priority="50">
|
||||
<!-- Force Odoo's outer breadcrumb container to render even when a page
|
||||
sets breadcrumbs_searchbar=True (e.g. /my/orders, /my/invoices, and
|
||||
other stock Odoo list pages). Keeps the breadcrumb consistently above
|
||||
our shell, never inside the right column. We still respect
|
||||
no_breadcrumbs and my_details (used by /my/account). -->
|
||||
<xpath expr="//div[hasclass('o_portal') and hasclass('container') and hasclass('mt-3')]" position="attributes">
|
||||
<attribute name="t-if">not no_breadcrumbs and not my_details</attribute>
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
Replace #wrap entirely. The $0 text node inside
|
||||
<main class="o_fp_portal_main"> causes Odoo's inheritance
|
||||
engine to re-emit the original #wrap div (with all its
|
||||
children) at that position. Every existing portal page
|
||||
continues to render correctly because Odoo's <t t-out="0"/>
|
||||
content slot inside #wrap is preserved verbatim.
|
||||
-->
|
||||
<xpath expr="//div[@id='wrap']" position="replace">
|
||||
<div class="o_fp_portal_shell">
|
||||
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
|
||||
<button type="button"
|
||||
class="o_fp_portal_hamburger d-md-none"
|
||||
aria-label="Open navigation">
|
||||
<i class="fa fa-bars"/>
|
||||
</button>
|
||||
<!-- Backdrop for mobile drawer (hidden by default) -->
|
||||
<div class="o_fp_portal_backdrop"/>
|
||||
<!-- Sidebar navigation component -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
|
||||
<!-- Main content area — original #wrap re-emitted here via $0 -->
|
||||
<main class="o_fp_portal_main">$0</main>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Sidebar template — rendered by fp_portal_shell -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
|
||||
<aside class="o_fp_portal_sidebar">
|
||||
<!-- Partner display name header -->
|
||||
<div class="o_fp_sidebar_header">
|
||||
<t t-out="fp_partner_display_name or 'My Account'"/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation items, walked from the Python-side data structure.
|
||||
fp_sidebar_items is injected by the controller mixin in Task 4.
|
||||
Guards here handle the case where Task 4 hasn't deployed yet. -->
|
||||
<t t-foreach="fp_sidebar_items or []" t-as="entry">
|
||||
<!-- Section labels render as non-link headers -->
|
||||
<t t-if="entry.get('type') == 'section_label'">
|
||||
<div class="o_fp_sidebar_section_label" t-out="entry.get('label', '')"/>
|
||||
</t>
|
||||
<!-- Items render as anchor links -->
|
||||
<t t-elif="entry.get('type') == 'item'">
|
||||
<a t-att-href="entry.get('url', '#')"
|
||||
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
|
||||
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '•'"/>
|
||||
<span t-out="entry.get('label', '')"/>
|
||||
</a>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Footer: sign out link always present -->
|
||||
<div class="o_fp_sidebar_footer">
|
||||
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
|
||||
<span class="o_fp_sidebar_icon">↪</span>
|
||||
<span>Sign Out</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -7,24 +7,27 @@
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- QUOTE REQUESTS — list with tabs (Active / Converted / Declined) -->
|
||||
<!-- QUOTE REQUESTS — list with filter pills + real-time search -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_quote_requests" name="My Quote Requests">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Quote Requests</t>
|
||||
</t>
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item" t-foreach="searchbar_filters" t-as="f">
|
||||
<a t-attf-class="nav-link #{'active' if filterby == f else ''}"
|
||||
t-attf-href="/my/quote_requests?filterby=#{f}&sortby=#{sortby}">
|
||||
<t t-out="searchbar_filters[f]['label']"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Filter pills + search + sort strip -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||
<t t-set="filters" t-value="filters"/>
|
||||
<t t-set="active_filter" t-value="filter_state"/>
|
||||
<t t-set="sorts" t-value="sorts"/>
|
||||
<t t-set="active_sort" t-value="sortby"/>
|
||||
<t t-set="search" t-value="search"/>
|
||||
<t t-set="url" t-value="url"/>
|
||||
<t t-set="extra_qs" t-value="extra_qs"/>
|
||||
<t t-set="target" t-value="target"/>
|
||||
<t t-set="result_total" t-value="result_total"/>
|
||||
<t t-set="clipped" t-value="clipped"/>
|
||||
</t>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a href="/my/quote_requests/new" class="o_fp_btn_primary">
|
||||
@@ -50,7 +53,7 @@
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="o_fp_qr_filterable">
|
||||
<tr t-foreach="quote_requests" t-as="qr">
|
||||
<td>
|
||||
<a t-att-href="'/my/quote_requests/%s' % qr.id"
|
||||
@@ -74,6 +77,16 @@
|
||||
<t t-set="label" t-value="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
|
||||
</t>
|
||||
</td>
|
||||
<!-- Hidden search fields: contact, part numbers, descriptions -->
|
||||
<td class="d-none" aria-hidden="true">
|
||||
<span t-out="qr.contact_name or ''"/>
|
||||
<span t-out="qr.contact_email or ''"/>
|
||||
<t t-foreach="qr.line_ids" t-as="ln">
|
||||
<span t-out="ln.part_number or ''"/>
|
||||
<span t-out="ln.description or ''"/>
|
||||
<span t-if="ln.product_id" t-out="ln.product_id.default_code or ''"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
@@ -418,15 +431,28 @@
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- JOBS — list with segmented progress bars -->
|
||||
<!-- JOBS — list with filter pills + real-time search (cards layout) -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_jobs" name="My Work Orders">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Work Orders</t>
|
||||
</t>
|
||||
|
||||
<!-- Filter pills + search + sort strip -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||
<t t-set="filters" t-value="filters"/>
|
||||
<t t-set="active_filter" t-value="filter_state"/>
|
||||
<t t-set="sorts" t-value="sorts"/>
|
||||
<t t-set="active_sort" t-value="sortby"/>
|
||||
<t t-set="search" t-value="search"/>
|
||||
<t t-set="url" t-value="url"/>
|
||||
<t t-set="extra_qs" t-value="extra_qs"/>
|
||||
<t t-set="target" t-value="target"/>
|
||||
<t t-set="result_total" t-value="result_total"/>
|
||||
<t t-set="clipped" t-value="clipped"/>
|
||||
</t>
|
||||
|
||||
<t t-if="not jobs">
|
||||
<div class="o_fp_card text-center text-muted">
|
||||
<p class="mb-2">You have no plating jobs yet.</p>
|
||||
@@ -434,36 +460,32 @@
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="jobs">
|
||||
<div class="o_fp_dashboard">
|
||||
<!-- id="fp_jobs_list" is the data-fp-target for the search JS -->
|
||||
<div class="o_fp_dashboard" id="fp_jobs_list">
|
||||
<t t-foreach="jobs" t-as="job">
|
||||
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
|
||||
<div class="o_fp_job_header">
|
||||
<div>
|
||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||
<span class="o_fp_job_meta">
|
||||
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
||||
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
||||
</span>
|
||||
</div>
|
||||
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
||||
<t t-set="state" t-value="job.state"/>
|
||||
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||
<!-- Wrapper div is the filterable row unit.
|
||||
Hidden span carries extra search terms that
|
||||
are not visible in the card UI. -->
|
||||
<div class="o_fp_job_card_wrap">
|
||||
<t t-call="fusion_plating_portal.fp_portal_job_card">
|
||||
<t t-set="job" t-value="job"/>
|
||||
</t>
|
||||
<!-- Extra hidden search terms for this card -->
|
||||
<t t-set="_backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
|
||||
<t t-set="_so" t-value="_backend_job.sale_order_id if _backend_job and 'sale_order_id' in _backend_job._fields else False"/>
|
||||
<t t-set="_part" t-value="_backend_job.part_catalog_id if _backend_job and 'part_catalog_id' in _backend_job._fields else False"/>
|
||||
<span class="d-none" aria-hidden="true">
|
||||
<t t-if="_part">
|
||||
<t t-out="_part.part_number or ''"/>
|
||||
<t t-out="_part.name or ''"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- State -> active step index map (same as dashboard) -->
|
||||
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
||||
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
||||
<t t-set="steps" t-value="[
|
||||
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
||||
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
||||
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
||||
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
||||
]"/>
|
||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
||||
</a>
|
||||
<t t-if="_so">
|
||||
<t t-out="_so.name or ''"/>
|
||||
<t t-out="_so.client_order_ref or ''"/>
|
||||
</t>
|
||||
<t t-out="job.notes or ''"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
@@ -477,15 +499,28 @@
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_fp_job_detail">
|
||||
|
||||
<!-- Hero header -->
|
||||
<!-- Hero header: WO ref + part + ship-to + key facts -->
|
||||
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
|
||||
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
|
||||
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
|
||||
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
|
||||
|
||||
<div class="o_fp_job_detail_hero">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="o_fp_detail_label">Work Order</div>
|
||||
<h2><span t-out="job.name"/></h2>
|
||||
<div t-if="job.process_type_ids" class="o_fp_detail_subtitle">
|
||||
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
||||
</div>
|
||||
<t t-if="part">
|
||||
<div class="o_fp_detail_subtitle">
|
||||
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
|
||||
<t t-out="part.name or part.display_name"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="job.process_type_ids">
|
||||
<div class="o_fp_detail_subtitle">
|
||||
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_fp_detail_facts">
|
||||
<div t-if="job.quantity">
|
||||
<span class="o_fp_fact_label">Qty </span>
|
||||
@@ -501,7 +536,23 @@
|
||||
</div>
|
||||
<div t-if="job.tracking_ref">
|
||||
<span class="o_fp_fact_label">Tracking </span>
|
||||
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
|
||||
<span class="o_fp_fact_value">
|
||||
<a t-if="job.x_fc_tracking_url"
|
||||
t-att-href="job.x_fc_tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</a>
|
||||
<t t-else="">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<div t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
|
||||
<span class="o_fp_fact_label">Ship to </span>
|
||||
<span class="o_fp_fact_value">
|
||||
<t t-out="ship_to.name"/><t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,6 +604,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking history (if shipment has events) -->
|
||||
<div t-if="job.x_fc_tracking_event_ids" class="o_fp_card"
|
||||
style="margin-top:1.25rem">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div style="font-weight:600;color:#111827;font-size:1rem">
|
||||
Tracking History
|
||||
</div>
|
||||
<span t-if="job.tracking_ref"
|
||||
style="font-size:.75rem;color:#6b7280;font-family:monospace">
|
||||
<a t-if="job.x_fc_tracking_url"
|
||||
t-att-href="job.x_fc_tracking_url"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</a>
|
||||
<t t-else="" t-out="job.tracking_ref"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_timeline">
|
||||
<t t-foreach="job.x_fc_tracking_event_ids" t-as="evt">
|
||||
<div class="o_fp_timeline_item o_fp_timeline_done">
|
||||
<div class="o_fp_timeline_dot">●</div>
|
||||
<div class="o_fp_timeline_title"
|
||||
t-out="evt.event_description or 'Tracking update'"/>
|
||||
<div class="o_fp_timeline_time">
|
||||
<t t-if="evt.event_datetime"
|
||||
t-out="evt.event_datetime"
|
||||
t-options='{"widget": "datetime"}'/>
|
||||
<t t-elif="evt.event_date"
|
||||
t-out="evt.event_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
<t t-if="evt.event_site">
|
||||
<span style="color:#9ca3af"> ·
|
||||
<t t-out="evt.event_site"/>
|
||||
<t t-if="evt.event_province">,
|
||||
<t t-out="evt.event_province"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer notes (if any) -->
|
||||
<div t-if="job.notes" class="o_fp_card" style="margin-top:1.25rem">
|
||||
<div style="font-weight:600;color:#111827;font-size:1rem;margin-bottom:.6rem">Notes</div>
|
||||
@@ -582,107 +677,28 @@
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- PURCHASE ORDERS — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_purchase_orders" name="My Purchase Orders">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Purchase Orders</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not orders">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No purchase orders found.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="orders" t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Order</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="orders" t-as="order">
|
||||
<td t-out="order.name"/>
|
||||
<td>
|
||||
<span t-field="order.date_order" t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="order.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- INVOICES — list -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_fp_invoices" name="My Invoices">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Invoices</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not invoices">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No invoices found.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="invoices" t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Invoice</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th class="text-end">Amount Due</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="invoices" t-as="inv">
|
||||
<td t-out="inv.name"/>
|
||||
<td>
|
||||
<span t-if="inv.invoice_date"
|
||||
t-field="inv.invoice_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="inv.invoice_date_due"
|
||||
t-field="inv.invoice_date_due"
|
||||
t-options='{"widget": "date"}'/>
|
||||
<span t-else="" class="text-muted">--</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="inv.amount_residual"
|
||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="inv.amount_total"
|
||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- DELIVERIES / PACKING SLIPS — list -->
|
||||
<!-- DELIVERIES / PACKING SLIPS — list with search + sort -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_deliveries" name="My Deliveries">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Packing Slips / Deliveries</t>
|
||||
</t>
|
||||
|
||||
<!-- Search + sort strip (no filter pills — all rows are delivered) -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||
<t t-set="filters" t-value="filters"/>
|
||||
<t t-set="active_filter" t-value="filter_state"/>
|
||||
<t t-set="sorts" t-value="sorts"/>
|
||||
<t t-set="active_sort" t-value="sortby"/>
|
||||
<t t-set="search" t-value="search"/>
|
||||
<t t-set="url" t-value="url"/>
|
||||
<t t-set="extra_qs" t-value="extra_qs"/>
|
||||
<t t-set="target" t-value="target"/>
|
||||
<t t-set="result_total" t-value="result_total"/>
|
||||
<t t-set="clipped" t-value="clipped"/>
|
||||
</t>
|
||||
|
||||
<t t-if="not deliveries">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No deliveries found.</p>
|
||||
@@ -692,13 +708,17 @@
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Reference</th>
|
||||
<th>Origin</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="o_fp_deliveries_filterable">
|
||||
<tr t-foreach="deliveries" t-as="dlv">
|
||||
<td t-out="dlv.name"/>
|
||||
<td>
|
||||
<span t-out="dlv.origin or ''"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="dlv.date_done"
|
||||
t-field="dlv.date_done"
|
||||
@@ -709,6 +729,13 @@
|
||||
<span class="o_fp_badge_dot"/>Delivered
|
||||
</span>
|
||||
</td>
|
||||
<!-- Hidden: partner ref / customer PO on the origin SO -->
|
||||
<td class="d-none" aria-hidden="true">
|
||||
<t t-if="dlv.sale_id">
|
||||
<span t-out="dlv.sale_id.name or ''"/>
|
||||
<span t-out="dlv.sale_id.client_order_ref or ''"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
@@ -716,15 +743,28 @@
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- CERTIFICATIONS — list -->
|
||||
<!-- CERTIFICATIONS — list with search + sort -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_certifications" name="My Certifications">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Certifications & Quality</t>
|
||||
</t>
|
||||
|
||||
<!-- Search + sort strip (no filter pills — all certs are terminal) -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||
<t t-set="filters" t-value="filters"/>
|
||||
<t t-set="active_filter" t-value="filter_state"/>
|
||||
<t t-set="sorts" t-value="sorts"/>
|
||||
<t t-set="active_sort" t-value="sortby"/>
|
||||
<t t-set="search" t-value="search"/>
|
||||
<t t-set="url" t-value="url"/>
|
||||
<t t-set="extra_qs" t-value="extra_qs"/>
|
||||
<t t-set="target" t-value="target"/>
|
||||
<t t-set="result_total" t-value="result_total"/>
|
||||
<t t-set="clipped" t-value="clipped"/>
|
||||
</t>
|
||||
|
||||
<t t-if="not cert_jobs">
|
||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
||||
<p class="text-muted mb-0">No certificates available yet.</p>
|
||||
@@ -739,7 +779,7 @@
|
||||
<th class="text-end">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="o_fp_certs_filterable">
|
||||
<tr t-foreach="cert_jobs" t-as="cj">
|
||||
<td>
|
||||
<a t-att-href="'/my/jobs/%s' % cj.id" t-out="cj.name"/>
|
||||
@@ -760,6 +800,19 @@
|
||||
<i class="fa fa-download"/> CoC
|
||||
</a>
|
||||
</td>
|
||||
<!-- Hidden: part name, customer PO from the backend job -->
|
||||
<td class="d-none" aria-hidden="true">
|
||||
<t t-set="_bj" t-value="cj.x_fc_job_id if 'x_fc_job_id' in cj._fields else False"/>
|
||||
<t t-set="_so" t-value="_bj.sale_order_id if _bj and 'sale_order_id' in _bj._fields else False"/>
|
||||
<t t-set="_part" t-value="_bj.part_catalog_id if _bj and 'part_catalog_id' in _bj._fields else False"/>
|
||||
<t t-if="_part">
|
||||
<span t-out="_part.part_number or ''"/>
|
||||
<span t-out="_part.name or ''"/>
|
||||
</t>
|
||||
<t t-if="_so">
|
||||
<span t-out="_so.client_order_ref or ''"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
|
||||
@@ -14,6 +14,41 @@
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- /my/orders — inject filter strip + data-fp-filterable before the -->
|
||||
<!-- Odoo portal table so real-time search works client-side. -->
|
||||
<!-- Sort dropdown reuses Odoo's existing sortby param; filter pills -->
|
||||
<!-- link to URL params that Odoo's stock route honours natively. -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="portal_my_orders_fp_search"
|
||||
inherit_id="sale.portal_my_orders"
|
||||
priority="50">
|
||||
|
||||
<!-- Inject the controls strip right after the portal_searchbar call
|
||||
and before the "no orders" alert / portal_table. The anchor is
|
||||
the <div t-if="not orders"> alert — we inject before it. -->
|
||||
<xpath expr="//div[@t-if='not orders']" position="before">
|
||||
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||
<t t-set="filters" t-value="False"/>
|
||||
<t t-set="active_filter" t-value="'all'"/>
|
||||
<t t-set="sorts" t-value="False"/>
|
||||
<t t-set="active_sort" t-value="'date'"/>
|
||||
<t t-set="search" t-value="''"/>
|
||||
<t t-set="url" t-value="'/my/orders'"/>
|
||||
<t t-set="extra_qs" t-value="''"/>
|
||||
<!-- Odoo's portal.portal_table emits a <table class="o_portal_my_doc_table">
|
||||
so we don't need to add our own id; the JS just needs a stable selector. -->
|
||||
<t t-set="target" t-value="'.o_portal_my_doc_table tbody'"/>
|
||||
<t t-set="result_total" t-value="len(orders) if orders else 0"/>
|
||||
<t t-set="clipped" t-value="False"/>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Sale order portal content: add Part # column -->
|
||||
<!-- ================================================================== -->
|
||||
<template id="sale_order_portal_content_fp_part_column"
|
||||
inherit_id="sale.sale_order_portal_content">
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.8.0',
|
||||
'version': '19.0.3.18.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -29,17 +29,23 @@ Provides:
|
||||
'price': 0.00,
|
||||
'currency': 'CAD',
|
||||
'depends': [
|
||||
'delivery',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_shipping',
|
||||
'sale_management',
|
||||
'stock',
|
||||
],
|
||||
'data': [
|
||||
'security/fp_receiving_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_receiving_sequence_data.xml',
|
||||
'data/delivery_carrier_seed_data.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_racking_inspection_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Seeds the 12 carrier records the plating shop uses that are NOT
|
||||
already provided by Odoo / fusion_shipping. All start with
|
||||
delivery_type='fixed'; Phase D will flip Purolator (and any others
|
||||
we add integrations for) to their real delivery_type.
|
||||
|
||||
noupdate=1 — these records are upserted once on install. Hand-edits
|
||||
on the carrier records (e.g. renaming "FedEx" to "FedEx Express")
|
||||
are preserved across module upgrades.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
<record id="delivery_carrier_ups" model="delivery.carrier">
|
||||
<field name="name">UPS</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_fedex" model="delivery.carrier">
|
||||
<field name="name">FedEx</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">21</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_usps" model="delivery.carrier">
|
||||
<field name="name">USPS</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">22</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_dhl" model="delivery.carrier">
|
||||
<field name="name">DHL</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">23</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_purolator" model="delivery.carrier">
|
||||
<field name="name">Purolator</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">24</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_cct" model="delivery.carrier">
|
||||
<field name="name">CCT</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_canpar" model="delivery.carrier">
|
||||
<field name="name">Canpar Express</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">26</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_gls_canada" model="delivery.carrier">
|
||||
<field name="name">GLS Canada</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">27</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_loomis" model="delivery.carrier">
|
||||
<field name="name">Loomis Express</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">28</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_day_ross" model="delivery.carrier">
|
||||
<field name="name">Day & Ross</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">29</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_dicom" model="delivery.carrier">
|
||||
<field name="name">Dicom Transportation</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_customer_dropoff" model="delivery.carrier">
|
||||
<field name="name">Customer Drop-off</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">31</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_local_delivery" model="delivery.carrier">
|
||||
<field name="name">Local Delivery</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">32</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Name-match existing fp_receiving.carrier_name → x_fc_carrier_id.
|
||||
|
||||
Phase A of the shipping integration replaces the free-text carrier
|
||||
field with a Many2one to delivery.carrier. Existing records (16 on
|
||||
entech at write time) have free-text values like "FedEx", "Purolator"
|
||||
in carrier_name. This migration walks them and populates the new M2O
|
||||
when a unique case-insensitive name match exists.
|
||||
|
||||
delivery.carrier.name is jsonb (translatable) in Odoo 19 — match
|
||||
strips to the en_US translation. Ambiguous values stay as text in
|
||||
carrier_name for the operator to pick manually.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Skip if the field doesn't exist yet (defensive — the column is
|
||||
# added by the registry update that runs before post-migrate).
|
||||
cr.execute("""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'fp_receiving'
|
||||
AND column_name = 'x_fc_carrier_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning('x_fc_carrier_id column not present — skip.')
|
||||
return
|
||||
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving r
|
||||
SET x_fc_carrier_id = dc.id
|
||||
FROM delivery_carrier dc
|
||||
WHERE r.carrier_name IS NOT NULL
|
||||
AND r.carrier_name <> ''
|
||||
AND r.x_fc_carrier_id IS NULL
|
||||
AND LOWER(TRIM(r.carrier_name)) =
|
||||
LOWER(TRIM((dc.name->>'en_US')))
|
||||
""")
|
||||
matched = cr.rowcount
|
||||
_logger.info(
|
||||
'Receiving carrier migration: matched %d record(s) by name.',
|
||||
matched,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Backfill missing part metadata + received_qty on fp.receiving.line.
|
||||
|
||||
A bug in fp.receiving auto-create (now fixed in
|
||||
fusion_plating_receiving/models/sale_order.py) read
|
||||
``order.x_fc_part_catalog_id`` (the rarely-populated SO header field)
|
||||
instead of ``line.x_fc_part_catalog_id`` (the authoritative per-line
|
||||
field), leaving every auto-generated receiving line with an empty
|
||||
``part_number`` and ``part_catalog_id``. Same auto-create also forgot
|
||||
to prefill ``received_qty``.
|
||||
|
||||
This migration walks existing receiving records and rebuilds the line
|
||||
metadata from the linked SO's order lines via position-based zip — only
|
||||
when the receiving line count matches the SO line count (otherwise the
|
||||
mapping isn't safe and we leave the record alone for manual review).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Find candidates: receiving lines with empty part_catalog_id AND
|
||||
# empty part_number, scoped to receivings with a linked SO.
|
||||
cr.execute("""
|
||||
SELECT r.id AS receiving_id,
|
||||
r.sale_order_id AS so_id,
|
||||
array_agg(rl.id ORDER BY rl.id) AS line_ids
|
||||
FROM fp_receiving r
|
||||
JOIN fp_receiving_line rl ON rl.receiving_id = r.id
|
||||
WHERE r.sale_order_id IS NOT NULL
|
||||
AND (rl.part_catalog_id IS NULL
|
||||
AND (rl.part_number IS NULL OR rl.part_number = ''))
|
||||
GROUP BY r.id, r.sale_order_id
|
||||
""")
|
||||
candidates = cr.fetchall()
|
||||
if not candidates:
|
||||
_logger.info('Receiving line backfill: no candidates.')
|
||||
return
|
||||
|
||||
fixed = 0
|
||||
skipped = 0
|
||||
for receiving_id, so_id, recv_line_ids in candidates:
|
||||
# Pull the SO's order lines in stable order.
|
||||
cr.execute("""
|
||||
SELECT id, x_fc_part_catalog_id, product_uom_qty, name
|
||||
FROM sale_order_line
|
||||
WHERE order_id = %s
|
||||
ORDER BY sequence, id
|
||||
""", (so_id,))
|
||||
so_lines = cr.fetchall()
|
||||
if len(so_lines) != len(recv_line_ids):
|
||||
# Mismatch — don't risk corrupting a non-trivial mapping.
|
||||
skipped += 1
|
||||
continue
|
||||
# Receiving lines come ordered by id ascending (the create call
|
||||
# in sale_order.py emits them in order_line order, so id-order
|
||||
# = sequence-order on the SO side).
|
||||
for recv_line_id, (sol_id, part_id, qty, name) in zip(
|
||||
recv_line_ids, so_lines,
|
||||
):
|
||||
part_number = ''
|
||||
if part_id:
|
||||
cr.execute(
|
||||
"SELECT part_number FROM fp_part_catalog WHERE id = %s",
|
||||
(part_id,),
|
||||
)
|
||||
row = cr.fetchone()
|
||||
part_number = (row and row[0]) or ''
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving_line
|
||||
SET part_catalog_id = %s,
|
||||
part_number = %s,
|
||||
received_qty = COALESCE(NULLIF(received_qty, 0),
|
||||
%s)
|
||||
WHERE id = %s
|
||||
""", (
|
||||
part_id or None,
|
||||
part_number,
|
||||
int(qty or 0),
|
||||
recv_line_id,
|
||||
))
|
||||
fixed += 1
|
||||
_logger.info(
|
||||
'Receiving line backfill: fixed %d lines, skipped %d receivings '
|
||||
'(line-count mismatch).', fixed, skipped,
|
||||
)
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving_racking_link
|
||||
from . import sale_order
|
||||
from . import fusion_shipment
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Per-package row for outbound multi-piece shipments.
|
||||
|
||||
Each fp.receiving has zero-or-more fp.outbound.package rows. When the
|
||||
operator clicks Generate Outbound Label, one stock.package + one
|
||||
carrier label is generated per row.
|
||||
|
||||
Single-box scenario: the form auto-fills one row when the receiving's
|
||||
top-level weight/dim are set, so existing UX still works.
|
||||
Multi-box scenario: operator adds more rows. Each row gets its own
|
||||
tracking number + label PDF/ZPL stored back on the row after the API
|
||||
call returns.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpOutboundPackage(models.Model):
|
||||
_name = 'fp.outbound.package'
|
||||
_description = 'Fusion Plating — Outbound Package (per-box detail)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
weight = fields.Float(string='Weight', digits=(10, 3))
|
||||
length = fields.Float(string='Length', digits=(10, 2))
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
# Populated by the carrier API once Generate Label fires.
|
||||
tracking_number = fields.Char(readonly=True, copy=False)
|
||||
label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Label',
|
||||
ondelete='set null',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
# Computed convenience: filename of the label (for download UX).
|
||||
label_filename = fields.Char(
|
||||
related='label_attachment_id.name', readonly=True,
|
||||
)
|
||||
@@ -3,9 +3,15 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
"""Parts receiving record.
|
||||
@@ -70,8 +76,620 @@ class FpReceiving(models.Model):
|
||||
qty_match = fields.Boolean(
|
||||
string='Qty Match', compute='_compute_qty_match', store=True,
|
||||
)
|
||||
carrier_name = fields.Char(string='Carrier', help='Who delivered the parts (Purolator, customer drop-off, etc.).')
|
||||
carrier_name = fields.Char(
|
||||
string='Carrier (Legacy)',
|
||||
help='Legacy free-text carrier field. Kept for back-compat with '
|
||||
'records that predate the carrier_id M2O. New records use '
|
||||
'x_fc_carrier_id instead.',
|
||||
)
|
||||
carrier_tracking = fields.Char(string='Inbound Tracking #')
|
||||
|
||||
# ---- Phase A — outbound carrier + shipment link ----------------------
|
||||
# The receiver picks the OUTBOUND (return) carrier here; clicking
|
||||
# "Create Outbound Shipment" creates a draft fusion.shipment which
|
||||
# owns weight, dimensions, label PDF, tracking. The shop's workflow
|
||||
# generates the return label at receiving time so the printed label
|
||||
# can travel with the parts.
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
help='The shipment record carrying weight, dimensions, label PDF, '
|
||||
'and tracking. Created via the "Create Outbound Shipment" '
|
||||
'button on this form.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
x_fc_has_label = fields.Boolean(
|
||||
compute='_compute_x_fc_has_label',
|
||||
help='True when the linked outbound shipment has a label PDF '
|
||||
'attached. Drives the Print Label smart-button visibility.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id.label_attachment_id')
|
||||
def _compute_x_fc_has_label(self):
|
||||
for rec in self:
|
||||
rec.x_fc_has_label = bool(
|
||||
rec.x_fc_outbound_shipment_id
|
||||
and rec.x_fc_outbound_shipment_id.label_attachment_id
|
||||
)
|
||||
|
||||
# ---- Phase C — Outbound packaging fields -----------------------------
|
||||
# Operator enters these at receiving time so the shipping label can be
|
||||
# generated immediately. Pushed to the linked fusion.shipment when
|
||||
# action_generate_outbound_label fires.
|
||||
x_fc_weight = fields.Float(
|
||||
string='Weight', digits=(10, 3), tracking=True,
|
||||
help='Total package weight for outbound shipping. Used at label '
|
||||
'generation time.',
|
||||
)
|
||||
x_fc_weight_uom = fields.Selection(
|
||||
[('lb', 'lb'), ('kg', 'kg')],
|
||||
string='Weight UoM', default='lb', tracking=True,
|
||||
)
|
||||
x_fc_length = fields.Float(
|
||||
string='Length', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_width = fields.Float(
|
||||
string='Width', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_height = fields.Float(
|
||||
string='Height', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_dim_uom = fields.Selection(
|
||||
[('in', 'in'), ('cm', 'cm')],
|
||||
string='Dim UoM', default='in', tracking=True,
|
||||
)
|
||||
|
||||
# Back-link to the synthetic stock.picking used at API-call time.
|
||||
# Set by _fp_build_shipping_picking; kept for debugging / traceability.
|
||||
x_fc_shipping_picking_id = fields.Many2one(
|
||||
'stock.picking', string='Shipping Picking',
|
||||
readonly=True, copy=False,
|
||||
help='The internal picking record used to drive the carrier API '
|
||||
'call. Hidden from operator UIs; kept for traceability.',
|
||||
)
|
||||
|
||||
# Per-package detail for multi-piece shipments (MPS). Each row
|
||||
# produces one stock.package + one carrier label. Single-box flow
|
||||
# still works: when no rows are entered, _fp_build_shipping_picking
|
||||
# falls back to the receiving's top-level weight/dim fields.
|
||||
x_fc_outbound_package_ids = fields.One2many(
|
||||
'fp.outbound.package', 'receiving_id',
|
||||
string='Outbound Packages',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id')
|
||||
def _compute_x_fc_outbound_shipment_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_outbound_shipment_count = (
|
||||
1 if rec.x_fc_outbound_shipment_id else 0
|
||||
)
|
||||
|
||||
@api.onchange('x_fc_carrier_id')
|
||||
def _onchange_x_fc_carrier_id(self):
|
||||
"""Propagate carrier change to a linked DRAFT shipment.
|
||||
|
||||
Once a shipment is confirmed / shipped / delivered, we leave it
|
||||
alone — changing the carrier on a non-draft shipment is a
|
||||
destructive operation that needs explicit user intent (cancel +
|
||||
re-create), not a side-effect of editing the receiving form.
|
||||
"""
|
||||
for rec in self:
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
|
||||
ship.carrier_id = rec.x_fc_carrier_id.id
|
||||
|
||||
# ---- Actions ----------------------------------------------------------
|
||||
def action_create_outbound_shipment(self):
|
||||
"""Create a draft fusion.shipment linked to this receiving.
|
||||
|
||||
Idempotent: if a shipment is already linked, just open it.
|
||||
Pre-fills carrier_type, sender + recipient name/address, and
|
||||
service_type from the carrier's defaults so the operator never
|
||||
sees an empty form.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_outbound_shipment_id:
|
||||
return self.action_view_outbound_shipment()
|
||||
if 'fusion.shipment' not in self.env:
|
||||
raise UserError(_(
|
||||
'fusion_shipping module is not installed. '
|
||||
'Cannot create an outbound shipment.'
|
||||
))
|
||||
vals = {
|
||||
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
|
||||
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
|
||||
'status': 'draft',
|
||||
}
|
||||
vals.update(self._fp_resolve_shipment_defaults())
|
||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||
self.x_fc_outbound_shipment_id = shipment.id
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound shipment <b>%s</b> created (draft).'
|
||||
)) % shipment.name)
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def _fp_resolve_shipment_defaults(self):
|
||||
"""Build the dict of fusion.shipment field values that can be
|
||||
derived from the receiving's context (carrier, SO, company).
|
||||
Used at creation time and re-used by the generate-label flow
|
||||
to refresh fields if the operator changes carrier mid-flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
vals = {}
|
||||
carrier = self.x_fc_carrier_id
|
||||
# carrier_type — Selection on fusion.shipment ('canada_post',
|
||||
# 'ups_rest', 'fedex_rest', etc.). Map from delivery_type by
|
||||
# stripping the 'fusion_' prefix (e.g. 'fusion_fedex_rest' →
|
||||
# 'fedex_rest'). Selection on the model may not include every
|
||||
# value our delivery_type uses; defensive against missing keys.
|
||||
if carrier and carrier.delivery_type:
|
||||
dt = carrier.delivery_type
|
||||
ct = dt[len('fusion_'):] if dt.startswith('fusion_') else dt
|
||||
Ship = self.env.get('fusion.shipment')
|
||||
if Ship is not None:
|
||||
valid_types = dict(
|
||||
Ship._fields['carrier_type'].selection
|
||||
)
|
||||
if ct in valid_types:
|
||||
vals['carrier_type'] = ct
|
||||
# service_type — carrier-specific. FedEx REST stores it on
|
||||
# carrier.fedex_rest_service_type; UPS REST has its own field.
|
||||
# Read whichever attribute exists.
|
||||
if carrier:
|
||||
for attr in ('fedex_rest_service_type', 'ups_rest_service_type',
|
||||
'dhl_rest_service_type'):
|
||||
if attr in carrier._fields and carrier[attr]:
|
||||
vals['service_type'] = carrier[attr]
|
||||
break
|
||||
# Sender from company partner; recipient from SO shipping address.
|
||||
company_partner = self.env.company.partner_id
|
||||
vals['sender_name'] = company_partner.name or ''
|
||||
vals['sender_address'] = self._fp_format_address(company_partner)
|
||||
so = self.sale_order_id
|
||||
if so:
|
||||
recipient = so.partner_shipping_id or so.partner_id
|
||||
vals['recipient_name'] = recipient.name or ''
|
||||
vals['recipient_address'] = self._fp_format_address(recipient)
|
||||
return vals
|
||||
|
||||
def _fp_format_address(self, partner):
|
||||
"""Single-line address string for the shipment record.
|
||||
fusion.shipment.sender_address / recipient_address are plain
|
||||
Char; we just need a readable rendering."""
|
||||
if not partner:
|
||||
return ''
|
||||
parts = [partner.street, partner.street2, partner.city,
|
||||
partner.state_id.code if partner.state_id else False,
|
||||
partner.zip,
|
||||
partner.country_id.name if partner.country_id else False]
|
||||
return ', '.join(p for p in parts if p)
|
||||
|
||||
def action_view_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_outbound_shipment_id.name,
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': self.x_fc_outbound_shipment_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ---- Phase C — Generate Outbound Label -------------------------------
|
||||
def action_generate_outbound_label(self):
|
||||
"""One-button label generation.
|
||||
|
||||
Branches on carrier.delivery_type:
|
||||
- 'fixed' (no API integration): opens manual entry wizard.
|
||||
- 'fusion_*' (API integration): synthesizes a stock.picking,
|
||||
calls the existing carrier.<provider>_send_shipping method,
|
||||
copies the result back to the linked fusion.shipment.
|
||||
- On API exception: falls back to the manual wizard with the
|
||||
error message in the note field.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(note=_(
|
||||
'Carrier "%s" has no API integration configured. Enter '
|
||||
'the label PDF and tracking number below to record the '
|
||||
'shipment manually.'
|
||||
) % carrier.name)
|
||||
# Ensure the shipment exists before we attempt the API call.
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
self.action_create_outbound_shipment()
|
||||
# Push the packaging info onto the shipment so it's the source
|
||||
# of truth post-generation.
|
||||
self._fp_sync_packaging_to_shipment()
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping(picking)
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Receiving %s: outbound label API call failed: %s',
|
||||
self.name, e,
|
||||
)
|
||||
return self._fp_open_manual_label_wizard(note=_(
|
||||
'Carrier API call failed:\n %s\n\nEnter the label '
|
||||
'PDF and tracking number below to record the shipment '
|
||||
'manually.'
|
||||
) % str(e))
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def _fp_validate_label_inputs(self):
|
||||
"""Gate: required inputs before label generation."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_carrier_id:
|
||||
raise UserError(_(
|
||||
'Pick an Outbound Carrier before generating a label.'
|
||||
))
|
||||
if not self.x_fc_weight or self.x_fc_weight <= 0:
|
||||
raise UserError(_(
|
||||
'Enter the Weight before generating a label.'
|
||||
))
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_(
|
||||
'Receiving "%s" is not linked to a sale order — '
|
||||
'cannot generate a shipping label.'
|
||||
) % self.name)
|
||||
if not self.sale_order_id.partner_shipping_id \
|
||||
and not self.sale_order_id.partner_id:
|
||||
raise UserError(_(
|
||||
'Sale order has no shipping address. Set one on '
|
||||
'%s before generating a label.'
|
||||
) % self.sale_order_id.name)
|
||||
|
||||
def _fp_open_manual_label_wizard(self, note=''):
|
||||
"""Open the small manual-entry wizard for label PDF + tracking."""
|
||||
self.ensure_one()
|
||||
# Ensure the shipment exists so the wizard has a target to write to.
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
self.action_create_outbound_shipment()
|
||||
Wizard = self.env.get('fp.label.manual.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Manual label wizard is not installed. Upgrade '
|
||||
'fusion_plating_receiving.'
|
||||
))
|
||||
wiz = Wizard.create({
|
||||
'receiving_id': self.id,
|
||||
'note': note or '',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Enter Label Manually — %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _fp_sync_packaging_to_shipment(self):
|
||||
"""Copy weight + dimensions from the receiving to the linked
|
||||
fusion.shipment so the shipment record carries the values used
|
||||
for label generation."""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship:
|
||||
return
|
||||
vals = {}
|
||||
if self.x_fc_weight:
|
||||
vals['weight'] = self.x_fc_weight
|
||||
if 'x_fc_length' in ship._fields:
|
||||
if self.x_fc_length:
|
||||
vals['x_fc_length'] = self.x_fc_length
|
||||
if self.x_fc_width:
|
||||
vals['x_fc_width'] = self.x_fc_width
|
||||
if self.x_fc_height:
|
||||
vals['x_fc_height'] = self.x_fc_height
|
||||
if self.x_fc_dim_uom:
|
||||
vals['x_fc_dim_uom'] = self.x_fc_dim_uom
|
||||
if self.x_fc_weight_uom:
|
||||
vals['x_fc_weight_uom'] = self.x_fc_weight_uom
|
||||
if vals:
|
||||
ship.sudo().write(vals)
|
||||
|
||||
def _fp_build_shipping_picking(self):
|
||||
"""Synthesize a stock.picking just to carry the data needed by
|
||||
carrier.send_shipping. The picking is auto-validated to 'done'
|
||||
state so it doesn't sit as draft in operator views.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Picking = self.env['stock.picking'].sudo()
|
||||
warehouse = self.env['stock.warehouse'].sudo().search(
|
||||
[('company_id', '=', self.env.company.id)], limit=1,
|
||||
)
|
||||
if not warehouse:
|
||||
raise UserError(_(
|
||||
'No warehouse configured for the company. Configure '
|
||||
'one in Settings > Warehouses before generating labels.'
|
||||
))
|
||||
picking_type = warehouse.out_type_id
|
||||
if not picking_type:
|
||||
raise UserError(_(
|
||||
'Warehouse "%s" has no outgoing picking type.'
|
||||
) % warehouse.name)
|
||||
so = self.sale_order_id
|
||||
partner = so.partner_shipping_id or so.partner_id
|
||||
# Use the first SO line's product as the synthetic move's product
|
||||
# (carrier APIs read product info for dimensions / customs forms).
|
||||
product = (so.order_line and so.order_line[0].product_id) or self.env.ref(
|
||||
'product.product_product_4', raise_if_not_found=False,
|
||||
)
|
||||
if not product:
|
||||
raise UserError(_(
|
||||
'No product available to synthesize the shipping picking.'
|
||||
))
|
||||
picking = Picking.create({
|
||||
'partner_id': partner.id,
|
||||
'picking_type_id': picking_type.id,
|
||||
'origin': so.name,
|
||||
'sale_id': so.id,
|
||||
'carrier_id': self.x_fc_carrier_id.id,
|
||||
'move_ids': [(0, 0, {
|
||||
# Odoo 19 dropped stock.move.name; description_picking
|
||||
# replaces it (see CLAUDE.md "stock.move.name removed").
|
||||
'description_picking': 'Outbound %s' % (self.name or ''),
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': product.uom_id.id,
|
||||
'location_id': picking_type.default_location_src_id.id,
|
||||
'location_dest_id': picking_type.default_location_dest_id.id,
|
||||
})],
|
||||
})
|
||||
# Force the picking's weight so the API helper reads our value
|
||||
# instead of the computed (zero) weight from the synthetic move.
|
||||
if 'weight' in picking._fields:
|
||||
picking.write({'weight': self.x_fc_weight})
|
||||
# Confirm + assign so move_lines exist; we then pre-pack them
|
||||
# into one stock.package carrying the operator-entered weight +
|
||||
# the carrier's default package type. Without an explicit
|
||||
# package, _get_packages_from_picking falls back to weight_bulk
|
||||
# which reads from product.weight (always 0 for our synthetic
|
||||
# move) → FedEx rejects with "weight 0.0 lb". Setting
|
||||
# package_type_id makes DeliveryPackage.packaging_type resolve
|
||||
# to the carrier-specific shipper_package_code (e.g.
|
||||
# 'YOUR_PACKAGING' for FedEx).
|
||||
picking.action_confirm()
|
||||
try:
|
||||
picking.action_assign()
|
||||
except Exception:
|
||||
pass
|
||||
Package = self.env.get('stock.package')
|
||||
if Package is not None and picking.move_line_ids:
|
||||
default_pkg_type = self._fp_resolve_carrier_default_package_type()
|
||||
# Build the list of (weight, dimensions) tuples — one per
|
||||
# outbound package. Multi-piece shipments use the per-row
|
||||
# data from x_fc_outbound_package_ids; single-piece falls
|
||||
# back to the receiving's top-level weight/dim fields.
|
||||
rows = self.x_fc_outbound_package_ids.filtered(
|
||||
lambda r: (r.weight or 0) > 0
|
||||
)
|
||||
if not rows:
|
||||
# Synthesize one virtual row from the top-level fields.
|
||||
rows = [type('Row', (), {
|
||||
'weight': self.x_fc_weight,
|
||||
'length': self.x_fc_length,
|
||||
'width': self.x_fc_width,
|
||||
'height': self.x_fc_height,
|
||||
'id': False,
|
||||
})()]
|
||||
ml = picking.move_line_ids[0]
|
||||
packages = Package
|
||||
for row in rows:
|
||||
pkg_vals = {'shipping_weight': row.weight or 0}
|
||||
if default_pkg_type:
|
||||
pkg_vals['package_type_id'] = default_pkg_type.id
|
||||
pkg = Package.sudo().create(pkg_vals)
|
||||
packages |= pkg
|
||||
# Spread move_line qty across packages via result_package_id.
|
||||
# Stock's pack flow allows multiple move lines, but our move
|
||||
# has a single line with qty=1. For multi-box, we split the
|
||||
# move_line by creating extra lines (one per package).
|
||||
if len(packages) == 1:
|
||||
ml.result_package_id = packages[0].id
|
||||
else:
|
||||
# First package keeps the existing move_line.
|
||||
ml.result_package_id = packages[0].id
|
||||
Move = picking.move_ids[0] if picking.move_ids else False
|
||||
if Move:
|
||||
MoveLine = self.env['stock.move.line'].sudo()
|
||||
for pkg in packages[1:]:
|
||||
MoveLine.create({
|
||||
'move_id': Move.id,
|
||||
'picking_id': picking.id,
|
||||
'product_id': Move.product_id.id,
|
||||
'product_uom_id': Move.product_uom.id,
|
||||
'quantity': 1,
|
||||
'location_id': Move.location_id.id,
|
||||
'location_dest_id': Move.location_dest_id.id,
|
||||
'result_package_id': pkg.id,
|
||||
})
|
||||
# Stash packages on the picking via a transient attr so
|
||||
# _fp_apply_shipping_result can walk them in the same order
|
||||
# the API processes them (FedEx returns labels in the
|
||||
# order packages were submitted).
|
||||
picking._fp_outbound_packages = packages
|
||||
self.x_fc_shipping_picking_id = picking.id
|
||||
return picking
|
||||
|
||||
def _fp_resolve_carrier_default_package_type(self):
|
||||
"""Return the stock.package.type to use for the synthetic
|
||||
outbound package. Reads the carrier's per-provider default
|
||||
(e.g. fedex_rest_default_package_type_id). Returns False when
|
||||
no default is configured — the API call will then fail with a
|
||||
clear PACKAGINGTYPE error pointing the admin at the setup.
|
||||
"""
|
||||
self.ensure_one()
|
||||
carrier = self.x_fc_carrier_id
|
||||
if not carrier:
|
||||
return False
|
||||
# Field name pattern is <delivery_type>_default_package_type_id
|
||||
# for the FedEx REST / UPS REST / etc. integrations.
|
||||
field_name = '%s_default_package_type_id' % (
|
||||
carrier.delivery_type or ''
|
||||
)
|
||||
# Strip the 'fusion_' prefix used by fusion_shipping.
|
||||
if field_name.startswith('fusion_'):
|
||||
field_name = field_name[len('fusion_'):]
|
||||
if field_name in carrier._fields:
|
||||
return carrier[field_name]
|
||||
return False
|
||||
|
||||
def _fp_apply_shipping_result(self, picking, shipping_data):
|
||||
"""Copy tracking + label(s) from the picking back to the linked
|
||||
fusion.shipment AND to the per-package rows for multi-piece
|
||||
shipments. shipping_data is the list returned by
|
||||
carrier.send_shipping — `[{exact_price, tracking_number}, ...]`,
|
||||
one dict per package, in submission order.
|
||||
|
||||
Multi-piece (MPS): walks shipping_data alongside the picking's
|
||||
packages and writes per-package tracking + label_attachment back
|
||||
onto the matching fp.outbound.package row. The shipment-level
|
||||
tracking_number stores the first package's tracking (so the
|
||||
chatter / portal / notification still has a single primary ref).
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship:
|
||||
return
|
||||
# All label attachments uploaded to the picking by the upstream
|
||||
# send_shipping. PDF for PDF mode, application/zpl-ish for ZPLII.
|
||||
# We accept any attachment created on this picking by the API
|
||||
# call (the upstream code uses message_post which creates them).
|
||||
label_atts = self.env['ir.attachment'].sudo().search([
|
||||
('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id),
|
||||
], order='id asc')
|
||||
# Per-package shipping_data list — one entry per package.
|
||||
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
||||
shipping_data
|
||||
]
|
||||
# Pair rows with their results. If user didn't enter per-row
|
||||
# data, fall back to a single virtual row scenario (no rows to
|
||||
# write back to).
|
||||
rows = self.x_fc_outbound_package_ids.filtered(
|
||||
lambda r: (r.weight or 0) > 0
|
||||
)
|
||||
# Walk both lists in parallel; carrier returns one tracking +
|
||||
# label per package in submission order. Some carriers return
|
||||
# one combined tracking_ref split by '+' — handle both.
|
||||
primary_tracking = ''
|
||||
per_pkg_trackings = []
|
||||
for sd in sd_list:
|
||||
tn = sd.get('tracking_number') or ''
|
||||
for part in tn.split('+'):
|
||||
if part:
|
||||
per_pkg_trackings.append(part)
|
||||
if not per_pkg_trackings and 'carrier_tracking_ref' in picking._fields:
|
||||
for part in (picking.carrier_tracking_ref or '').split('+'):
|
||||
if part:
|
||||
per_pkg_trackings.append(part)
|
||||
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
|
||||
# Write per-row labels + tracking. Attachments are paired by
|
||||
# index — N labels and N rows. Excess on either side is ignored.
|
||||
for idx, row in enumerate(rows):
|
||||
row_vals = {}
|
||||
if idx < len(per_pkg_trackings):
|
||||
row_vals['tracking_number'] = per_pkg_trackings[idx]
|
||||
if idx < len(label_atts):
|
||||
row_vals['label_attachment_id'] = label_atts[idx].id
|
||||
if row_vals:
|
||||
row.sudo().write(row_vals)
|
||||
# Shipment-level fields. Primary label = first attachment; mirror
|
||||
# all labels onto x_fc_label_attachment_ids for the multi-print UX.
|
||||
vals = {'status': 'confirmed'}
|
||||
if primary_tracking:
|
||||
vals['tracking_number'] = primary_tracking
|
||||
if label_atts:
|
||||
vals['label_attachment_id'] = label_atts[0].id
|
||||
if 'x_fc_label_attachment_ids' in ship._fields:
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
|
||||
# Link the synthetic stock.picking so the Transfer field shows
|
||||
# it on the shipment form. Also refresh sender/recipient/carrier
|
||||
# defaults in case the operator changed carrier between create
|
||||
# and generate.
|
||||
if 'picking_id' in ship._fields:
|
||||
vals['picking_id'] = picking.id
|
||||
for k, v in self._fp_resolve_shipment_defaults().items():
|
||||
# Only fill if blank; never overwrite an operator edit.
|
||||
if not ship[k]:
|
||||
vals[k] = v
|
||||
ship.sudo().write(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound label generated. Tracking: <b>%s</b>'
|
||||
)) % (tracking_number or '(see attached PDF)'))
|
||||
# Validate the synthetic picking so it lands in 'done' state
|
||||
# instead of sitting at 'ready'. The shipping label is the proof
|
||||
# of dispatch — keeping the picking open misleads anyone looking
|
||||
# at the warehouse view. Wrapped in try/except so any quirk in
|
||||
# the validation flow (e.g. zero on-hand stock) doesn't block
|
||||
# the label generation success path.
|
||||
if picking and picking.state not in ('done', 'cancel'):
|
||||
try:
|
||||
# skip_sms = bypass the SMS-on-delivery confirm wizard
|
||||
# (stock_sms intercepts button_validate otherwise).
|
||||
# skip_backorder = no backorder dialog when qty doesn't
|
||||
# reconcile (won't on a synthetic picking with no stock).
|
||||
# skip_immediate = bypass the immediate-transfer prompt.
|
||||
result = picking.with_context(
|
||||
skip_immediate=True,
|
||||
skip_backorder=True,
|
||||
skip_sms=True,
|
||||
).button_validate()
|
||||
# If button_validate still returned an action (a wizard
|
||||
# popped up despite the context flags), log and move on
|
||||
# — the label is already saved; manual validation later
|
||||
# is fine.
|
||||
if isinstance(result, dict) and result.get('res_model'):
|
||||
_logger.info(
|
||||
'Receiving %s: button_validate returned a wizard '
|
||||
'(%s); leaving picking %s in state %s.',
|
||||
self.name,
|
||||
result.get('res_model'),
|
||||
picking.name,
|
||||
picking.state,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Receiving %s: failed to auto-validate picking %s: %s',
|
||||
self.name, picking.name, e,
|
||||
)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the label PDF for printing.
|
||||
|
||||
Returns the standard Odoo download action so the operator can
|
||||
print from their browser. Phase F replaces this with auto-print
|
||||
to a network printer.
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship or not ship.label_attachment_id:
|
||||
raise UserError(_(
|
||||
'No outbound shipping label on this receiving. '
|
||||
'Generate the label first.'
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
|
||||
'target': 'new',
|
||||
}
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Sub 12 audit fix — discoverable handoff from fp.receiving (boxes
|
||||
# counted) to fp.racking.inspection (parts inspected by the racking
|
||||
# crew). The racking inspection is auto-created on fp.job.action_confirm
|
||||
# but until now there was no smart-button on the receiving form to find
|
||||
# it — racking crew had to navigate via a separate menu.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class FpReceivingRackingLink(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
racking_inspection_count = fields.Integer(
|
||||
string='Racking Inspections', compute='_compute_racking_inspection_count',
|
||||
)
|
||||
|
||||
def _compute_racking_inspection_count(self):
|
||||
Inspection = self.env['fp.racking.inspection'] \
|
||||
if 'fp.racking.inspection' in self.env else None
|
||||
for rec in self:
|
||||
if Inspection is None or not rec.sale_order_id:
|
||||
rec.racking_inspection_count = 0
|
||||
continue
|
||||
rec.racking_inspection_count = Inspection.search_count([
|
||||
('sale_order_id', '=', rec.sale_order_id.id),
|
||||
])
|
||||
|
||||
def action_view_racking_inspections(self):
|
||||
"""Open the racking inspection(s) for this receiving's SO. If
|
||||
none exists yet, default-create context lets the user spawn one
|
||||
with the SO context pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Inspection = self.env['fp.racking.inspection']
|
||||
domain = [('sale_order_id', '=', self.sale_order_id.id)] \
|
||||
if self.sale_order_id else []
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Racking Inspections'),
|
||||
'res_model': 'fp.racking.inspection',
|
||||
'view_mode': 'list,form',
|
||||
'domain': domain,
|
||||
'context': {
|
||||
'default_sale_order_id': self.sale_order_id.id
|
||||
if self.sale_order_id else False,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase C — extend fusion.shipment with dimension fields.
|
||||
|
||||
fusion_shipping's native model has `weight` but no length/width/height.
|
||||
The plating workflow needs all four captured at receiving time so the
|
||||
shipment record carries everything the carrier API would want. Added
|
||||
here (not in fusion_shipping) to keep the upstream module untouched.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionShipment(models.Model):
|
||||
_inherit = 'fusion.shipment'
|
||||
|
||||
x_fc_length = fields.Float(string='Length', digits=(10, 2))
|
||||
x_fc_width = fields.Float(string='Width', digits=(10, 2))
|
||||
x_fc_height = fields.Float(string='Height', digits=(10, 2))
|
||||
x_fc_dim_uom = fields.Selection(
|
||||
[('in', 'in'), ('cm', 'cm')],
|
||||
string='Dim UoM', default='in',
|
||||
)
|
||||
x_fc_weight_uom = fields.Selection(
|
||||
[('lb', 'lb'), ('kg', 'kg')],
|
||||
string='Weight UoM', default='lb',
|
||||
)
|
||||
|
||||
# Multi-piece label storage. label_attachment_id remains the
|
||||
# primary (first box) for backward-compat; this M2M holds the full
|
||||
# set so the operator can download any box's label individually.
|
||||
x_fc_label_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_shipment_label_attachment_rel',
|
||||
'shipment_id', 'attachment_id',
|
||||
string='All Labels',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Phase C — resolved carrier tracking URL with the tracking number
|
||||
# substituted into the carrier.tracking_url template. Used by the
|
||||
# shipment_labeled email template and any other place that needs a
|
||||
# working clickable tracking link. Single source of truth so both
|
||||
# email + portal stay consistent.
|
||||
x_fc_tracking_url = fields.Char(
|
||||
string='Tracking URL (resolved)',
|
||||
compute='_compute_x_fc_tracking_url',
|
||||
help='carrier.tracking_url with <shipmenttrackingnumber> replaced '
|
||||
'by tracking_number. Empty when the carrier has no URL '
|
||||
'template or there is no tracking number yet.',
|
||||
)
|
||||
|
||||
@api.depends('carrier_id.tracking_url', 'tracking_number')
|
||||
def _compute_x_fc_tracking_url(self):
|
||||
for rec in self:
|
||||
tpl = (rec.carrier_id.tracking_url or '') if rec.carrier_id else ''
|
||||
tn = rec.tracking_number or ''
|
||||
if not tpl or not tn:
|
||||
rec.x_fc_tracking_url = ''
|
||||
continue
|
||||
placeholder = '<shipmenttrackingnumber>'
|
||||
if placeholder in tpl:
|
||||
rec.x_fc_tracking_url = tpl.replace(placeholder, tn)
|
||||
else:
|
||||
rec.x_fc_tracking_url = tpl + tn
|
||||
|
||||
def write(self, vals):
|
||||
"""Sync the carrier tracking number + label to the customer
|
||||
portal job whenever they land on the shipment. The portal_job
|
||||
currently shows `delivery.name` as 'tracking' — wrong; the
|
||||
customer wants the carrier's actual tracking number so the
|
||||
clickable link goes to FedEx/UPS/etc."""
|
||||
res = super().write(vals)
|
||||
sync_keys = {'tracking_number', 'label_attachment_id', 'status'}
|
||||
if not sync_keys & set(vals.keys()):
|
||||
return res
|
||||
for ship in self:
|
||||
try:
|
||||
ship._fp_sync_to_portal_job()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Shipment %s: portal-job sync failed: %s',
|
||||
ship.name, e,
|
||||
)
|
||||
return res
|
||||
|
||||
def _fp_sync_to_portal_job(self):
|
||||
"""Walk shipment → SO → fp.job → fusion.plating.portal.job
|
||||
and push the carrier tracking number + label + delivery's
|
||||
packing slip onto the customer-facing record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is None:
|
||||
return
|
||||
jobs = Job.sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)],
|
||||
)
|
||||
if not jobs:
|
||||
return
|
||||
for job in jobs:
|
||||
portal = job.portal_job_id
|
||||
if not portal:
|
||||
continue
|
||||
vals = {}
|
||||
if self.tracking_number and portal.tracking_ref != self.tracking_number:
|
||||
vals['tracking_ref'] = self.tracking_number
|
||||
# Packing slip lives on the linked fp.delivery, not the
|
||||
# shipment. Walk it lazily here so a packing-slip generated
|
||||
# earlier on the delivery also lands on the portal job.
|
||||
delivery = job.delivery_id
|
||||
if (delivery
|
||||
and 'packing_list_attachment_id' in delivery._fields
|
||||
and delivery.packing_list_attachment_id
|
||||
and portal.packing_list_attachment_id !=
|
||||
delivery.packing_list_attachment_id):
|
||||
vals['packing_list_attachment_id'] = (
|
||||
delivery.packing_list_attachment_id.id
|
||||
)
|
||||
# Once a tracking number exists, the parts have been picked
|
||||
# by the carrier (or are about to be) — advance the portal
|
||||
# state to 'shipped' so the customer sees their order is
|
||||
# on its way. The 'delivered' status flips when FedEx
|
||||
# tracking reports the delivery.
|
||||
if self.tracking_number and portal.state in (
|
||||
'received', 'in_progress', 'ready_to_ship',
|
||||
):
|
||||
vals['state'] = 'shipped'
|
||||
if vals:
|
||||
portal.sudo().write(vals)
|
||||
@@ -22,25 +22,41 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create receiving record on SO confirmation."""
|
||||
"""Override to auto-create receiving record on SO confirmation.
|
||||
|
||||
Per-line metadata (part catalog, part number) is sourced from
|
||||
``sale.order.line.x_fc_part_catalog_id`` — NOT from the SO header.
|
||||
The header field exists too but is rarely populated; the line
|
||||
carries the authoritative part link in the configurator flow.
|
||||
|
||||
Each receiving line prefills ``received_qty`` to ``expected_qty``
|
||||
so the racking crew only types when the count is off (mirrors
|
||||
the header behaviour in fp_receiving.py:create).
|
||||
"""
|
||||
res = super().action_confirm()
|
||||
for order in self:
|
||||
# Only create if no receiving record exists yet
|
||||
if not order.x_fc_receiving_ids:
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
receiving_vals = {
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': [],
|
||||
}
|
||||
# Auto-create lines from SO lines
|
||||
for line in order.order_line:
|
||||
receiving_vals['line_ids'].append((0, 0, {
|
||||
'part_number': order.x_fc_part_catalog_id.part_number if order.x_fc_part_catalog_id else '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': int(line.product_uom_qty),
|
||||
}))
|
||||
self.env['fp.receiving'].create(receiving_vals)
|
||||
if order.x_fc_receiving_ids:
|
||||
continue
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
line_vals = []
|
||||
for line in order.order_line:
|
||||
part = (
|
||||
line.x_fc_part_catalog_id
|
||||
if 'x_fc_part_catalog_id' in line._fields else False
|
||||
)
|
||||
expected = int(line.product_uom_qty or 0)
|
||||
line_vals.append((0, 0, {
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': expected,
|
||||
'received_qty': expected,
|
||||
}))
|
||||
self.env['fp.receiving'].create({
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
return res
|
||||
|
||||
def action_view_receiving(self):
|
||||
|
||||
@@ -14,3 +14,9 @@ access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_rack
|
||||
access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
||||
access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,group_fp_receiving,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_carrier_fields
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase A — carrier field + outbound shipment link tests on fp.receiving.
|
||||
|
||||
See docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestCarrierFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'CarrierCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.so = cls.env['sale.order'].create({
|
||||
'partner_id': cls.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': cls.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
# Carrier records seeded by data/delivery_carrier_seed_data.xml.
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
cls.carrier_fedex = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_fedex',
|
||||
)
|
||||
|
||||
def _make_receiving(self, **kw):
|
||||
vals = {'sale_order_id': self.so.id}
|
||||
vals.update(kw)
|
||||
return self.env['fp.receiving'].create(vals)
|
||||
|
||||
# ---- Field existence ------------------------------------------------
|
||||
|
||||
def test_carrier_id_field_exists_on_receiving(self):
|
||||
recv = self._make_receiving()
|
||||
self.assertIn('x_fc_carrier_id', recv._fields)
|
||||
|
||||
def test_outbound_shipment_id_field_exists_on_receiving(self):
|
||||
recv = self._make_receiving()
|
||||
self.assertIn('x_fc_outbound_shipment_id', recv._fields)
|
||||
|
||||
# ---- action_create_outbound_shipment --------------------------------
|
||||
|
||||
def test_action_create_outbound_shipment_creates_draft(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
self.assertFalse(recv.x_fc_outbound_shipment_id)
|
||||
recv.action_create_outbound_shipment()
|
||||
self.assertTrue(recv.x_fc_outbound_shipment_id)
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
self.assertEqual(ship.status, 'draft')
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
self.assertEqual(ship.sale_order_id, self.so)
|
||||
|
||||
def test_action_create_outbound_shipment_idempotent(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
first_ship = recv.x_fc_outbound_shipment_id
|
||||
recv.action_create_outbound_shipment()
|
||||
# Second call must not create a new shipment.
|
||||
self.assertEqual(recv.x_fc_outbound_shipment_id, first_ship)
|
||||
count = self.env['fusion.shipment'].search_count([
|
||||
('sale_order_id', '=', self.so.id),
|
||||
])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
# ---- onchange propagation -------------------------------------------
|
||||
|
||||
def test_carrier_id_change_propagates_to_draft_shipment(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
# Onchange triggers via the Form helper — we simulate by calling
|
||||
# the handler directly after writing.
|
||||
recv.x_fc_carrier_id = self.carrier_fedex.id
|
||||
recv._onchange_x_fc_carrier_id()
|
||||
self.assertEqual(ship.carrier_id, self.carrier_fedex)
|
||||
|
||||
def test_carrier_id_change_does_not_propagate_to_confirmed_shipment(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
# Confirm the shipment — propagation must stop.
|
||||
ship.status = 'confirmed'
|
||||
recv.x_fc_carrier_id = self.carrier_fedex.id
|
||||
recv._onchange_x_fc_carrier_id()
|
||||
# Confirmed shipment retains the original carrier.
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
@@ -72,18 +72,41 @@
|
||||
type="object"
|
||||
invisible="state != 'discrepancy'"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
<button name="action_generate_outbound_label"
|
||||
type="object"
|
||||
string="Generate Outbound Label"
|
||||
class="btn-primary"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight"/>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
string="Print Label"
|
||||
class="btn-secondary"
|
||||
icon="fa-file-pdf-o"
|
||||
invisible="not x_fc_outbound_shipment_id"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,counted,staged,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_racking_inspections"
|
||||
<button name="action_create_outbound_shipment"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-search-plus">
|
||||
<field name="racking_inspection_count"
|
||||
icon="fa-truck">
|
||||
<field name="x_fc_outbound_shipment_count"
|
||||
widget="statinfo"
|
||||
string="Racking Inspections"/>
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_has_label">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">PDF</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_has_label" invisible="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
@@ -114,8 +137,46 @@
|
||||
<group string="Reception">
|
||||
<field name="received_by_id"/>
|
||||
<field name="received_date"/>
|
||||
<field name="carrier_name"/>
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="carrier_tracking"/>
|
||||
<field name="carrier_name"
|
||||
invisible="not carrier_name"
|
||||
readonly="1"
|
||||
string="Legacy Carrier"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Outbound Packaging"
|
||||
invisible="not x_fc_carrier_id">
|
||||
<group>
|
||||
<label for="x_fc_weight"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_weight"/>
|
||||
<field name="x_fc_weight_uom" nolabel="1"/>
|
||||
</div>
|
||||
<label for="x_fc_length" string="Dimensions (L×W×H)"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_length"
|
||||
placeholder="L"/>
|
||||
<span class="mx-1">×</span>
|
||||
<field name="x_fc_width"
|
||||
placeholder="W"/>
|
||||
<span class="mx-1">×</span>
|
||||
<field name="x_fc_height"
|
||||
placeholder="H"/>
|
||||
<field name="x_fc_dim_uom" nolabel="1"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<p class="text-muted" colspan="2">
|
||||
Enter the weight and dimensions of the
|
||||
packaging you'll use to ship the finished
|
||||
parts back. The system reuses the same
|
||||
boxes for the return shipment. Click
|
||||
<strong>Generate Outbound Label</strong>
|
||||
in the header once carrier + weight are
|
||||
set.
|
||||
</p>
|
||||
</group>
|
||||
<group string="Quantities (populated by racking crew)">
|
||||
<field name="expected_qty" readonly="1"/>
|
||||
@@ -124,6 +185,33 @@
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Multi-Piece Packages"
|
||||
name="outbound_packages"
|
||||
invisible="not x_fc_carrier_id">
|
||||
<p class="text-muted">
|
||||
For multi-box shipments, add one row per
|
||||
box with its weight + dimensions. The
|
||||
carrier API will return one tracking
|
||||
number + one label per row.
|
||||
<strong>Single-box flow:</strong> leave
|
||||
this empty and the top-level weight/dim
|
||||
fields above are used.
|
||||
</p>
|
||||
<field name="x_fc_outbound_package_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="weight"/>
|
||||
<field name="length"/>
|
||||
<field name="width"/>
|
||||
<field name="height"/>
|
||||
<field name="tracking_number"
|
||||
readonly="1"/>
|
||||
<field name="label_attachment_id"
|
||||
readonly="1"
|
||||
widget="many2one_binary"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Receiving Lines" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Phase C MPS — extends fusion_shipping's shipment form with the
|
||||
All Labels list (x_fc_label_attachment_ids, one entry per package
|
||||
on a multi-piece shipment).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fusion_shipment_form_mps_inherit" model="ir.ui.view">
|
||||
<field name="name">fusion.shipment.form.mps.inherit</field>
|
||||
<field name="model">fusion.shipment</field>
|
||||
<field name="inherit_id" ref="fusion_shipping.view_fusion_shipment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='labels']/group[1]" position="after">
|
||||
<separator string="All Labels (Multi-Piece)"
|
||||
invisible="not x_fc_label_attachment_ids"/>
|
||||
<field name="x_fc_label_attachment_ids"
|
||||
invisible="not x_fc_label_attachment_ids"
|
||||
options="{'no_create': True}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="mimetype"/>
|
||||
<field name="file_size"/>
|
||||
</list>
|
||||
</field>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_label_manual_wizard
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Manual outbound-label entry wizard.
|
||||
|
||||
Opens automatically from fp.receiving.action_generate_outbound_label
|
||||
when:
|
||||
- the chosen carrier has no API integration (delivery_type='fixed'), or
|
||||
- the carrier API call fails (network, credential, malformed response).
|
||||
|
||||
Operator pastes the label PDF from the carrier's web tool + types the
|
||||
tracking number. On confirm, both land on the linked fusion.shipment.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpLabelManualWizard(models.TransientModel):
|
||||
_name = 'fp.label.manual.wizard'
|
||||
_description = 'Fusion Plating — Manual Outbound Label Entry'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, readonly=True, ondelete='cascade',
|
||||
)
|
||||
receiving_name = fields.Char(related='receiving_id.name', readonly=True)
|
||||
carrier_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_carrier_id', readonly=True,
|
||||
)
|
||||
shipment_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_outbound_shipment_id', readonly=True,
|
||||
)
|
||||
note = fields.Text(
|
||||
string='Why Manual?', readonly=True,
|
||||
help='Explanatory message — set by the caller (no API, API '
|
||||
'failure, etc.).',
|
||||
)
|
||||
label_pdf = fields.Binary(string='Shipping Label PDF')
|
||||
label_filename = fields.Char(string='Filename')
|
||||
tracking_number = fields.Char(string='Tracking Number')
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not self.label_pdf:
|
||||
raise UserError(_(
|
||||
'Attach the shipping label PDF before confirming.'
|
||||
))
|
||||
if not (self.tracking_number or '').strip():
|
||||
raise UserError(_(
|
||||
'Enter the tracking number before confirming.'
|
||||
))
|
||||
ship = self.shipment_id
|
||||
if not ship:
|
||||
raise UserError(_(
|
||||
'No outbound shipment linked to this receiving — '
|
||||
'cannot save manual label.'
|
||||
))
|
||||
# Create the attachment, then write the shipment.
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.label_filename or 'shipping-label.pdf',
|
||||
'type': 'binary',
|
||||
'datas': self.label_pdf,
|
||||
'mimetype': 'application/pdf',
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': ship.id,
|
||||
})
|
||||
ship.sudo().write({
|
||||
'label_attachment_id': att.id,
|
||||
'tracking_number': self.tracking_number.strip(),
|
||||
'status': 'confirmed',
|
||||
})
|
||||
ship.message_post(body=Markup(_(
|
||||
'Manual label saved — tracking <b>%s</b>.'
|
||||
)) % self.tracking_number)
|
||||
self.receiving_id.message_post(body=Markup(_(
|
||||
'Outbound label entered manually. Tracking: <b>%s</b>'
|
||||
)) % self.tracking_number)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_label_manual_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.label.manual.wizard.form</field>
|
||||
<field name="model">fp.label.manual.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Manual Outbound Label Entry">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Enter Label Manually —
|
||||
<field name="receiving_name"
|
||||
readonly="1" nolabel="1" class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not note">
|
||||
<i class="fa fa-info-circle"/>
|
||||
<field name="note" nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="carrier_id" readonly="1"/>
|
||||
<field name="shipment_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Label Details"/>
|
||||
<group>
|
||||
<field name="label_pdf"
|
||||
filename="label_filename"/>
|
||||
<field name="label_filename" invisible="1"/>
|
||||
<field name="tracking_number"
|
||||
placeholder="e.g. 1Z999AA10123456784"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Save Label" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.26.1.0',
|
||||
'version': '19.0.26.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -244,7 +244,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
and 'x_fc_assigned_manager_id' in so_fields):
|
||||
pending_accept_sos = SO.search_count([
|
||||
('state', '=', 'sale'),
|
||||
('x_fc_receiving_status', '=', 'inspected'),
|
||||
('x_fc_receiving_status', '=', 'received'),
|
||||
('x_fc_assigned_manager_id', '=', False),
|
||||
])
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fusion Shipping",
|
||||
"version": "19.0.1.1.0",
|
||||
"version": "19.0.1.5.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||
|
||||
@@ -342,6 +342,34 @@ class FedexRequest:
|
||||
res.append({'number': partner.parent_id.vat, 'tinType': 'BUSINESS_NATIONAL'})
|
||||
return res
|
||||
|
||||
def _strip_customs_for_domestic(self, request_data):
|
||||
"""Remove customsClearanceDetail when shipper + recipient are in
|
||||
the same country and the service isn't FEDEX_REGIONAL_ECONOMY.
|
||||
|
||||
FedEx rejects domestic Ground/Express requests that carry a
|
||||
customs block (TOTALCUSTOMSVALUE.REQUIRED). The upstream model
|
||||
always builds the block; we strip it for clearly-domestic cases.
|
||||
Caller invokes this immediately before _send_fedex_request.
|
||||
"""
|
||||
rs = request_data.get('requestedShipment', {})
|
||||
shipper = rs.get('shipper') or {}
|
||||
ship_addr = shipper.get('address') or {}
|
||||
# Recipient lives under 'recipients' (list) for /ship and
|
||||
# 'recipient' (single) for /rate. Handle both shapes.
|
||||
rec = rs.get('recipients') or []
|
||||
if isinstance(rec, list) and rec:
|
||||
rec_addr = rec[0].get('address') or {}
|
||||
else:
|
||||
rec_addr = (rs.get('recipient') or {}).get('address') or {}
|
||||
ship_country = ship_addr.get('countryCode')
|
||||
rec_country = rec_addr.get('countryCode')
|
||||
if (ship_country and rec_country
|
||||
and ship_country == rec_country
|
||||
and self.service_type != 'FEDEX_REGIONAL_ECONOMY'
|
||||
# India domestic still uses customs per upstream logic.
|
||||
and not (ship_country == 'IN' and rec_country == 'IN')):
|
||||
rs.pop('customsClearanceDetail', None)
|
||||
|
||||
def _get_shipping_price(self, ship_from, ship_to, packages, currency):
|
||||
fedex_currency = _convert_curr_iso_fdx(currency)
|
||||
request_data = {
|
||||
@@ -364,6 +392,7 @@ class FedexRequest:
|
||||
}
|
||||
}
|
||||
self._add_extra_data_to_request(request_data, 'rate')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
||||
try:
|
||||
rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {})
|
||||
@@ -474,6 +503,7 @@ class FedexRequest:
|
||||
request_data['requestedShipment']['customsClearanceDetail']['customsOption'] = {'type': 'COURTESY_RETURN_LABEL'}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'ship')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -561,6 +591,7 @@ class FedexRequest:
|
||||
}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'return')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -597,6 +628,62 @@ class FedexRequest:
|
||||
return actual['totalNetChargeWithDutiesAndTaxes']
|
||||
return actual['totalNetCharge']
|
||||
|
||||
def track_shipment(self, tracking_nr):
|
||||
"""Call FedEx /track/v1/trackingnumbers and return the parsed
|
||||
scan-event list. Returns:
|
||||
{
|
||||
'tracking_number': '<str>',
|
||||
'status': '<str — latest status description>',
|
||||
'events': [
|
||||
{
|
||||
'date_time': '<ISO 8601 str>',
|
||||
'description': '<str>',
|
||||
'event_type': '<str — FedEx event code>',
|
||||
'city': '<str>',
|
||||
'state_province': '<str>',
|
||||
'country': '<str>',
|
||||
'signed_by': '<str — present on delivery events>',
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
Empty events list when FedEx returns no scans yet (newly-printed
|
||||
label that hasn't been picked up). Raises ValidationError on
|
||||
HTTP error.
|
||||
"""
|
||||
res = self._send_fedex_request("/track/v1/trackingnumbers", {
|
||||
'includeDetailedScans': True,
|
||||
'trackingInfo': [{
|
||||
'trackingNumberInfo': {
|
||||
'trackingNumber': tracking_nr,
|
||||
},
|
||||
}],
|
||||
})
|
||||
out = {'tracking_number': tracking_nr, 'status': '', 'events': []}
|
||||
try:
|
||||
results = (res.get('completeTrackResults') or [{}])[0]
|
||||
track = (results.get('trackResults') or [{}])[0]
|
||||
except (AttributeError, IndexError):
|
||||
return out
|
||||
latest = track.get('latestStatusDetail') or {}
|
||||
out['status'] = (
|
||||
latest.get('description')
|
||||
or latest.get('statusByLocale')
|
||||
or ''
|
||||
)
|
||||
for scan in (track.get('scanEvents') or []):
|
||||
addr = scan.get('scanLocation') or {}
|
||||
out['events'].append({
|
||||
'date_time': scan.get('date') or '',
|
||||
'description': scan.get('eventDescription') or '',
|
||||
'event_type': scan.get('eventType') or '',
|
||||
'city': addr.get('city') or '',
|
||||
'state_province': addr.get('stateOrProvinceCode') or '',
|
||||
'country': addr.get('countryCode') or '',
|
||||
'signed_by': track.get('deliveryDetails', {}).get(
|
||||
'receivedByName', '') or '',
|
||||
})
|
||||
return out
|
||||
|
||||
def cancel_shipment(self, tracking_nr):
|
||||
res = self._send_fedex_request('/ship/v1/shipments/cancel', {
|
||||
'accountNumber': {'value': self.account_number},
|
||||
|
||||
@@ -292,7 +292,13 @@ class FusionShipment(models.Model):
|
||||
# ── Tracking ──────────────────────────────────────────────
|
||||
|
||||
def action_refresh_tracking(self):
|
||||
"""Fetch latest tracking events from Canada Post VIS API."""
|
||||
"""Fetch latest tracking events from the carrier's API.
|
||||
|
||||
Dispatch by carrier_type:
|
||||
- canada_post → Canada Post VIS API (inline below)
|
||||
- fedex_rest → FedEx /track/v1/trackingnumbers
|
||||
- other carriers → not yet supported; raise with clear message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.tracking_number:
|
||||
raise ValidationError(
|
||||
@@ -301,6 +307,15 @@ class FusionShipment(models.Model):
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier linked to this shipment."))
|
||||
if self.carrier_type == 'fedex_rest':
|
||||
return self._refresh_tracking_fedex_rest()
|
||||
if self.carrier_type != 'canada_post':
|
||||
raise ValidationError(_(
|
||||
"Refresh Tracking is only wired to Canada Post and "
|
||||
"FedEx REST at this time. For %(carrier)s shipments, "
|
||||
"use the Track Shipment button to view live tracking "
|
||||
"on the carrier's website."
|
||||
) % {'carrier': carrier.name})
|
||||
|
||||
# VIS tracking uses /vis/ path, not /rs/
|
||||
if carrier.prod_environment:
|
||||
@@ -745,3 +760,70 @@ class FusionShipment(models.Model):
|
||||
attachment_ids=(
|
||||
self.return_label_attachment_id.ids
|
||||
if self.return_label_attachment_id else []))
|
||||
|
||||
# ── FedEx REST tracking ──────────────────────────────────────────
|
||||
|
||||
def _refresh_tracking_fedex_rest(self):
|
||||
"""Call FedEx /track/v1/trackingnumbers and load scan events.
|
||||
|
||||
Parses the response via the FedexRestRequest.track_shipment
|
||||
helper, replaces the shipment's tracking_event_ids with the
|
||||
latest events, and updates status to 'delivered' if the latest
|
||||
event indicates delivery. The 'delivered' transition cascades
|
||||
to the portal_job via the existing write() hook.
|
||||
"""
|
||||
self.ensure_one()
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
try:
|
||||
fedex = FedexRestRequest(self.carrier_id)
|
||||
result = fedex.track_shipment(self.tracking_number)
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
_("FedEx tracking error: %s") % str(e))
|
||||
# Replace events.
|
||||
self.tracking_event_ids.unlink()
|
||||
vals_list = []
|
||||
delivered = False
|
||||
for evt in result.get('events') or []:
|
||||
evt_date_str = ''
|
||||
evt_time_str = ''
|
||||
evt_datetime = False
|
||||
raw_dt = evt.get('date_time') or ''
|
||||
if raw_dt:
|
||||
# FedEx returns ISO 8601 like 2026-05-18T14:30:00-05:00.
|
||||
try:
|
||||
parsed = dt_mod.fromisoformat(raw_dt)
|
||||
# Strip tzinfo so it stores in Odoo's naive UTC fields.
|
||||
if parsed.tzinfo is not None:
|
||||
import pytz as _pytz
|
||||
parsed = parsed.astimezone(_pytz.UTC).replace(tzinfo=None)
|
||||
evt_datetime = parsed
|
||||
evt_date_str = parsed.strftime('%Y-%m-%d')
|
||||
evt_time_str = parsed.strftime('%H:%M:%S')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if (evt.get('event_type') or '').upper() == 'DL' or (
|
||||
'delivered' in (evt.get('description') or '').lower()):
|
||||
delivered = True
|
||||
vals_list.append({
|
||||
'shipment_id': self.id,
|
||||
'event_date': evt_date_str or False,
|
||||
'event_time': evt_time_str or '',
|
||||
'event_datetime': evt_datetime,
|
||||
'event_description': evt.get('description') or '',
|
||||
'event_type': evt.get('event_type') or '',
|
||||
'event_site': evt.get('city') or '',
|
||||
'event_province': evt.get('state_province') or '',
|
||||
'signatory_name': evt.get('signed_by') or '',
|
||||
})
|
||||
if vals_list:
|
||||
self.env['fusion.tracking.event'].create(vals_list)
|
||||
self.last_tracking_update = fields.Datetime.now()
|
||||
if delivered and self.status != 'delivered':
|
||||
self.status = 'delivered'
|
||||
self.delivery_date = fields.Datetime.now()
|
||||
self.message_post(body=_(
|
||||
"FedEx tracking refreshed: %(n)d event(s) loaded. Status: %(s)s"
|
||||
) % {'n': len(vals_list), 's': result.get('status') or '—'})
|
||||
|
||||
@@ -78,12 +78,17 @@
|
||||
string="Refresh Tracking"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
invisible="not tracking_number or status == 'cancelled'"/>
|
||||
invisible="not tracking_number or status == 'cancelled' or carrier_type not in ('canada_post', 'fedex_rest')"/>
|
||||
<button name="action_track_on_carrier" type="object"
|
||||
string="Track Shipment"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"
|
||||
invisible="not tracking_number"/>
|
||||
<button name="action_view_label" type="object"
|
||||
string="Print Shipping Label"
|
||||
class="btn-secondary"
|
||||
icon="fa-print"
|
||||
invisible="not label_attachment_id"/>
|
||||
<button name="action_create_return_label" type="object"
|
||||
string="Create Return Label"
|
||||
class="btn-warning"
|
||||
@@ -130,6 +135,14 @@
|
||||
<span class="o_stat_text">Events</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_label" type="object"
|
||||
class="oe_stat_button" icon="fa-print"
|
||||
invisible="not label_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">PDF</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
@@ -138,7 +151,8 @@
|
||||
<group>
|
||||
<group string="Shipment Details">
|
||||
<field name="tracking_number"/>
|
||||
<field name="shipment_id"/>
|
||||
<field name="shipment_id"
|
||||
invisible="carrier_type != 'canada_post'"/>
|
||||
<field name="carrier_id"/>
|
||||
<field name="carrier_type"/>
|
||||
<field name="service_type"/>
|
||||
|
||||
Reference in New Issue
Block a user