diff --git a/fusion-plating/%{http_code} b/fusion-plating/%{http_code} new file mode 100644 index 00000000..e69de29b diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index d5549f3d..6739805d 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -27,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 `
` without `` 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 `` so `-u ` 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 `` tags renders as literal `foo` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... %s ...'))` 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: %s')) % tracking)`. | any model posting HTML-formatted chatter | ### Pending — IN PROGRESS when this session ended diff --git a/fusion_plating/docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md b/fusion_plating/docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md new file mode 100644 index 00000000..07f998af --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md @@ -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`. diff --git a/fusion_plating/docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md b/fusion_plating/docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md new file mode 100644 index 00000000..35296d94 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md @@ -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. diff --git a/fusion_plating/docs/superpowers/specs/2026-05-18-phase-c-generate-label-end-to-end-design.md b/fusion_plating/docs/superpowers/specs/2026-05-18-phase-c-generate-label-end-to-end-design.md new file mode 100644 index 00000000..42892759 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-18-phase-c-generate-label-end-to-end-design.md @@ -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 `` 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 `` 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 + + + + + +``` +`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 `` 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. diff --git a/fusion_plating/docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md b/fusion_plating/docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md new file mode 100644 index 00000000..e319ab4a --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md @@ -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. diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 2688ce75..f3f377d6 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py index a9290754..b8c13ef4 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py @@ -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 diff --git a/fusion_plating/fusion_plating_certificates/__init__.py b/fusion_plating/fusion_plating_certificates/__init__.py index 3c90fa80..cf9f201b 100644 --- a/fusion_plating/fusion_plating_certificates/__init__.py +++ b/fusion_plating/fusion_plating_certificates/__init__.py @@ -4,3 +4,4 @@ # Part of the Fusion Plating product family. from . import models +from . import wizards diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 2e775aa4..0e2e9afd 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 141bb1d2..267e0e7a 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -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.""" diff --git a/fusion_plating/fusion_plating_certificates/models/res_partner.py b/fusion_plating/fusion_plating_certificates/models/res_partner.py index b2fc7cd7..c72e446d 100644 --- a/fusion_plating/fusion_plating_certificates/models/res_partner.py +++ b/fusion_plating/fusion_plating_certificates/models/res_partner.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv b/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv index 0ebe99ce..04771077 100644 --- a/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_certificates/tests/__init__.py b/fusion_plating/fusion_plating_certificates/tests/__init__.py new file mode 100644 index 00000000..ba4bd968 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_action_issue_gates diff --git a/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py new file mode 100644 index 00000000..d2ecd019 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py @@ -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') diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml index 700db35c..16db57d1 100644 --- a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml +++ b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml @@ -42,7 +42,7 @@
@@ -80,8 +81,7 @@ + name="thickness_pdf"> + + + + +

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.

-
    -
  1. Open the linked Plating Job (smart - button above)
  2. -
  3. Click into the auto-spawned Quality - Check
  4. -
  5. Go to the Thickness Report tab - and upload the PDF from the Fischerscope - / XDAL 600 export
  6. -
  7. Pass the QC, then come back here and - click Issue
  8. -
@@ -120,8 +118,8 @@ Click Issue 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.

diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml new file mode 100644 index 00000000..dedbbd97 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml @@ -0,0 +1,22 @@ + + + + + Generate Missing Certs for Closed Jobs + + + list + + code + action = env['fp.job'].action_backfill_missing_certs() + + diff --git a/fusion_plating/fusion_plating_jobs/wizards/__init__.py b/fusion_plating/fusion_plating_jobs/wizards/__init__.py index deae8927..98043e5e 100644 --- a/fusion_plating/fusion_plating_jobs/wizards/__init__.py +++ b/fusion_plating/fusion_plating_jobs/wizards/__init__.py @@ -4,3 +4,4 @@ from . import fp_job_step_move_wizard from . import fp_job_step_input_wizard +from . import fp_cert_issue_wizard diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py new file mode 100644 index 00000000..22186a5e --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py @@ -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 %s 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)) diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml new file mode 100644 index 00000000..31e1f620 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml @@ -0,0 +1,101 @@ + + + + + fp.cert.issue.wizard.form + fp.cert.issue.wizard + +
+ +
+

+ Issue Certs — + +

+
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+
+
+
+ +
+
+
diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index c5c9a9a4..fabaa88f 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -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', ], diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index 4f57bfd4..5758eb26 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -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 %s 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'), diff --git a/fusion_plating/fusion_plating_logistics/tests/__init__.py b/fusion_plating/fusion_plating_logistics/tests/__init__.py new file mode 100644 index 00000000..337790fa --- /dev/null +++ b/fusion_plating/fusion_plating_logistics/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_delivery_shipping_fields diff --git a/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py b/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py new file mode 100644 index 00000000..f1661f29 --- /dev/null +++ b/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py @@ -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) diff --git a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml index 1f22c892..7287c37f 100644 --- a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml +++ b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml @@ -59,6 +59,16 @@ statusbar_visible="draft,scheduled,en_route,delivered"/> +
+ +
+
+
+ EN Technologies +
+

Your Order Is Being Prepared for Shipment

+

+ Hi , the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier. +

+ + + + + + + + + + + + + + + + + +
ShipmentDetail
Sale Order
Carrier
Tracking Number
+
+
+ What's next: Once the carrier collects the package, you'll receive a Shipped confirmation with the Certificate of Conformance attached. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+ + + diff --git a/fusion_plating/fusion_plating_notifications/models/__init__.py b/fusion_plating/fusion_plating_notifications/models/__init__.py index 5eb4878b..980cda15 100644 --- a/fusion_plating/fusion_plating_notifications/models/__init__.py +++ b/fusion_plating/fusion_plating_notifications/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py index 92a89e36..005bd937 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py @@ -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', diff --git a/fusion_plating/fusion_plating_notifications/models/fusion_shipment.py b/fusion_plating/fusion_plating_notifications/models/fusion_shipment.py new file mode 100644 index 00000000..7fd7ff15 --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/models/fusion_shipment.py @@ -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 diff --git a/fusion_plating/fusion_plating_portal/__manifest__.py b/fusion_plating/fusion_plating_portal/__manifest__.py index f36cbc15..b3276477 100644 --- a/fusion_plating/fusion_plating_portal/__manifest__.py +++ b/fusion_plating/fusion_plating_portal/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Customer Portal', - 'version': '19.0.4.1.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.', diff --git a/fusion_plating/fusion_plating_portal/controllers/portal.py b/fusion_plating/fusion_plating_portal/controllers/portal.py index 0d701d51..bf6233f0 100644 --- a/fusion_plating/fusion_plating_portal/controllers/portal.py +++ b/fusion_plating/fusion_plating_portal/controllers/portal.py @@ -424,7 +424,10 @@ class FpCustomerPortal(CustomerPortal): 'icon': '📑', }) - # SHIPPING (idx 4) — packing list + tracking + # SHIPPING (idx 4) — packing list + tracking. Two separate + # docs so each can be pending/ready independently. Previously + # combined into one entry; that broke when tracking_ref landed + # before the packing slip (KeyError 'url'). if job.packing_list_attachment_id: groups[4]['docs'].append({ 'label': 'Packing Slip', @@ -438,11 +441,26 @@ class FpCustomerPortal(CustomerPortal): }) else: groups[4]['docs'].append({ - 'label': 'Packing Slip · Tracking #', - 'sub': 'Available when shipped' + (' — ' + job.tracking_ref if job.tracking_ref else ''), - 'pending': not job.tracking_ref, + 'label': 'Packing Slip', + 'sub': 'Available once shipped', + 'pending': True, 'icon': '📦', }) + if job.tracking_ref: + groups[4]['docs'].append({ + 'label': 'Tracking #%s' % job.tracking_ref, + 'sub': 'Click to track on the carrier site', + 'url': job.x_fc_tracking_url or '#', + 'icon_class': 'o_fp_doc_icon_shipping', + 'icon': '🚚', + }) + else: + groups[4]['docs'].append({ + 'label': 'Tracking #', + 'sub': 'Available when shipped', + 'pending': True, + 'icon': '🚚', + }) return groups diff --git a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py index 7a4188da..999f7a50 100644 --- a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py +++ b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py @@ -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 = '' + 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', diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml index 71714c93..94b7d13e 100644 --- a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml +++ b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml @@ -536,7 +536,17 @@
Tracking - + + + + + + + +
Ship to @@ -594,6 +604,50 @@
+ +
+
+
+ Tracking History +
+ + + + + + +
+
+ +
+
+
+
+ + + + · + + , + + + + +
+
+ +
+
+
Notes
diff --git a/fusion_plating/fusion_plating_receiving/__init__.py b/fusion_plating/fusion_plating_receiving/__init__.py index 3c90fa80..cf9f201b 100644 --- a/fusion_plating/fusion_plating_receiving/__init__.py +++ b/fusion_plating/fusion_plating_receiving/__init__.py @@ -4,3 +4,4 @@ # Part of the Fusion Plating product family. from . import models +from . import wizards diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py index bfd9460e..c32c57e1 100644 --- a/fusion_plating/fusion_plating_receiving/__manifest__.py +++ b/fusion_plating/fusion_plating_receiving/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml b/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml new file mode 100644 index 00000000..794fbd7b --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml @@ -0,0 +1,120 @@ + + + + + UPS + fixed + + 0 + 20 + + + + FedEx + fixed + + 0 + 21 + + + + USPS + fixed + + 0 + 22 + + + + DHL + fixed + + 0 + 23 + + + + Purolator + fixed + + 0 + 24 + + + + CCT + fixed + + 0 + 25 + + + + Canpar Express + fixed + + 0 + 26 + + + + GLS Canada + fixed + + 0 + 27 + + + + Loomis Express + fixed + + 0 + 28 + + + + Day & Ross + fixed + + 0 + 29 + + + + Dicom Transportation + fixed + + 0 + 30 + + + + Customer Drop-off + fixed + + 0 + 31 + + + + Local Delivery + fixed + + 0 + 32 + + diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py new file mode 100644 index 00000000..3b4cb41d --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py @@ -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, + ) diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py new file mode 100644 index 00000000..8018b90d --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py @@ -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, + ) diff --git a/fusion_plating/fusion_plating_receiving/models/__init__.py b/fusion_plating/fusion_plating_receiving/models/__init__.py index 30ac79b8..ee87ba4e 100644 --- a/fusion_plating/fusion_plating_receiving/models/__init__.py +++ b/fusion_plating/fusion_plating_receiving/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py b/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py new file mode 100644 index 00000000..7e4b0c71 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py @@ -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, + ) diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index 70a63dfe..b9a143d6 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -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 %s 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._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 _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: %s' + )) % (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') diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py deleted file mode 100644 index 1afa1487..00000000 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py +++ /dev/null @@ -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, - }, - } diff --git a/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py b/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py new file mode 100644 index 00000000..9128e7a8 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py @@ -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 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 = '' + 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) diff --git a/fusion_plating/fusion_plating_receiving/models/sale_order.py b/fusion_plating/fusion_plating_receiving/models/sale_order.py index e9267a1b..a9fd2bd0 100644 --- a/fusion_plating/fusion_plating_receiving/models/sale_order.py +++ b/fusion_plating/fusion_plating_receiving/models/sale_order.py @@ -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): diff --git a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv index 4e1c7d82..c13b5f51 100644 --- a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_receiving/tests/__init__.py b/fusion_plating/fusion_plating_receiving/tests/__init__.py new file mode 100644 index 00000000..ffad1d47 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_carrier_fields diff --git a/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py b/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py new file mode 100644 index 00000000..c1262802 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py @@ -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) diff --git a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml index 98acc7c2..312e78d3 100644 --- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -72,18 +72,41 @@ type="object" invisible="state != 'discrepancy'" groups="fusion_plating.group_fusion_plating_manager"/> + +
+