changes
This commit is contained in:
0
fusion-plating/%{http_code}
Normal file
0
fusion-plating/%{http_code}
Normal file
@@ -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 `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
|
||||
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
|
||||
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `'` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records, write a one-shot post-migration or update via `odoo shell`. | any code scripting `mail.template.body_html` |
|
||||
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
|
||||
|
||||
### Pending — IN PROGRESS when this session ended
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Certificate Creation Timing + Data Completeness Gates
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering incident:** WO-30040 marked done with no CoC produced — chatter showed `Cert auto-create (coc) failed: name 'coating' is not defined` (regression in `fusion_plating_jobs/models/fp_job.py:1706` where `coating` was referenced but never bound).
|
||||
|
||||
## Goal
|
||||
|
||||
Two things, decided as one unit of work:
|
||||
|
||||
1. **Fix the broken cert-creation path** so jobs marked done always produce the expected draft certs.
|
||||
2. **Harden the data-completeness gates** so a CoC cannot be issued with missing critical information.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the cert lifecycle timing (kept at `button_mark_done()`).
|
||||
- Wizard-based "Issue CoC" flow (Approach C, rejected).
|
||||
- SO-confirm cert-stub flow (Approach B, rejected).
|
||||
- Email delivery refactor — issuance still triggers existing `fp.notification.template` dispatch.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Cert creation stays at `fp.job.button_mark_done()` | All upstream data should be settled by then; existing architecture is sound — only the bug masks that. |
|
||||
| D2 | Receiving must close before job-done | qty_received blank or unreconciled blocks `button_mark_done`. Guarantees the cert always points to a closed receiving. |
|
||||
| D3 | Strict qty accounting | `qty_received ≡ qty_done + qty_scrapped + qty_visual_inspection_rejects`. NC qty on cert = `qty_scrapped + qty_visual_inspection_rejects`. |
|
||||
| D4 | Per-company default signer | New `res.company.x_fc_default_coc_signer_id`. Customer-spec signer_user_id wins if set. |
|
||||
| D5 | Per-partner default CoC contact | New `res.partner.x_fc_default_coc_contact_id`. Sales sets it once per customer. |
|
||||
| D6 | Mandatory fields at `action_issue()` | spec_reference (existing), process_description, certified_by_id, contact_partner_id with valid email, qty reconciliation. |
|
||||
| D7 | Backfill action for closed jobs missing certs | One-shot server action — walks `state='done'` jobs whose `_resolve_required_cert_types()` is non-empty and have no matching cert; calls `_fp_create_certificates()`. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ JOB EXECUTION ─────────────────────────────────────────────────┐
|
||||
│ Steps run → Bake → QC → Receiving closed │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ button_mark_done() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ qty_received present AND │
|
||||
│ qty_received ≡ qty_done + qty_scrapped + qty_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _fp_create_certificates() (bug fixed + richer prefill) │
|
||||
│ Resolved sources: │
|
||||
│ process_description ← job.recipe_id.name │
|
||||
│ certified_by_id ← customer_spec.signer_user_id │
|
||||
│ OR company.x_fc_default_coc_signer_id│
|
||||
│ contact_partner_id ← partner.x_fc_default_coc_contact_id │
|
||||
│ nc_quantity ← qty_scrapped + qty_visual_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Draft cert(s) — milestone advances to "Issue Certs" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ ISSUANCE ──────────────────────────────────────────────────────┐
|
||||
│ Manager opens cert → action_issue() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ process_description present │
|
||||
│ certified_by_id present │
|
||||
│ contact_partner_id present, with email │
|
||||
│ qty reconciliation (belt-and-suspenders vs Gate 1) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ state → issued, PDF generated, attached │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Schema changes (additive)
|
||||
|
||||
| Model | New field | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `res.company` | `x_fc_default_coc_signer_id` | M2O `res.users` | Default signing authority. Set once per facility. |
|
||||
| `res.partner` | `x_fc_default_coc_contact_id` | M2O `res.partner` (children of self) | Sales sets per customer. |
|
||||
|
||||
Both are additive — no data migration needed.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Version bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating` | 19.0.20.1.0 → 19.0.20.2.0 | `models/res_company.py`, `views/res_company_views.xml` (or settings view) |
|
||||
| `fusion_plating_certificates` | 19.0.6.1.0 → 19.0.6.2.0 | `models/res_partner.py`, `models/fp_certificate.py`, `views/res_partner_views.xml` |
|
||||
| `fusion_plating_jobs` | 19.0.10.8.0 → 19.0.10.9.0 | `models/fp_job.py` (mark_done gate + cert prefill bug fix + backfill action) |
|
||||
|
||||
## Gate logic — `button_mark_done()`
|
||||
|
||||
Inside the existing `if not skip_qty_gate and job.qty:` block, add:
|
||||
|
||||
```python
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received is blank. "
|
||||
"Close the receiving record for SO %s before completing this job."
|
||||
) % (job.name, job.sale_order_id.name if job.sale_order_id else '?'))
|
||||
|
||||
accounted_out = (job.qty_done or 0) + (job.qty_scrapped or 0) \
|
||||
+ (job.qty_visual_inspection_rejects or 0)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done (%g) + "
|
||||
"qty_scrapped (%g) + visual rejects (%g) = %g. "
|
||||
"Reconcile before closing."
|
||||
) % (job.name, job.qty_received, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, job.qty_visual_inspection_rejects or 0,
|
||||
accounted_out))
|
||||
```
|
||||
|
||||
Manager bypass: existing `fp_skip_qty_reconcile=True` context covers both.
|
||||
|
||||
## Cert prefill table (`_fp_create_certificates`)
|
||||
|
||||
| Cert field | Source |
|
||||
|---|---|
|
||||
| partner_id | `job.partner_id` (existing) |
|
||||
| sale_order_id | `job.sale_order_id` (existing) |
|
||||
| x_fc_job_id | `job.id` (existing) |
|
||||
| certificate_type | `_resolve_required_cert_types()` (existing) |
|
||||
| part_number | `job.part_catalog_id.part_number` (existing) |
|
||||
| entech_wo_number | `job.name` (existing) |
|
||||
| po_number | `job.sale_order_id.x_fc_po_number` (existing) |
|
||||
| customer_job_no | `job.sale_order_id.x_fc_customer_job_number` (existing) |
|
||||
| spec_reference | from `customer_spec.code [+ " Rev " + revision]` (existing) |
|
||||
| customer_spec_id | `job.customer_spec_id` (existing) |
|
||||
| quantity_shipped | `qty_done - qty_scrapped` (existing) |
|
||||
| **nc_quantity** | **`qty_scrapped + qty_visual_inspection_rejects`** (NEW) |
|
||||
| **process_description** | **`job.recipe_id.name`** (NEW; was broken — `coating` was undefined) |
|
||||
| **certified_by_id** | **`customer_spec.signer_user_id` OR `company.x_fc_default_coc_signer_id`** (NEW) |
|
||||
| **contact_partner_id** | **`partner.x_fc_default_coc_contact_id`** (NEW) |
|
||||
|
||||
## Gate logic — `action_issue()` (added in sequence before `state = 'issued'`)
|
||||
|
||||
1. **process_description present** — raise with hint to set coating-config / fill manually.
|
||||
2. **certified_by_id present** — raise with hint to set company default.
|
||||
3. **contact_partner_id present AND `email` non-empty** — raise with specific hint.
|
||||
4. **qty reconciliation** — defensive; reads `x_fc_job_id` if linked.
|
||||
|
||||
Order: cheapest checks first; first failure wins.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Job has no recipe_id | `process_description = False` → action_issue blocks → manager fills manually. |
|
||||
| Company has no default signer | `certified_by_id` blank → action_issue blocks. |
|
||||
| Partner has no default contact | `contact_partner_id` blank → action_issue blocks. |
|
||||
| Contact has no email | Action_issue blocks specifically on email. |
|
||||
| Customer-spec overrides company signer | `customer_spec.signer_user_id` wins (already used by signature unification). |
|
||||
| Multi-line SO with different recipes | First line with a recipe wins for process_description; manager can override. |
|
||||
| Re-running `_fp_create_certificates` | Idempotent by (job_id, certificate_type); NEW fields only set on initial create. |
|
||||
| Older jobs with NULL `qty_visual_inspection_rejects` | Coerce to 0; no migration needed. |
|
||||
| Receiving never existed (internal rework) | Mark_done blocks; manager bypass via `fp_skip_qty_reconcile=True`. |
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- WO-30040 itself (already `done`, no cert) is not auto-fixed by this change.
|
||||
- New server action **"Generate missing certs for closed jobs"** walks `fp.job` records where `state='done'` AND `_resolve_required_cert_types()` is non-empty AND no matching cert exists. Surfaced in the Jobs menu so the user can run once after deploy.
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** (in `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` and new `fusion_plating_certificates/tests/test_action_issue_gates.py`):
|
||||
|
||||
- `test_mark_done_blocks_on_blank_qty_received`
|
||||
- `test_mark_done_blocks_on_qty_received_mismatch`
|
||||
- `test_mark_done_passes_with_clean_qty_reconcile`
|
||||
- `test_mark_done_bypass_skips_qty_received_check`
|
||||
- `test_create_cert_resolves_recipe_name` (replaces "coating" wording)
|
||||
- `test_create_cert_handles_job_with_no_recipe`
|
||||
- `test_create_cert_prefills_signer_from_company`
|
||||
- `test_create_cert_prefills_signer_from_customer_spec`
|
||||
- `test_create_cert_prefills_contact_from_partner`
|
||||
- `test_create_cert_computes_nc_quantity`
|
||||
- `test_create_cert_handles_null_visual_rejects`
|
||||
- `test_action_issue_blocks_on_missing_process_description`
|
||||
- `test_action_issue_blocks_on_missing_certified_by`
|
||||
- `test_action_issue_blocks_on_missing_contact`
|
||||
- `test_action_issue_blocks_on_contact_without_email`
|
||||
- `test_action_issue_blocks_on_qty_mismatch`
|
||||
- `test_action_issue_passes_when_all_data_present`
|
||||
- `test_create_cert_idempotency`
|
||||
|
||||
**Manual verification on entech (post-deploy):**
|
||||
|
||||
1. Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs.
|
||||
2. Try `action_issue` → expect blockers for unset defaults.
|
||||
3. Configure defaults; retry → cert issues, PDF renders, attaches.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Push to `K:/Github/Odoo-Modules/fusion_plating/` (git path).
|
||||
- Mirror to docker mount as needed.
|
||||
- Update on entech LXC 111 via the deploy commands in `project_entech_session_handoff.md`.
|
||||
- Module install order: `fusion_plating` → `fusion_plating_certificates` → `fusion_plating_jobs`.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase A — Shipping Carrier Foundation
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Full shipping integration (Phases A–F). This spec covers Phase A only — the field-level foundation linking plating records to `fusion_shipping`'s existing shipment infrastructure.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the free-text `carrier_name` on `fp.receiving` with a proper M2O to `delivery.carrier`, and link both `fp.receiving` and `fp.delivery` to the `fusion.shipment` model that already exists in `fusion_shipping`. After Phase A, the receiver can pick a carrier from a 15-option dropdown and create a draft outbound shipment record — wiring is in place for Phase B (manual label entry) and Phase E (auto-label generation at receiving time).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Weight, dimensions, label PDF, tracking number on `fp.receiving` / `fp.delivery` themselves — these live on the linked `fusion.shipment` record (already implemented by `fusion_shipping`).
|
||||
- Bridge module (Phase C), Purolator integration (Phase D), at-receiving auto-label (Phase E), printer hookup (Phase F).
|
||||
- Modifying `fusion_shipping`'s existing models — Phase A is additive on the plating side only.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Carrier field: M2O to `delivery.carrier` (not Selection) | Matches `fusion_shipping`'s framework; allows API integration on the same record without conversion. |
|
||||
| D2 | Architecture: mirror fields on both `fp.receiving` and `fp.delivery`, auto-sync at delivery creation | Self-contained records; loose coupling; shipping crew can override per-stage. |
|
||||
| D3 | Source of truth for weight / dims / label / tracking: `fusion.shipment`, NOT mirrored on plating records | Shipment model already has every field; avoid duplicating + the sync logic. |
|
||||
| D4 | 15 carriers seeded as `delivery.carrier` data records (XML), all `delivery_type='fixed'` initially | Phase D will flip Purolator (and any others added) to their integration types. Manual carriers (Customer Pickup etc.) stay `fixed` permanently. |
|
||||
| D5 | Existing `carrier_name` (Char) and `carrier_tracking` (Char) kept as legacy | Migration populates `x_fc_carrier_id` by name match; unmatched text stays for operator review. |
|
||||
| D6 | `fusion_plating_receiving` + `fusion_plating_logistics` gain hard `fusion_shipping` dependency | The M2O to `fusion.shipment` requires the model to exist; no conditional compilation. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ fp.receiving ─────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer (smart-button counter) │
|
||||
│ Existing: carrier_name (Char) — legacy, populated by migration │
|
||||
│ Existing: carrier_tracking (Char) — legacy │
|
||||
│ │
|
||||
│ ACTION: action_create_outbound_shipment() │
|
||||
│ → creates fusion.shipment with sale_order_id + carrier_id │
|
||||
│ → idempotent: returns existing if already linked │
|
||||
│ ACTION: action_view_outbound_shipment() │
|
||||
│ → opens linked fusion.shipment in form view │
|
||||
│ ONCHANGE: x_fc_carrier_id propagates to linked shipment │
|
||||
│ → only if shipment.status == 'draft' │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
copy at delivery creation (fp.job._fp_create_delivery)
|
||||
│
|
||||
▼
|
||||
|
||||
┌─ fp.delivery ──────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer │
|
||||
│ Same ACTIONs and propagation as fp.receiving │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ delivery.carrier (seed data) ─────────────────────────────────────┐
|
||||
│ Already on entech: Standard delivery, Canada Post, Customer Pickup│
|
||||
│ Phase A adds (delivery_type='fixed', product_id=delivery. │
|
||||
│ product_product_delivery): │
|
||||
│ UPS, FedEx, USPS, DHL, Purolator, CCT, Canpar Express, │
|
||||
│ GLS Canada, Loomis Express, Day & Ross, Dicom Transportation, │
|
||||
│ Customer Drop-off, Local Delivery │
|
||||
│ Idempotent: XML uses noupdate=1 + record ids check existing names │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Field details
|
||||
|
||||
**On `fp.receiving`:**
|
||||
```python
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
help='The shipment record carrying weight, dimensions, label PDF, '
|
||||
'and tracking. Created via the "Create Outbound Shipment" '
|
||||
'button.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
```
|
||||
|
||||
Identical pair on `fp.delivery`.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_receiving` | 19.0.3.9.0 → 19.0.3.10.0 | manifest (+depends), `models/fp_receiving.py`, `views/fp_receiving_views.xml`, `data/delivery_carrier_seed_data.xml` (NEW), `migrations/19.0.3.10.0/post-migrate.py` (NEW), `tests/test_carrier_fields.py` (NEW) |
|
||||
| `fusion_plating_logistics` | bump | manifest (+depends), `models/fp_delivery.py`, `views/fp_delivery_views.xml`, `tests/test_delivery_shipping_fields.py` (NEW) |
|
||||
| `fusion_plating_jobs` | bump | `models/fp_job.py` (mirror at `_fp_create_delivery`), extend existing milestone-cascade test class |
|
||||
|
||||
## Migration logic (post-migrate)
|
||||
|
||||
```python
|
||||
def migrate(cr, version):
|
||||
# Name-match existing carrier_name text → delivery.carrier.name
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving r
|
||||
SET x_fc_carrier_id = dc.id
|
||||
FROM delivery_carrier dc
|
||||
WHERE r.carrier_name IS NOT NULL
|
||||
AND r.carrier_name <> ''
|
||||
AND r.x_fc_carrier_id IS NULL
|
||||
AND LOWER(TRIM(r.carrier_name)) =
|
||||
LOWER(TRIM((dc.name->>'en_US')))
|
||||
""")
|
||||
```
|
||||
|
||||
`delivery.carrier.name` is jsonb in Odoo 19 (translatable). The migration strips to `en_US` for the match.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Receiving has no SO link | Shipment creation works without `sale_order_id` (set_null on shipment side). |
|
||||
| Carrier picked but no shipment yet | Smart button reads "Create Outbound Shipment" → one click creates + opens. |
|
||||
| User changes carrier on receiving after shipment exists | Onchange propagates only when `shipment.status == 'draft'`. Confirmed/shipped shipments are left alone. |
|
||||
| Two receivings on same SO (split deliveries) | Each has its own `x_fc_outbound_shipment_id`. Mirror picks first one; user can change. |
|
||||
| Migration finds ambiguous name | Case-insensitive exact match only. Unmatched stays in `carrier_name` text. |
|
||||
| Shipment is deleted | `ondelete='set null'` — receiving keeps carrier but smart button reverts to "Create". |
|
||||
| `fusion_shipping` not installed | Manifest dependency fails fast on module load — correct failure mode. |
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** in `fusion_plating_receiving/tests/test_carrier_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_receiving`
|
||||
- `test_outbound_shipment_id_field_exists_on_receiving`
|
||||
- `test_action_create_outbound_shipment_creates_draft`
|
||||
- `test_action_create_outbound_shipment_idempotent`
|
||||
- `test_carrier_id_change_propagates_to_draft_shipment`
|
||||
- `test_carrier_id_change_does_not_propagate_to_confirmed_shipment`
|
||||
|
||||
**Unit tests** in `fusion_plating_logistics/tests/test_delivery_shipping_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_delivery`
|
||||
- `test_outbound_shipment_id_field_exists_on_delivery`
|
||||
|
||||
**Unit tests** extending `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
|
||||
- `test_create_delivery_mirrors_carrier_from_receiving`
|
||||
- `test_create_delivery_mirrors_outbound_shipment`
|
||||
- `test_create_delivery_no_receiving_no_mirror`
|
||||
|
||||
**Manual verification post-deploy:**
|
||||
1. Open RCV-30041 → carrier dropdown shows 15 options.
|
||||
2. Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
|
||||
3. Confirm `x_fc_outbound_shipment_id` is populated on RCV-30041.
|
||||
4. Confirm same fields/buttons on a fresh fp.delivery record auto-created via mark-done.
|
||||
|
||||
## Deployment
|
||||
|
||||
- 3 module upgrades: `fusion_plating_receiving`, `fusion_plating_logistics`, `fusion_plating_jobs`.
|
||||
- `fusion_shipping` is already installed — no action needed.
|
||||
- Migration runs automatically; spot-check by querying `fp_receiving.x_fc_carrier_id` post-deploy.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Phase C — Generate Label End-to-End
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Shipping integration phase 3 of 5 (after Phase A foundation; Phase B was merged in as a fallback path).
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the at-receiving outbound-label workflow: receiver enters weight + dimensions + picks carrier, clicks one button, system generates the carrier's shipping label PDF + tracking number (API when available, manual fallback when not). Operator prints the label, ships the box, customer gets the tracking link by email and on the portal.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
[Receiver] enters weight + dims + picks carrier on RECEIVING FORM
|
||||
↓
|
||||
Click "Generate Outbound Label"
|
||||
↓
|
||||
Carrier has API integration?
|
||||
├─ YES → carrier.send_shipping([picking]) → label PDF + tracking
|
||||
│ saved to fusion.shipment
|
||||
│
|
||||
└─ NO/API FAILS → open manual entry wizard
|
||||
operator pastes PDF + types tracking
|
||||
saved to fusion.shipment
|
||||
↓
|
||||
[Shipping] "Print Label" button → opens PDF in browser print dialog
|
||||
↓
|
||||
[Notification] fp.notification.template fires (event: shipment_labeled)
|
||||
with tracking_number + tracking_url placeholders
|
||||
↓
|
||||
[Portal] Job page renders tracking_number as clickable link to
|
||||
carrier.tracking_url template
|
||||
```
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Weight + dimensions live on fp.receiving as `related=` fields → fusion.shipment | Receiver enters them on the receiving form (their workflow); shipment stays as source of truth. |
|
||||
| D2 | One button: "Generate Outbound Label". API path is primary; manual is fallback | One UX, two branches inside. No separate "Manual Label Entry" flow surfaced to operator. |
|
||||
| D3 | Manual fallback opens automatically on API failure OR when carrier has no API integration | Operator never has to think about which path to take. |
|
||||
| D4 | Adapter approach: synthesize a stock.picking just for the API call (locked Phase C question) | Max reuse of existing fusion_shipping methods; picking is hidden from operator UIs. |
|
||||
| D5 | Notification trigger fires whenever tracking_number gets set (API OR manual), not at label generation | Same downstream behavior regardless of how the label was obtained. |
|
||||
| D6 | Portal renders tracking as `<a href="...">` using delivery.carrier.tracking_url template | Standard Odoo carrier tracking URL pattern. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Purolator integration (Phase D — independent).
|
||||
- Auto-print to a network printer (Phase F).
|
||||
- Multi-package shipments (single package per shipment in Phase C).
|
||||
- Rate quote / carrier shopping (just label generation).
|
||||
- Job sticker auto-print at same moment (Phase F).
|
||||
- Return labels (different API call; can come later).
|
||||
|
||||
## Files changing
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_receiving/models/fp_receiving.py` | NEW related fields: `x_fc_weight`, `x_fc_weight_uom`, `x_fc_length`, `x_fc_width`, `x_fc_height`, `x_fc_dim_uom` (related to fusion.shipment / fusion.order.package). NEW `x_fc_shipping_picking_id` (M2O stock.picking, back-link). NEW `action_generate_outbound_label()`. NEW `action_print_label()`. NEW helper `_fp_build_shipping_picking()`. |
|
||||
| `fusion_plating_receiving/wizards/__init__.py` (NEW) | Wizard module init. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard.py` (NEW) | Transient model: `receiving_id`, `label_pdf` (Binary), `label_filename` (Char), `tracking_number` (Char), `note` (Char — context why manual fallback). `action_confirm()` writes to fusion.shipment + closes wizard. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml` (NEW) | Wizard form view. |
|
||||
| `fusion_plating_receiving/views/fp_receiving_views.xml` | Add weight + dimensions group (Reception group). Add header buttons "Generate Outbound Label" + "Print Label". |
|
||||
| `fusion_plating_receiving/__manifest__.py` | Bump 19.0.3.10.0 → 19.0.3.11.0. Register new wizard files. Add `stock`, `delivery` to depends. |
|
||||
| `fusion_plating_receiving/security/ir.model.access.csv` | ACLs for the new wizard models. |
|
||||
| `fusion_plating_notifications/data/notification_templates.xml` (EXISTING — extend) | Add `shipment_labeled` trigger entry with default template. |
|
||||
| `fusion_plating_portal/views/fp_portal_templates.xml` (EXISTING — extend) | Render tracking_number as `<a>` link on job page. |
|
||||
| Tests | Three new files + extensions. |
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Related fields on fp.receiving
|
||||
|
||||
```python
|
||||
x_fc_weight = fields.Float(
|
||||
related='x_fc_outbound_shipment_id.weight',
|
||||
readonly=False, store=False,
|
||||
)
|
||||
# Similar for length/width/height — these come from fusion.order.package, not fusion.shipment directly.
|
||||
# Decision: write to the shipment's first package (auto-create if absent).
|
||||
```
|
||||
|
||||
Wait — `fusion.shipment.weight` exists, but length/width/height live on `fusion.order.package`. The shipment has a one2many relationship via `sale_order_id.package_ids`. For Phase C, the simplest path: store dimensions on the shipment by adding them as fields, OR auto-create a package per shipment.
|
||||
|
||||
**Resolved:** Phase C reads/writes weight + dimensions on the shipment record directly. If `fusion.shipment` doesn't have dimension fields, we add them via inheritance from this side (this is in fusion_shipping's model — would require touching it). Alternative: store on a synthetic fusion.order.package.
|
||||
|
||||
**Decision for spec:** add length/width/height + dim_uom as new fields directly on `fusion.shipment` via inheritance from `fusion_plating_receiving` (or move to fusion_shipping if appropriate during implementation). Cleaner than the package indirection for a single-package flow.
|
||||
|
||||
### action_generate_outbound_label
|
||||
|
||||
```python
|
||||
def action_generate_outbound_label(self):
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs() # carrier, weight, recipient addr, shipment exists
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('Carrier "%s" has no API integration. Enter the '
|
||||
'label PDF and tracking number manually.') % carrier.name,
|
||||
)
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping([picking]) # standard Odoo call
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except Exception as e:
|
||||
_logger.warning("Label gen failed for %s: %s", self.name, e)
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('API call failed: %s\n\nEnter the label manually below.') % e,
|
||||
)
|
||||
return self._fp_open_outbound_shipment_action() # smart-button target
|
||||
```
|
||||
|
||||
### Manual fallback wizard
|
||||
|
||||
Small transient model `fp.label.manual.wizard` with:
|
||||
- `receiving_id` (M2O fp.receiving, required)
|
||||
- `label_pdf` (Binary, required at confirm time)
|
||||
- `label_filename` (Char)
|
||||
- `tracking_number` (Char, required at confirm time)
|
||||
- `note` (Char, readonly — explanatory message)
|
||||
|
||||
`action_confirm()`:
|
||||
- Validate label + tracking present.
|
||||
- Write to the receiving's linked fusion.shipment: `label_attachment_id` (create ir.attachment) + `tracking_number` + `status='confirmed'`.
|
||||
- Close wizard, post chatter to receiving.
|
||||
|
||||
### Synthetic stock.picking
|
||||
|
||||
```python
|
||||
def _fp_build_shipping_picking(self):
|
||||
self.ensure_one()
|
||||
Picking = self.env['stock.picking']
|
||||
warehouse = self.env['stock.warehouse'].search([
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
picking_type = warehouse.out_type_id
|
||||
so = self.sale_order_id
|
||||
return Picking.create({
|
||||
'partner_id': so.partner_shipping_id.id,
|
||||
'picking_type_id': picking_type.id,
|
||||
'origin': so.name,
|
||||
'sale_id': so.id,
|
||||
'carrier_id': self.x_fc_carrier_id.id,
|
||||
# Synthetic single move from a generic shipping product:
|
||||
'move_ids': [(0, 0, {
|
||||
'name': 'Outbound Shipment %s' % self.name,
|
||||
'product_id': self.env.ref('product.product_product_4').id, # default service-type
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': self.env.ref('uom.product_uom_unit').id,
|
||||
'location_id': picking_type.default_location_src_id.id,
|
||||
'location_dest_id': picking_type.default_location_dest_id.id,
|
||||
})],
|
||||
'x_fc_fp_receiving_id': self.id, # back-link, defined on stock.picking
|
||||
})
|
||||
```
|
||||
|
||||
Then immediately after `send_shipping` succeeds:
|
||||
- `picking.action_confirm()` + `picking.action_assign()` + `picking.button_validate()` to take the picking to 'done' state (so it doesn't sit as draft in operator views).
|
||||
|
||||
### Notification trigger
|
||||
|
||||
Add event `shipment_labeled` to fp.notification.template selection. Default email template:
|
||||
```
|
||||
Subject: Your order is ready to ship — Tracking #{{ tracking_number }}
|
||||
Body: Hi {{ partner_name }},
|
||||
Your order for SO {{ sale_order_name }} has shipped.
|
||||
Tracking number: {{ tracking_number }}
|
||||
Track here: {{ tracking_url }}
|
||||
```
|
||||
|
||||
Fired by an `on_write` hook on `fusion.shipment` when `tracking_number` transitions from empty to non-empty.
|
||||
|
||||
### Portal display
|
||||
|
||||
In `fusion_plating_portal/views/fp_portal_templates.xml`, locate the job-card / job-detail rendering. Wherever tracking_ref is shown, replace with:
|
||||
```xml
|
||||
<t t-if="job.delivery_id and job.delivery_id.x_fc_outbound_shipment_id">
|
||||
<a t-att-href="job.delivery_id.x_fc_outbound_shipment_id.tracking_url"
|
||||
target="_blank">
|
||||
<t t-esc="job.delivery_id.x_fc_outbound_shipment_id.tracking_number"/>
|
||||
</a>
|
||||
</t>
|
||||
```
|
||||
`tracking_url` is a computed field on `fusion.shipment` that resolves the `delivery.carrier.tracking_url` template (already exists in Odoo).
|
||||
|
||||
## Test plan
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_generate_label_blocks_when_no_carrier` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_shipment` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_weight` | UserError raised |
|
||||
| `test_generate_label_routes_manual_for_fixed_carrier` | Wizard opens, no API call made |
|
||||
| `test_generate_label_calls_api_for_integrated_carrier` | carrier.send_shipping called once (mocked) |
|
||||
| `test_generate_label_writes_result_to_shipment_on_success` | tracking_number + label_attachment populated |
|
||||
| `test_generate_label_falls_back_to_wizard_on_api_failure` | Mock raises → wizard opens with note |
|
||||
| `test_manual_wizard_confirm_writes_shipment` | label + tracking saved; status confirmed |
|
||||
| `test_print_label_returns_attachment_action` | Action dict points to the label PDF |
|
||||
| `test_notification_fires_when_tracking_set` | fp.notification.template._dispatch called with shipment_labeled event |
|
||||
| `test_portal_renders_tracking_link` | Render contains `<a href="...">` with tracking URL |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| No warehouse configured | UserError: "No warehouse for the company — configure one in Settings > Warehouse." |
|
||||
| sale_order.partner_shipping_id missing | Falls back to `sale_order.partner_id`. |
|
||||
| Multi-package SO (rare) | Phase C single-package only. Multi-package raises with a "Phase E" note. |
|
||||
| Carrier API timeout | Caught as `Exception` in the try block; manual wizard opens with error in note. |
|
||||
| Operator generates label twice | Second call sees existing tracking, refuses and prompts to void/regenerate. |
|
||||
| Customer changes weight after label generated | Block weight edit when shipment.status == 'confirmed'. Manager can void shipment to re-generate. |
|
||||
|
||||
## Deployment
|
||||
|
||||
3 modules upgraded: `fusion_plating_receiving` (main), `fusion_plating_notifications` (trigger), `fusion_plating_portal` (link).
|
||||
|
||||
Manual verification on entech:
|
||||
1. Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
|
||||
2. Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has `delivery_type='fixed'` — manual wizard opens.
|
||||
3. Paste a sample PDF + tracking number in wizard. Confirm.
|
||||
4. Verify fusion.shipment has the label and tracking saved.
|
||||
5. Verify Print Label button works (opens PDF).
|
||||
6. (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Receiving Gate on Step Start / Finish
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering observation:** WO-30040 closed with `qty_received` blank and chatter warnings on Post-plate Inspection / Final Inspection ("Step started before parts were received"). The existing soft chatter warning is not strong enough — operators ignore it and the job still completes.
|
||||
|
||||
## Goal
|
||||
|
||||
Block step transitions (start AND finish) on any non-Contract-Review step until the SO's receiving record is closed. Future-proof for custom steps added later. Allow manager bypass via the existing `fp_skip_*` context-flag pattern.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Scope: all step kinds EXCEPT Contract Review | CR is paperwork — doesn't need parts on the floor. Every other step (including future custom steps) involves physical work. |
|
||||
| D2 | Timing: both `button_start` AND `button_finish` | Strongest. Operator can't begin OR complete physical work without receiving closed. Catches both "started too early" and "started before parts arrived, completed before they did". |
|
||||
| D3 | Threshold: `sale_order.x_fc_receiving_status == 'received'` | Post-Sub-8 (and the 2026-05-18 cleanup), `received` is the terminal receiving state. `not_received` and `partial` block. |
|
||||
| D4 | Manager bypass: `fp_skip_receiving_gate=True` context flag | Matches existing `fp_skip_*` pattern (qty_reconcile, qc_gate, step_gate, bake_gate). Auditor trail via chatter on the state transition. |
|
||||
| D5 | Implementation: single helper called from both buttons | Mirrors existing `_fp_check_contract_review_complete` pattern. DRY — same code tested once. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Receiving model's state machine (already correct post-Sub-8).
|
||||
- The `_update_so_receiving_status` mapping (already maps `closed → received`).
|
||||
- Other gates (qty_reconcile, qc_gate, bake_gate) — untouched.
|
||||
- Schema changes — pure behavior change.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
fp.job.step.button_start fp.job.step.button_finish
|
||||
1. Sequential-order gate (existing) 1. _fp_check_contract_review_complete (existing)
|
||||
2. _fp_check_receiving_gate() ← NEW 2. _fp_check_receiving_gate() ← NEW
|
||||
3. Contract Review auto-open (existing) 3. super().button_finish() + downstream (existing)
|
||||
4. Racking auto-open (existing)
|
||||
5. Standard path + serial promote (existing)
|
||||
[old soft chatter warning removed]
|
||||
```
|
||||
|
||||
## Helper method
|
||||
|
||||
```python
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review. Fires from both
|
||||
button_start and button_finish. Manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue # internal rework — gate doesn't apply
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
continue # defensive: configurator not installed
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(so.x_fc_receiving_status, so.x_fc_receiving_status or 'unknown')
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received yet '
|
||||
'(SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > Receiving) '
|
||||
'before starting or finishing work on this step. A '
|
||||
'manager can bypass this gate for documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
```
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs` | 19.0.10.12.0 → 19.0.10.13.0 | `models/fp_job_step.py` (helper + 2 callers + remove soft warning); `tests/test_fp_job_milestone_cascade.py` (new TestReceivingGate class) |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Step on job with no SO link (internal rework) | Gate doesn't fire — `continue`. |
|
||||
| Configurator module not installed (`x_fc_receiving_status` field absent) | Gate doesn't fire — `continue`. |
|
||||
| Contract Review step on `not_received` SO | Gate exempt; step proceeds (paperwork). |
|
||||
| Step on `partial` SO | Blocks — `partial` is not `received`. Operator waits for all boxes to land. |
|
||||
| Manager bypass via context | All gates skipped uniformly. Audit trail preserved via state-transition tracking. |
|
||||
|
||||
## Test plan
|
||||
|
||||
8 unit tests in new `TestReceivingGate` class in `test_fp_job_milestone_cascade.py`:
|
||||
|
||||
- `test_start_blocks_when_not_received`
|
||||
- `test_start_allows_when_received`
|
||||
- `test_start_skips_contract_review`
|
||||
- `test_start_bypass_via_context`
|
||||
- `test_finish_blocks_when_not_received`
|
||||
- `test_finish_allows_when_received`
|
||||
- `test_finish_skips_contract_review`
|
||||
- `test_finish_bypass_via_context`
|
||||
|
||||
**Manual verification on entech post-deploy:**
|
||||
1. Open SO-30041 (currently `not_received`) → fp.job → try `button_start` on first non-CR step → UserError raised.
|
||||
2. Close the receiving record (counted → staged → closed) → SO flips to `received`.
|
||||
3. Re-try `button_start` → succeeds.
|
||||
4. Repeat the start/finish flow with `fp_skip_receiving_gate=True` from a shell to verify bypass.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- The old soft chatter warning at fp_job_step.py:894-907 is removed. The information is no longer useful — it was a soft warning for a behavior we're now hard-blocking. The job's chatter still tracks the state transition via Odoo's tracking.
|
||||
- Jobs already in `in_progress` on `not_received` SOs at deploy time: any future button_finish will block. Manager must either close receiving OR use bypass.
|
||||
- No DB migration needed.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Single-module deploy to entech LXC 111 (`fusion_plating_jobs`).
|
||||
- No restart of dependent modules required.
|
||||
- Verify with manual flow above.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.13.0.2',
|
||||
'version': '19.0.13.0.3',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -420,14 +420,16 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'partial' or recv_status == 'received':
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state.
|
||||
# Inspection happens in the recipe's racking step, not
|
||||
# in receiving.
|
||||
if not so.x_fc_assigned_manager_id:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
# Manager assigned, MOs exist → in production
|
||||
so.x_fc_workflow_stage = 'in_production'
|
||||
continue
|
||||
|
||||
@@ -450,17 +452,23 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving as accepted; this unlocks manager assignment."""
|
||||
"""Mark receiving as accepted; this unlocks manager assignment.
|
||||
|
||||
Sub 8: receiving's terminal state is 'closed' (post-Sub-8) or
|
||||
'accepted' (legacy). Either maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state == 'inspecting':
|
||||
rec.state = 'accepted'
|
||||
# flip SO receiving status to 'inspected' if possible
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.6.1.0',
|
||||
'version': '19.0.6.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -37,6 +37,7 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_certificates_menu.xml',
|
||||
'wizards/fp_cert_void_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -88,6 +88,24 @@ class FpCertificate(models.Model):
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
|
||||
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
# linked QC check. That works when the operator uploaded it via the
|
||||
# tablet, but managers issuing certs after the fact don't want to
|
||||
# navigate to the QC. This pair of fields gives them a direct upload
|
||||
# path on the cert form. When set, _fp_merge_thickness_into_pdf uses
|
||||
# this in preference to the QC-side upload.
|
||||
x_fc_local_thickness_pdf = fields.Binary(
|
||||
string='Fischerscope PDF (Upload Here)',
|
||||
attachment=True,
|
||||
help='Drop the Fischerscope / XDAL 600 XRF export PDF here. '
|
||||
'When the cert is issued it will be appended as page 2 of '
|
||||
'the CoC. Overrides any PDF on the linked QC check.',
|
||||
)
|
||||
x_fc_local_thickness_pdf_filename = fields.Char(
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# ---- Material traceability (T2.3) ----
|
||||
batch_ids = fields.Many2many(
|
||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
||||
@@ -330,6 +348,23 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Lazy-fill from partner defaults BEFORE running the gates.
|
||||
# Without this, a cert created before partner.x_fc_default_*
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
)
|
||||
if (not rec.certified_by_id
|
||||
and rec.company_id
|
||||
and 'x_fc_owner_user_id' in rec.company_id._fields
|
||||
and rec.company_id.x_fc_owner_user_id):
|
||||
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
@@ -340,24 +375,127 @@ class FpCertificate(models.Model):
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Aerospace / Nadcap customers: actual thickness readings
|
||||
# must be on file BEFORE the cert is issued. The flag lives
|
||||
# on the partner so commercial customers aren't blocked.
|
||||
if (rec.partner_id
|
||||
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_strict_thickness_required
|
||||
and rec.certificate_type == 'coc'):
|
||||
if not rec.thickness_reading_ids:
|
||||
# Process description (what was done to the parts). Without
|
||||
# it the cert PDF just shows blank process text — customer
|
||||
# has no idea what they paid for. Auto-filled from the
|
||||
# recipe at create time; manager can override before issuing.
|
||||
if not rec.process_description:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Process '
|
||||
'Description is blank.\n\nFill it manually (e.g. '
|
||||
'"ELECTROLESS NICKEL PLATING PER AMS 2404") or '
|
||||
'assign a recipe to the job so it auto-fills.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Signing authority — the human who attests the work. Auto-
|
||||
# filled from per-spec signer_user_id, falling back to
|
||||
# company.x_fc_owner_user_id. If neither is configured, the
|
||||
# manager must pick before issuing.
|
||||
if not rec.certified_by_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Certified By '
|
||||
'is not set.\n\nPick the signing authority, or have '
|
||||
'an admin configure the company\'s Certificate Owner '
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||
)
|
||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
||||
if not (has_readings or has_qc_fischer_pdf or has_local_pdf):
|
||||
type_label = (
|
||||
_('Thickness Report')
|
||||
if rec.certificate_type == 'thickness_report'
|
||||
else _('CoC')
|
||||
)
|
||||
raise UserError(_(
|
||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||
'requires actual thickness readings on every CoC '
|
||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||
'against the job for SO %(so)s via the Tablet Station '
|
||||
'before issuing.'
|
||||
'Cannot issue %(type)s "%(name)s" — customer '
|
||||
'"%(cust)s" requires thickness data on every '
|
||||
'%(type)s. No readings, no Fischerscope PDF on '
|
||||
'the linked QC, and no local Fischerscope upload '
|
||||
'on this cert.\n\nUse the Issue Certs wizard '
|
||||
'from the work order to upload the Fischerscope '
|
||||
'report, or log readings against the job for '
|
||||
'SO %(so)s via the Tablet Station.'
|
||||
) % {
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': partner.name if partner else '?',
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
})
|
||||
# Defensive qty reconciliation — should already be guaranteed
|
||||
# by fp.job.button_mark_done's gate, but re-checked here so
|
||||
# certs created outside the job flow (manual, scripts) still
|
||||
# can't issue with a mismatched job. No bypass — qty integrity
|
||||
# is non-negotiable at issue.
|
||||
job = (rec.x_fc_job_id
|
||||
if 'x_fc_job_id' in rec._fields else False)
|
||||
if job and job.qty_received:
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted) > 0.0001:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — job '
|
||||
'%(job)s qty mismatch (received %(r)g vs '
|
||||
'accounted-out %(a)g). Reconcile job '
|
||||
'quantities before issuing.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name,
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
'job': job.name,
|
||||
'r': job.qty_received,
|
||||
'a': accounted,
|
||||
})
|
||||
rec.state = 'issued'
|
||||
# Generate the CoC PDF and attach it so action_send_to_customer
|
||||
@@ -445,35 +583,48 @@ class FpCertificate(models.Model):
|
||||
self.ensure_one()
|
||||
if self.certificate_type != 'coc':
|
||||
return None
|
||||
# Find the linked job. fp.certificate has either x_fc_job_id
|
||||
# (preferred — added by fusion_plating_jobs) or job_id (older).
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
# Find a passed QC on this job with an uploaded Fischerscope PDF.
|
||||
# Prefer state=passed; fall through to any with a PDF.
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
# Resolution order for the source of the Fischerscope bytes:
|
||||
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
|
||||
# dropped it directly on the cert form
|
||||
# 2. Linked QC's thickness_report_pdf_id — operator uploaded
|
||||
# via the tablet during inspection
|
||||
# Either path yields the same merged-PDF outcome.
|
||||
fischer_bytes = b''
|
||||
qc = False
|
||||
if self.x_fc_local_thickness_pdf:
|
||||
try:
|
||||
fischer_bytes = _b64.b64decode(
|
||||
self.x_fc_local_thickness_pdf or b''
|
||||
)
|
||||
except Exception:
|
||||
fischer_bytes = b''
|
||||
if not fischer_bytes:
|
||||
# Fall through to the QC-side PDF.
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
if not fischer_bytes:
|
||||
return None
|
||||
# Merge — pypdf is the modern name; PyPDF2 still works on older
|
||||
@@ -519,9 +670,13 @@ class FpCertificate(models.Model):
|
||||
'CoC-only.', self.name,
|
||||
)
|
||||
return None
|
||||
source = (
|
||||
_('cert upload') if self.x_fc_local_thickness_pdf
|
||||
else _('QC %s') % (qc.name if qc else '?')
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Fischerscope thickness report from QC %s appended to CoC PDF.'
|
||||
) % qc.name)
|
||||
'Fischerscope thickness report (%s) appended to CoC PDF.'
|
||||
) % source)
|
||||
return merged
|
||||
|
||||
def action_void(self):
|
||||
@@ -533,6 +688,33 @@ class FpCertificate(models.Model):
|
||||
rec.state = 'voided'
|
||||
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
|
||||
|
||||
def action_open_void_wizard(self):
|
||||
"""Open the void-reason wizard. Bound to the Void header button
|
||||
instead of action_void directly so the manager always supplies a
|
||||
written reason (the underlying action_void still blocks on a
|
||||
blank reason as a defensive last-line check)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.state)
|
||||
Wizard = self.env.get('fp.cert.void.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Void wizard not available. Reinstall '
|
||||
'fusion_plating_certificates.'
|
||||
))
|
||||
wiz = Wizard.create({'cert_id': self.id})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Void %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_traceability(self):
|
||||
"""Show the batches (and their chemistry logs) that produced
|
||||
these parts — auditor's dream, customer's RMA friend."""
|
||||
|
||||
@@ -98,3 +98,18 @@ class ResPartner(models.Model):
|
||||
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
)
|
||||
|
||||
@@ -5,3 +5,5 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_action_issue_gates
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issuance-gate tests for fp.certificate.action_issue.
|
||||
|
||||
Covers the 2026-05-18 hardening that adds blocking checks for
|
||||
process_description, certified_by_id, contact_partner_id (with email),
|
||||
and qty reconciliation. See
|
||||
docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md.
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestActionIssueGates(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Signer',
|
||||
'login': 'signer_certissue',
|
||||
'email': 'signer@example.com',
|
||||
})
|
||||
cls.contact_with_email = cls.env['res.partner'].create({
|
||||
'name': 'Anne Recipient',
|
||||
'email': 'anne@cust.example',
|
||||
})
|
||||
cls.contact_no_email = cls.env['res.partner'].create({
|
||||
'name': 'Carl NoEmail',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'IssueCust',
|
||||
'is_company': True,
|
||||
})
|
||||
cls.contact_with_email.parent_id = cls.partner.id
|
||||
cls.contact_no_email.parent_id = cls.partner.id
|
||||
|
||||
def _make_cert(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
|
||||
# ---- the existing gate still works (spec_reference) ----
|
||||
|
||||
def test_blocks_on_missing_spec_reference(self):
|
||||
cert = self._make_cert(spec_reference=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
|
||||
# ---- new gate: process_description ----
|
||||
|
||||
def test_blocks_on_missing_process_description(self):
|
||||
cert = self._make_cert(process_description=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: certified_by_id ----
|
||||
|
||||
def test_blocks_on_missing_certified_by(self):
|
||||
cert = self._make_cert(certified_by_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Certified By', str(exc.exception))
|
||||
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
|
||||
# ---- happy path ----
|
||||
|
||||
def test_passes_when_all_data_present(self):
|
||||
cert = self._make_cert()
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
# ---- order: spec_reference still wins (cheapest first) ----
|
||||
|
||||
def test_gate_order_spec_reference_first(self):
|
||||
# Multiple missing → spec_reference message surfaces first.
|
||||
cert = self._make_cert(
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
# And NOT the process_description message (gate hit first).
|
||||
self.assertNotIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: thickness_report cert needs thickness data ----
|
||||
|
||||
def test_blocks_thickness_report_with_no_data(self):
|
||||
"""A thickness_report cert with zero readings and no Fischerscope
|
||||
PDF is empty paper — must block at issue."""
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('thickness data', str(exc.exception).lower())
|
||||
|
||||
def test_thickness_report_passes_with_readings(self):
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
self.env['fp.thickness.reading'].create({
|
||||
'certificate_id': cert.id,
|
||||
'nip_mils': 0.4,
|
||||
})
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
def test_coc_does_not_require_thickness_data_by_default(self):
|
||||
"""Commercial CoC (no strict_thickness flag) should still pass
|
||||
even without readings — only thickness_report type is gated."""
|
||||
cert = self._make_cert(certificate_type='coc')
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
@@ -42,7 +42,7 @@
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_void" string="Void"
|
||||
<button name="action_open_void_wizard" string="Void"
|
||||
type="object" class="btn-danger"
|
||||
invisible="state != 'issued'"/>
|
||||
<button name="action_send_to_customer" string="Send to Customer"
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<field name="x_fc_send_bol" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
<p class="text-muted">
|
||||
Boilerplate text printed in the "Certification Statement"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_cert_void_wizard
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Void Certificate Wizard.
|
||||
|
||||
Opened from an issued cert's "Void" button. Prompts the manager for a
|
||||
written reason, then calls action_void on the cert with the reason
|
||||
populated. The cert's chatter records the void event with the reason
|
||||
inline via the existing _logger / message_post in action_void.
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCertVoidWizard(models.TransientModel):
|
||||
_name = 'fp.cert.void.wizard'
|
||||
_description = 'Fusion Plating — Void Certificate Wizard'
|
||||
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
void_reason = fields.Text(
|
||||
string='Void Reason',
|
||||
help='Why this certificate is being voided. Printed on the '
|
||||
'cert chatter and visible in audit trails. Required for '
|
||||
'AS9100 / Nadcap document control. Validation happens at '
|
||||
'confirm time so the wizard can open empty.',
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not (self.void_reason or '').strip():
|
||||
raise UserError(_(
|
||||
'Please enter a void reason before voiding. The reason '
|
||||
'is logged to the cert chatter and printed on the audit '
|
||||
'trail (AS9100 / Nadcap requirement).'
|
||||
))
|
||||
if self.cert_id.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.cert_id.state)
|
||||
# Write the reason FIRST so the cert's action_void gate passes.
|
||||
self.cert_id.void_reason = self.void_reason
|
||||
self.cert_id.action_void()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_void_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.void.wizard.form</field>
|
||||
<field name="model">fp.cert.void.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Void Certificate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Void Certificate <field name="cert_name"
|
||||
readonly="1"
|
||||
nolabel="1"
|
||||
class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
Voiding marks this certificate as no longer
|
||||
valid. The audit trail keeps the record visible
|
||||
but flagged. Required for AS9100 / Nadcap
|
||||
document control.
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="void_reason"
|
||||
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Void Certificate"
|
||||
class="btn-danger"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.4.0',
|
||||
'version': '19.0.21.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the 'inspected' value from sale_order.x_fc_receiving_status.
|
||||
|
||||
Sub 8 (2026-04-22) moved part inspection out of receiving and into the
|
||||
recipe's racking step. The SO-level receiving status no longer needs
|
||||
'inspected' as a terminal value — 'received' (boxes counted/staged/
|
||||
closed) is now the final state.
|
||||
|
||||
This migration flips any existing rows with the obsolete value to the
|
||||
new terminal value. On a freshly-installed instance there are zero rows;
|
||||
the migration is defensive for instances that had pre-Sub-8 records.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE sale_order
|
||||
SET x_fc_receiving_status = 'received'
|
||||
WHERE x_fc_receiving_status = 'inspected'
|
||||
""")
|
||||
@@ -74,8 +74,12 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
x_fc_receiving_status = fields.Selection(
|
||||
[('not_received', 'Not Received'), ('partial', 'Partial'),
|
||||
('received', 'Received'), ('inspected', 'Inspected')],
|
||||
('received', 'Received')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
help='State of the linked fp.receiving record(s). Inspection is '
|
||||
"no longer a receiving state — Sub 8 moved part inspection "
|
||||
'into the recipe (racking step), so receiving stops at '
|
||||
'"received" (boxes counted, staged, closed).',
|
||||
)
|
||||
|
||||
# ---- Direct Order rewrite (Phase A) ----
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.8.0',
|
||||
'version': '19.0.10.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -67,9 +67,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'views/fp_step_priority_views.xml',
|
||||
'views/jobs_in_shopfloor_menu.xml',
|
||||
'views/legacy_menu_hide.xml',
|
||||
'views/fp_job_cert_backfill.xml',
|
||||
'views/res_users_views.xml',
|
||||
'wizards/fp_job_step_move_wizard_views.xml',
|
||||
'wizards/fp_job_step_input_wizard_views.xml',
|
||||
'wizards/fp_cert_issue_wizard_views.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
'report/report_fp_job_traveller.xml',
|
||||
'report/report_fp_job_wo_detail.xml',
|
||||
|
||||
@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
|
||||
'x_fc_local_thickness_pdf')
|
||||
def _compute_fischer_visibility(self):
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
||||
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
if QC is not None and rec.x_fc_job_id:
|
||||
# Cert-local upload wins over QC-side PDF (matches the
|
||||
# merge resolution order in fp_certificate.py).
|
||||
if rec.x_fc_local_thickness_pdf:
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
elif QC is not None and rec.x_fc_job_id:
|
||||
# Same lookup the merge method uses — passed-first,
|
||||
# then any QC with a PDF.
|
||||
qc = QC.sudo().search([
|
||||
|
||||
@@ -189,6 +189,15 @@ class FpJob(models.Model):
|
||||
back to partner-level send_coc / send_thickness_report flags.
|
||||
'none' returns empty (commercial customer, no paperwork).
|
||||
Unknown requirement codes default to {'coc'} as a safety net.
|
||||
|
||||
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
|
||||
wanted AND thickness is wanted, the thickness data is delivered
|
||||
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
|
||||
so we return ONE cert ({'coc'}) instead of two. A standalone
|
||||
thickness_report cert is only produced when thickness is wanted
|
||||
WITHOUT a CoC — a rare edge case kept for completeness.
|
||||
Action_issue's thickness-data gate enforces actual readings or
|
||||
a Fischerscope PDF on the merged CoC.
|
||||
"""
|
||||
self.ensure_one()
|
||||
req = (
|
||||
@@ -196,16 +205,17 @@ class FpJob(models.Model):
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
want_coc = bool(self.partner_id.x_fc_send_coc)
|
||||
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
|
||||
if want_coc:
|
||||
return {'coc'} # thickness gets merged in
|
||||
if want_thickness:
|
||||
return {'thickness_report'}
|
||||
return set()
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
'coc_thickness': {'coc'}, # bundled — thickness on page 2
|
||||
}.get(req, {'coc'})
|
||||
|
||||
next_milestone_action = fields.Selection(
|
||||
@@ -308,9 +318,29 @@ class FpJob(models.Model):
|
||||
return fn()
|
||||
|
||||
def _action_open_draft_certs(self):
|
||||
"""Open the cert list filtered to draft certs for this job.
|
||||
Manager reviews each in turn and clicks Issue per-cert."""
|
||||
"""Open the Issue Certs wizard for this job's draft certs.
|
||||
|
||||
The wizard prompts for a Fischerscope upload + readings per cert
|
||||
that needs thickness data (bundled CoC or standalone thickness
|
||||
report). Pure CoC certs (no thickness needed) appear in the
|
||||
wizard too and just need a Confirm click. Cleaner than the old
|
||||
"list view → open each cert → click Issue" flow.
|
||||
|
||||
Falls back to the cert list view if the wizard model isn't
|
||||
installed (defensive — should always exist when this module is).
|
||||
"""
|
||||
self.ensure_one()
|
||||
Wizard = self.env.get('fp.cert.issue.wizard')
|
||||
if Wizard is not None:
|
||||
try:
|
||||
return Wizard.open_for_job(self)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: cert issue wizard failed (%s) — "
|
||||
"falling back to cert list.", self.name, e,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Draft Certificates — %s') % self.name,
|
||||
@@ -1521,6 +1551,37 @@ class FpJob(models.Model):
|
||||
job.name, job.qty, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, accounted, job.qty,
|
||||
))
|
||||
# Receiving reconciliation: parts must be physically
|
||||
# received before the job can close, and the count must
|
||||
# match what came out (done + scrapped + visual rejects).
|
||||
# Without this guard a job ships with the wrong cert qty,
|
||||
# or worse, with no closed receiving for the auditor to
|
||||
# trace back to. Same bypass flag covers both checks.
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received "
|
||||
"is blank. Close the receiving record for SO %s "
|
||||
"before completing this job."
|
||||
) % (
|
||||
job.name,
|
||||
job.sale_order_id.name if job.sale_order_id else '?',
|
||||
))
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted_out = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done "
|
||||
"(%g) + qty_scrapped (%g) + visual rejects (%g) "
|
||||
"= %g. Reconcile before closing."
|
||||
) % (
|
||||
job.name, job.qty_received,
|
||||
job.qty_done or 0, job.qty_scrapped or 0,
|
||||
rejects, accounted_out,
|
||||
))
|
||||
# QC gate: customers flagged x_fc_requires_qc must have a
|
||||
# passed QC before the job closes. AS9100 / Nadcap compliance.
|
||||
if QC and not skip_qc_gate \
|
||||
@@ -1596,6 +1657,10 @@ class FpJob(models.Model):
|
||||
refund auto-link, and the legacy notification dispatch all
|
||||
look up by job_ref. Setting both ends keeps every consumer
|
||||
happy.
|
||||
|
||||
Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id
|
||||
from the linked receiving so the delivery carries the shipping
|
||||
choices made at receipt time. Shipping crew can override later.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.delivery_id:
|
||||
@@ -1612,6 +1677,25 @@ class FpJob(models.Model):
|
||||
"Job %s: fusion.plating.delivery has no job link field; "
|
||||
"delivery created without job back-reference.", self.name,
|
||||
)
|
||||
# Mirror outbound carrier + shipment from the SO's first
|
||||
# receiving record. If there are multiple receivings (split
|
||||
# shipments), the shipping crew can change either field on the
|
||||
# delivery form. Defensive: skip when fields aren't present
|
||||
# (older instance) or no receiving exists.
|
||||
if (self.sale_order_id
|
||||
and 'x_fc_receiving_ids' in self.sale_order_id._fields
|
||||
and self.sale_order_id.x_fc_receiving_ids):
|
||||
recv = self.sale_order_id.x_fc_receiving_ids[:1]
|
||||
if 'x_fc_carrier_id' in Delivery._fields \
|
||||
and 'x_fc_carrier_id' in recv._fields \
|
||||
and recv.x_fc_carrier_id:
|
||||
vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id
|
||||
if 'x_fc_outbound_shipment_id' in Delivery._fields \
|
||||
and 'x_fc_outbound_shipment_id' in recv._fields \
|
||||
and recv.x_fc_outbound_shipment_id:
|
||||
vals['x_fc_outbound_shipment_id'] = (
|
||||
recv.x_fc_outbound_shipment_id.id
|
||||
)
|
||||
try:
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
@@ -1626,13 +1710,20 @@ class FpJob(models.Model):
|
||||
on a job that already has a CoC won't create another one.
|
||||
|
||||
Each cert is pre-populated with everything action_issue needs
|
||||
(partner, spec_reference, part_number, quantity_shipped, po,
|
||||
(partner, spec_reference, process_description, certified_by,
|
||||
contact_partner, part_number, quantity_shipped, NC qty, PO,
|
||||
SO link, job link) so the manager just reviews and clicks Issue.
|
||||
|
||||
Replaces the single-CoC implementation: now honours
|
||||
part.certificate_requirement (coc / coc_thickness / none /
|
||||
inherit) and partner-level send_coc / send_thickness_report
|
||||
flags. Closes spec gap C-G1.
|
||||
Resolution sources for the new prefill fields:
|
||||
- process_description ← recipe.name (the job's process root)
|
||||
- certified_by_id ← customer_spec.signer_user_id, falling
|
||||
back to company.x_fc_owner_user_id
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
none / inherit) and partner-level send_coc /
|
||||
send_thickness_report flags. Closes spec gap C-G1.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
@@ -1645,6 +1736,25 @@ class FpJob(models.Model):
|
||||
# Spec drives the cert spec_reference. The customer.spec was
|
||||
# auto-filled onto the job at confirm time (sale_order.py).
|
||||
spec = self.customer_spec_id
|
||||
# Recipe drives the process description on the cert. Was previously
|
||||
# sourced from sale_order.x_fc_coating_config_id (since retired);
|
||||
# recipe.name is the human-readable replacement.
|
||||
recipe = self.recipe_id
|
||||
# Signer resolution: per-spec override wins, company default fills.
|
||||
signer = False
|
||||
if spec and 'signer_user_id' in spec._fields:
|
||||
signer = spec.signer_user_id
|
||||
if not signer and 'x_fc_owner_user_id' in self.company_id._fields:
|
||||
signer = self.company_id.x_fc_owner_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
+ (self.qty_visual_inspection_rejects or 0)
|
||||
)
|
||||
for cert_type in sorted(required):
|
||||
# Idempotency per type.
|
||||
existing_dom = [('certificate_type', '=', cert_type)]
|
||||
@@ -1691,6 +1801,8 @@ class FpJob(models.Model):
|
||||
(self.qty_done or self.qty or 0)
|
||||
- (self.qty_scrapped or 0)
|
||||
)
|
||||
if 'nc_quantity' in Cert._fields:
|
||||
vals['nc_quantity'] = nc_qty
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = (
|
||||
@@ -1703,8 +1815,12 @@ class FpJob(models.Model):
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
if 'process_description' in Cert._fields and recipe:
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
@@ -1728,6 +1844,107 @@ class FpJob(models.Model):
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backfill — closed jobs missing certs, plus cleanup of legacy
|
||||
# duplicate thickness_report certs created before the bundling rule.
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot management action for jobs that closed BEFORE the
|
||||
# _fp_create_certificates bug fix (e.g. WO-30040). Two passes:
|
||||
# 1. CREATE any missing draft cert per the (updated) resolver
|
||||
# 2. VOID legacy duplicate thickness_report certs that have a
|
||||
# paired CoC on the same job — the bundling rule says the
|
||||
# CoC carries the thickness data on page 2
|
||||
# Both passes are idempotent — safe to re-run.
|
||||
@api.model
|
||||
def action_backfill_missing_certs(self):
|
||||
Cert = self.env.get('fp.certificate')
|
||||
if Cert is None:
|
||||
raise UserError(_(
|
||||
'fp.certificate model is not installed. Install '
|
||||
'fusion_plating_certificates before running this action.'
|
||||
))
|
||||
candidate_jobs = self.search([('state', '=', 'done')])
|
||||
scanned = 0
|
||||
backfilled_jobs = self.env['fp.job']
|
||||
created_count = 0
|
||||
voided_count = 0
|
||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||
for job in candidate_jobs:
|
||||
required = job._resolve_required_cert_types()
|
||||
if not required:
|
||||
continue
|
||||
scanned += 1
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else
|
||||
(Cert.sudo().search([
|
||||
('sale_order_id', '=', job.sale_order_id.id),
|
||||
]) if job.sale_order_id else Cert.browse())
|
||||
)
|
||||
existing_types = set(existing_certs.mapped('certificate_type'))
|
||||
|
||||
# ---- Pass 1: create missing certs --------------------------
|
||||
missing = required - existing_types
|
||||
if missing:
|
||||
before = len(existing_certs)
|
||||
job._fp_create_certificates()
|
||||
# Re-read to get the freshly-created ones for pass 2.
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else existing_certs
|
||||
)
|
||||
delta = max(len(existing_certs) - before, 0)
|
||||
if delta:
|
||||
backfilled_jobs |= job
|
||||
created_count += delta
|
||||
|
||||
# ---- Pass 2: void duplicate thickness_report certs ---------
|
||||
# Bundling rule (CLAUDE.md): when CoC + thickness are both
|
||||
# wanted, the CoC absorbs the thickness data. A leftover
|
||||
# draft thickness_report cert on the same job is now noise
|
||||
# and should not be issued. Void it with a clear reason so
|
||||
# the audit trail tells the story.
|
||||
if 'coc' in required and 'coc' in existing_types:
|
||||
dup_thickness = existing_certs.filtered(
|
||||
lambda c: (c.certificate_type == 'thickness_report'
|
||||
and c.state == 'draft')
|
||||
)
|
||||
for cert in dup_thickness:
|
||||
cert.sudo().write({
|
||||
'state': 'voided',
|
||||
'void_reason': (
|
||||
'Auto-voided: bundling rule — thickness '
|
||||
'data is delivered as page 2 of the paired '
|
||||
'CoC, not as a separate cert.'
|
||||
),
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Auto-voided by cleanup: bundling rule routes '
|
||||
'thickness data to the CoC.'
|
||||
))
|
||||
voided_count += 1
|
||||
backfilled_jobs |= job
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cert backfill + cleanup complete'),
|
||||
'message': _(
|
||||
'Scanned %(s)d closed jobs. Created %(c)d draft '
|
||||
'cert(s); voided %(v)d duplicate thickness_report '
|
||||
'cert(s) across %(j)d job(s).'
|
||||
) % {
|
||||
's': scanned,
|
||||
'c': created_count,
|
||||
'v': voided_count,
|
||||
'j': len(backfilled_jobs),
|
||||
},
|
||||
'sticky': True,
|
||||
'type': 'success' if (created_count or voided_count) else 'warning',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
"""Phase 7 — adds the migration idempotency key on fp.job.step.
|
||||
|
||||
|
||||
@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
|
||||
'state': state_label,
|
||||
})
|
||||
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review (paperwork — doesn't
|
||||
need parts on the floor). Fires from both button_start and
|
||||
button_finish so an operator can't begin OR complete physical
|
||||
work before the receiving record is closed.
|
||||
|
||||
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
|
||||
pattern as the qty / QC / bake gates. Audit trail is preserved
|
||||
via the state-transition tracking on chatter.
|
||||
|
||||
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
|
||||
that's the terminal state (inspection moved into the recipe's
|
||||
racking step; ``'inspected'`` was dropped in the 2026-05-18
|
||||
cleanup).
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
# Internal rework / no SO — gate doesn't apply.
|
||||
continue
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
# Defensive: configurator module not installed.
|
||||
continue
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(
|
||||
so.x_fc_receiving_status,
|
||||
so.x_fc_receiving_status or 'unknown',
|
||||
)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received '
|
||||
'yet (SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > '
|
||||
'Receiving) before starting or finishing work on '
|
||||
'this step. A manager can bypass this gate for '
|
||||
'documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
"""Single source of truth for step start:
|
||||
1. Sub 13 predecessor gate (raise UserError if blocking)
|
||||
2. Policy B Contract Review auto-open (route to QA-005)
|
||||
3. Sub 8 Racking auto-open (route to racking inspection)
|
||||
4. super().button_start() + receiving soft check + serial
|
||||
promotion for the standard path
|
||||
2. Receiving gate (raise UserError if parts not received)
|
||||
3. Policy B Contract Review auto-open (route to QA-005)
|
||||
4. Sub 8 Racking auto-open (route to racking inspection)
|
||||
5. super().button_start() + serial promotion for the standard
|
||||
path
|
||||
|
||||
Manager bypasses available via context:
|
||||
fp_skip_predecessor_check=True skips the Sub 13 gate
|
||||
fp_skip_receiving_gate=True skips the receiving gate
|
||||
"""
|
||||
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
|
||||
),
|
||||
))
|
||||
|
||||
# ---- 2. Policy B Contract Review auto-open -----------------------
|
||||
# ---- 2. Receiving gate -------------------------------------------
|
||||
# Hard block (replaces the prior soft chatter warning). The
|
||||
# helper exempts Contract Review steps internally, so contract
|
||||
# review can still auto-open below regardless of receiving state.
|
||||
self._fp_check_receiving_gate()
|
||||
|
||||
# ---- 3. Policy B Contract Review auto-open -----------------------
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
action = step._fp_open_contract_review()
|
||||
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 3. Sub 8 Racking auto-open ----------------------------------
|
||||
# ---- 4. Sub 8 Racking auto-open ----------------------------------
|
||||
for step in self:
|
||||
if step._fp_is_racking_step():
|
||||
action = step._fp_open_racking_inspection()
|
||||
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 4. Standard path: start + receiving check + serial promote --
|
||||
# ---- 5. Standard path: start + serial promote --------------------
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
recv = so.x_fc_receiving_status if (
|
||||
'x_fc_receiving_status' in so._fields
|
||||
) else None
|
||||
if recv in (False, None, 'not_received'):
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(step)s" started before parts were received '
|
||||
'(SO %(so)s — receiving status: %(status)s). '
|
||||
'Confirm the parts are physically on the floor before '
|
||||
'continuing.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '',
|
||||
'status': recv or 'unknown',
|
||||
})
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
|
||||
@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status in ('partial', 'received'):
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state (no
|
||||
# more separate 'inspected'). Parts are on the floor;
|
||||
# inspection happens inside the recipe's racking step.
|
||||
if not so.x_fc_assigned_manager_id and not jobs:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving accepted; flip SO receiving status to inspected."""
|
||||
"""Mark receiving complete; flip SO receiving status to received.
|
||||
|
||||
Sub 8 (2026-04-22) moved inspection out of receiving and into the
|
||||
recipe's racking step. Receiving's terminal state is now 'closed'
|
||||
(or legacy 'accepted'), which maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
# Push receiving to its terminal state — 'closed' is the
|
||||
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
|
||||
# only for old records still in pre-Sub-8 states.
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state in ('inspecting',):
|
||||
rec.state = 'accepted'
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -20,3 +20,9 @@ access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_sup,fp.cert.issue.wiz.l.supervisor,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_mgr,fp.cert.issue.wiz.l.manager,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_sup,fp.cert.issue.wiz.r.supervisor,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_mgr,fp.cert.issue.wiz.r.manager,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -589,3 +589,367 @@ class TestQtyGate(TransactionCase):
|
||||
with self.assertRaises(UserError) as exc:
|
||||
wiz.action_commit()
|
||||
self.assertIn('at least 1', str(exc.exception))
|
||||
|
||||
|
||||
class TestCertCreationAndGates(TransactionCase):
|
||||
"""2026-05-18 — cert creation bug fix + gate hardening.
|
||||
|
||||
Covers the fixes for the WO-30040 incident where
|
||||
_fp_create_certificates raised NameError on `coating` and the cert
|
||||
was never created. Also covers the new qty_received gate on
|
||||
button_mark_done and the auto-fill of certified_by_id /
|
||||
contact_partner_id / nc_quantity / process_description.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Quality Manager',
|
||||
'login': 'qa_mgr_certtest',
|
||||
'email': 'qa@example.com',
|
||||
})
|
||||
cls.contact = cls.env['res.partner'].create({
|
||||
'name': 'Bob Receiver',
|
||||
'email': 'bob@cust.example',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'CertWidget',
|
||||
})
|
||||
cls.part = cls.env['fp.part.catalog'].create({
|
||||
'name': 'CertPart',
|
||||
'part_number': 'CP-001',
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_requirement': 'coc',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part.id,
|
||||
'qty': 1.0,
|
||||
'qty_done': 1.0,
|
||||
'qty_received': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ---------------- bug fix regression -------------------------------
|
||||
|
||||
def test_create_cert_handles_job_with_no_recipe(self):
|
||||
"""Regression for the `coating` NameError: cert must create
|
||||
even when the job has no recipe and no coating config."""
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.recipe_id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertFalse(certs.process_description)
|
||||
|
||||
# ---------------- prefill -----------------------------------------
|
||||
|
||||
def test_create_cert_prefills_signer_from_company(self):
|
||||
self.env.company.x_fc_owner_user_id = self.signer.id
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.certified_by_id, self.signer)
|
||||
|
||||
def test_create_cert_prefills_contact_from_partner(self):
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
|
||||
qty_visual_inspection_rejects=0,
|
||||
)
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.nc_quantity, 1)
|
||||
|
||||
# ---------------- mark_done qty_received gate ----------------------
|
||||
|
||||
def test_mark_done_blocks_on_blank_qty_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('Quantity Received', str(exc.exception))
|
||||
|
||||
def test_mark_done_blocks_on_qty_received_mismatch(self):
|
||||
from odoo.exceptions import UserError
|
||||
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
|
||||
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
|
||||
qty_received=5, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
|
||||
# rebalance so it passes the first check and fails the new one:
|
||||
job.qty = 4
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('qty mismatch', str(exc.exception).lower())
|
||||
|
||||
def test_mark_done_passes_with_clean_reconcile(self):
|
||||
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
|
||||
qty_received=4, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
def test_mark_done_bypass_skips_qty_received_check(self):
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(
|
||||
fp_skip_qty_reconcile=True,
|
||||
fp_skip_qc_gate=True,
|
||||
).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
# ---------------- backfill action ---------------------------------
|
||||
|
||||
def test_backfill_creates_missing_certs(self):
|
||||
"""A closed job with no cert gets one when the backfill runs."""
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
# Sanity: no cert exists
|
||||
self.assertFalse(self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]))
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
self.assertEqual(self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]), 1)
|
||||
|
||||
def test_backfill_idempotent(self):
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
before = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
after = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
class TestReceivingGate(TransactionCase):
|
||||
"""2026-05-18 — Hard gate on button_start / button_finish blocking
|
||||
step transitions until SO receiving status = 'received'. Contract
|
||||
Review steps are exempt; manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`. See
|
||||
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_so(self, recv_status='not_received'):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
if 'x_fc_receiving_status' in so._fields:
|
||||
so.x_fc_receiving_status = recv_status
|
||||
return so
|
||||
|
||||
def _make_job_with_step(self, recv_status='not_received',
|
||||
step_state='ready', is_cr=False):
|
||||
"""Build a job tied to an SO with the given receiving status,
|
||||
plus a single step in the given state. Returns (job, step)."""
|
||||
so = self._make_so(recv_status=recv_status)
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
step_vals = {
|
||||
'job_id': job.id,
|
||||
'name': 'Plate',
|
||||
'state': step_state,
|
||||
}
|
||||
# If a step_kind model is available, set CR vs not via kind.
|
||||
StepKind = self.env.get('fp.step.kind')
|
||||
if StepKind is not None and is_cr:
|
||||
cr_kind = StepKind.search(
|
||||
[('code', '=', 'contract_review')], limit=1,
|
||||
)
|
||||
if cr_kind:
|
||||
step_vals['step_kind_id'] = cr_kind.id
|
||||
step = self.env['fp.job.step'].create(step_vals)
|
||||
return job, step
|
||||
|
||||
# ---- button_start gate ------------------------------------------------
|
||||
|
||||
def test_start_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_start()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_start_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(recv_status='received')
|
||||
# Should not raise; step transitions to in_progress via super().
|
||||
step.button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
def test_start_skips_contract_review(self):
|
||||
# CR step exempt regardless of receiving status.
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', is_cr=True,
|
||||
)
|
||||
# button_start may return an action (CR auto-open) — must not raise.
|
||||
try:
|
||||
step.button_start()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
# Other failures (e.g. CR auto-open quirks in test env) are
|
||||
# not the gate — accept them.
|
||||
|
||||
def test_start_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
step.with_context(fp_skip_receiving_gate=True).button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
# ---- button_finish gate -----------------------------------------------
|
||||
|
||||
def test_finish_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_finish()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_finish_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='received', step_state='in_progress',
|
||||
)
|
||||
step.button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
def test_finish_skips_contract_review(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
is_cr=True,
|
||||
)
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
|
||||
def test_finish_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
step.with_context(fp_skip_receiving_gate=True).button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
|
||||
class TestCreateDeliveryShippingMirror(TransactionCase):
|
||||
"""Phase A — _fp_create_delivery mirrors shipping fields from the
|
||||
linked receiving onto the auto-created fp.delivery."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
|
||||
def _make_so_with_receiving(self, carrier=None, shipment=None):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'x_fc_carrier_id': carrier.id if carrier else False,
|
||||
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
|
||||
})
|
||||
return so, recv
|
||||
|
||||
def _make_job(self, so):
|
||||
return self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
|
||||
def test_create_delivery_mirrors_carrier_from_receiving(self):
|
||||
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
|
||||
|
||||
def test_create_delivery_mirrors_outbound_shipment(self):
|
||||
shipment = self.env['fusion.shipment'].create({
|
||||
'sale_order_id': False,
|
||||
'carrier_id': self.carrier_ups.id,
|
||||
'status': 'draft',
|
||||
})
|
||||
so, recv = self._make_so_with_receiving(
|
||||
carrier=self.carrier_ups, shipment=shipment,
|
||||
)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertEqual(
|
||||
job.delivery_id.x_fc_outbound_shipment_id, shipment,
|
||||
)
|
||||
|
||||
def test_create_delivery_no_receiving_no_mirror(self):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_carrier_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)
|
||||
|
||||
@@ -64,14 +64,15 @@
|
||||
as page 2 — open the Certificate PDF tab to verify.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
style="margin-top:0;">
|
||||
<i class="fa fa-exclamation-triangle" title="Warning"
|
||||
aria-label="Warning"/>
|
||||
<strong> No Fischerscope PDF on the linked QC.</strong>
|
||||
If this customer expects an XRF report with the CoC,
|
||||
have the operator upload the Fischerscope PDF on the
|
||||
QC check before issuing.
|
||||
<strong> No Fischerscope PDF available.</strong>
|
||||
Drop the PDF into the <em>Thickness Report
|
||||
(Fischerscope)</em> tab below, or upload it on the
|
||||
linked QC check, before issuing. Thickness Report
|
||||
certs cannot issue without thickness data.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -80,8 +81,7 @@
|
||||
<!-- Fischerscope file before merging into the cert. -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf"
|
||||
invisible="not x_fc_job_id">
|
||||
name="thickness_pdf">
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
@@ -94,25 +94,23 @@
|
||||
widget="many2one_binary"
|
||||
invisible="not x_fc_thickness_pdf_id"/>
|
||||
</group>
|
||||
<separator string="Upload Fischerscope PDF here"/>
|
||||
<group>
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness PDF has been
|
||||
uploaded on the linked QC yet. The CoC will
|
||||
be issued without an appended thickness
|
||||
report. To attach one:
|
||||
uploaded yet. The CoC will be issued without
|
||||
an appended thickness report. Either drop the
|
||||
PDF into the upload field above, OR upload it
|
||||
on the linked QC check and re-open this cert.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Open the linked Plating Job (smart
|
||||
button above)</li>
|
||||
<li>Click into the auto-spawned Quality
|
||||
Check</li>
|
||||
<li>Go to the <em>Thickness Report</em> tab
|
||||
and upload the PDF from the Fischerscope
|
||||
/ XDAL 600 export</li>
|
||||
<li>Pass the QC, then come back here and
|
||||
click Issue</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
@@ -120,8 +118,8 @@
|
||||
<i class="fa fa-arrow-up" title="Action"
|
||||
aria-label="Action"/>
|
||||
Click <strong>Issue</strong> in the header
|
||||
and the Fischerscope PDF above will be
|
||||
merged into page 2 of the CoC.
|
||||
and the Fischerscope PDF will be merged into
|
||||
page 2 of the CoC.
|
||||
</p>
|
||||
</div>
|
||||
</page>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
One-shot backfill for closed jobs that never produced a CoC because
|
||||
of the `coating` NameError regression (fixed 2026-05-18). Surfaced
|
||||
as a Settings > Technical menu item so the user can click once after
|
||||
deploying the fix.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_fp_job_backfill_missing_certs" model="ir.actions.server">
|
||||
<field name="name">Generate Missing Certs for Closed Jobs</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = env['fp.job'].action_backfill_missing_certs()</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
from . import fp_cert_issue_wizard
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issue Certs Wizard.
|
||||
|
||||
Opened from a job's "Issue Certs" milestone button. Walks each draft
|
||||
cert on the job, lets the manager upload the Fischerscope/XDAL output
|
||||
(PDF or .docx) per cert that needs thickness data, and tries to parse
|
||||
the .docx to pre-populate the readings table. Manager can edit/add
|
||||
readings before confirming. On confirm:
|
||||
|
||||
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
|
||||
of the issued CoC).
|
||||
- .docx uploads are attached as ir.attachment on the cert (evidence)
|
||||
and the parsed readings are written as fp.thickness.reading rows.
|
||||
- cert.action_issue() is called for each cert.
|
||||
|
||||
The wizard is a convenience layer — it does NOT replace the per-cert
|
||||
Issue button on the cert form, which stays as the fallback path.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Fischerscope XDAL 600 reading line, e.g.
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
_FISCHER_READING_RE = re.compile(
|
||||
r'n\s*=\s*(\d+)'
|
||||
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
|
||||
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
|
||||
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
|
||||
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_docx(raw_bytes):
|
||||
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
|
||||
|
||||
Returns dict:
|
||||
{
|
||||
'readings': [(nip_mils, ni_pct, p_pct), ...],
|
||||
'calibration': str or '',
|
||||
'operator': str or '',
|
||||
'date_str': str or '',
|
||||
'time_str': str or '',
|
||||
'raw_text': str (the extracted document body, for chatter),
|
||||
}
|
||||
|
||||
Soft-fails to an empty dict-like result when python-docx isn't
|
||||
installed or the bytes don't parse — the wizard still works, the
|
||||
operator just has to type readings manually.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '', 'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
try:
|
||||
import docx # python-docx
|
||||
except ImportError:
|
||||
_logger.info(
|
||||
'python-docx not installed — Fischerscope auto-parse '
|
||||
'skipped. Operator will enter readings manually.'
|
||||
)
|
||||
return empty
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(raw_bytes))
|
||||
except Exception as e:
|
||||
_logger.warning('Fischerscope .docx parse failed: %s', e)
|
||||
return empty
|
||||
# Pull text from paragraphs AND tables (Fischerscope reports
|
||||
# sometimes lay the readings inside a table cell).
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for tbl in doc.tables:
|
||||
for row in tbl.rows:
|
||||
for cell in row.cells:
|
||||
parts.append(cell.text)
|
||||
text = '\n'.join(parts)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)), # nip mils
|
||||
float(m.group(3)), # Ni %
|
||||
float(m.group(4)), # P %
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
calib = ''
|
||||
m = _FISCHER_CALIB_RE.search(text)
|
||||
if m:
|
||||
calib = m.group(1).strip()
|
||||
operator = ''
|
||||
m = _FISCHER_OPERATOR_RE.search(text)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
date_str = ''
|
||||
m = _FISCHER_DATE_RE.search(text)
|
||||
if m:
|
||||
date_str = m.group(1).strip()
|
||||
time_str = ''
|
||||
m = _FISCHER_TIME_RE.search(text)
|
||||
if m:
|
||||
time_str = m.group(1).strip()
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': calib,
|
||||
'operator': operator,
|
||||
'date_str': date_str,
|
||||
'time_str': time_str,
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizard(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job', string='Job', required=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
|
||||
)
|
||||
has_blocking_lines = fields.Boolean(
|
||||
compute='_compute_has_blocking_lines',
|
||||
help='True when at least one line is missing data the gate '
|
||||
'requires (no readings, no file, etc.). Used to disable '
|
||||
'the Confirm button.',
|
||||
)
|
||||
|
||||
@api.depends('line_ids', 'line_ids.is_ready')
|
||||
def _compute_has_blocking_lines(self):
|
||||
for w in self:
|
||||
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
|
||||
|
||||
@api.model
|
||||
def open_for_job(self, job):
|
||||
"""Factory — create a wizard pre-populated with one line per
|
||||
draft cert on the job. Returns an action dict that opens the
|
||||
wizard form."""
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
certs = Cert.search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if not certs:
|
||||
raise UserError(_(
|
||||
'No draft certificates on %s to issue.'
|
||||
) % job.name)
|
||||
wiz = self.create({
|
||||
'job_id': job.id,
|
||||
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Issue Certs — %s') % job.name,
|
||||
'res_model': self._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply every line's file + readings, then issue each cert.
|
||||
|
||||
Order matters: write the file/readings BEFORE calling action_issue
|
||||
so the gate sees the populated data. If a single cert raises on
|
||||
issue, the whole wizard rolls back (transactional).
|
||||
"""
|
||||
self.ensure_one()
|
||||
issued = []
|
||||
for ln in self.line_ids:
|
||||
ln._apply_to_cert()
|
||||
cert = ln.cert_id
|
||||
if cert.state == 'draft':
|
||||
cert.action_issue()
|
||||
issued.append(cert.name)
|
||||
if not issued:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Certs Issued'),
|
||||
'message': _('%d cert(s) issued: %s') % (
|
||||
len(issued), ', '.join(issued),
|
||||
),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizardLine(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.line'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
cert_type = fields.Selection(
|
||||
related='cert_id.certificate_type', readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
needs_thickness = fields.Boolean(
|
||||
compute='_compute_needs_thickness', store=False,
|
||||
)
|
||||
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||
fischer_filename = fields.Char(string='Filename')
|
||||
parsed_summary = fields.Text(
|
||||
string='Parsed Summary', readonly=True,
|
||||
help='Output of the .docx parser. Populated when you attach a '
|
||||
'Fischerscope .docx; the readings table below is auto-'
|
||||
'filled from the same parse. Empty for PDF uploads.',
|
||||
)
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
|
||||
)
|
||||
is_ready = fields.Boolean(
|
||||
compute='_compute_is_ready',
|
||||
help='True when this cert has enough data to issue: thickness '
|
||||
'data present if needed.',
|
||||
)
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
def _compute_needs_thickness(self):
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
'cert_id.thickness_reading_ids',
|
||||
'cert_id.x_fc_local_thickness_pdf')
|
||||
def _compute_is_ready(self):
|
||||
for ln in self:
|
||||
if not ln.needs_thickness:
|
||||
ln.is_ready = True
|
||||
continue
|
||||
ln.is_ready = bool(
|
||||
ln.fischer_file
|
||||
or ln.reading_line_ids
|
||||
or ln.cert_id.thickness_reading_ids
|
||||
or ln.cert_id.x_fc_local_thickness_pdf
|
||||
)
|
||||
|
||||
@api.onchange('fischer_file', 'fischer_filename')
|
||||
def _onchange_fischer_file(self):
|
||||
"""Try to parse .docx on upload; prefill the readings + summary."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
if not name.endswith('.docx'):
|
||||
self.parsed_summary = _(
|
||||
'Non-.docx upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
readings = parsed.get('readings') or []
|
||||
if readings:
|
||||
self.reading_line_ids = [(5, 0, 0)] + [
|
||||
(0, 0, {
|
||||
'sequence': i + 1,
|
||||
'nip_mils': nip,
|
||||
'ni_percent': ni,
|
||||
'p_percent': p,
|
||||
})
|
||||
for i, (nip, ni, p) in enumerate(readings)
|
||||
]
|
||||
self.parsed_summary = _(
|
||||
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
|
||||
'Operator: %(o)s · Date: %(d)s %(t)s'
|
||||
) % {
|
||||
'n': len(readings),
|
||||
'c': parsed.get('calibration') or '—',
|
||||
'o': parsed.get('operator') or '—',
|
||||
'd': parsed.get('date_str') or '—',
|
||||
't': parsed.get('time_str') or '',
|
||||
}
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert."""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
if name.endswith('.pdf'):
|
||||
# Drop the PDF into the cert-local field — merges into page 2.
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.fischer_file,
|
||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||
})
|
||||
else:
|
||||
# .doc / .docx / anything else — attach as evidence.
|
||||
self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert()
|
||||
|
||||
def _push_readings_to_cert(self):
|
||||
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||
Skips when no rows. Does not deduplicate against existing
|
||||
readings — the manager has just told us this is the new data."""
|
||||
self.ensure_one()
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if Reading is None or not self.reading_line_ids:
|
||||
return
|
||||
for r in self.reading_line_ids:
|
||||
vals = {
|
||||
'certificate_id': self.cert_id.id,
|
||||
'nip_mils': r.nip_mils,
|
||||
'ni_percent': r.ni_percent,
|
||||
'p_percent': r.p_percent,
|
||||
}
|
||||
if 'reading_number' in Reading._fields:
|
||||
vals['reading_number'] = r.sequence
|
||||
Reading.sudo().create(vals)
|
||||
|
||||
|
||||
class FpCertIssueWizardReading(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.reading'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
|
||||
_order = 'sequence, id'
|
||||
|
||||
line_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=1)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
p_percent = fields.Float(string='P %', digits=(6, 3))
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.issue.wizard.form</field>
|
||||
<field name="model">fp.cert.issue.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Issue Certs">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Issue Certs —
|
||||
<field name="job_id" readonly="1" nolabel="1"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not has_blocking_lines">
|
||||
<i class="fa fa-info-circle"/>
|
||||
At least one cert still needs thickness data
|
||||
(Fischerscope file or readings). Fill it in
|
||||
below before confirming.
|
||||
</div>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="false" delete="false">
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness" readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="is_ready" widget="boolean_toggle"
|
||||
readonly="1"
|
||||
decoration-success="is_ready"
|
||||
decoration-danger="not is_ready"/>
|
||||
</list>
|
||||
<form>
|
||||
<header>
|
||||
<field name="is_ready" widget="statusbar"
|
||||
statusbar_visible="True,False"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness"
|
||||
readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Fischerscope File"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"/>
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="not needs_thickness or not parsed_summary">
|
||||
<field name="parsed_summary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<separator string="Thickness Readings"
|
||||
invisible="not needs_thickness"/>
|
||||
<field name="reading_line_ids"
|
||||
invisible="not needs_thickness">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-primary"
|
||||
invisible="has_blocking_lines"/>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-secondary"
|
||||
invisible="not has_blocking_lines"
|
||||
disabled="1"
|
||||
help="One or more certs still need thickness data."/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.3.8.0',
|
||||
'version': '19.0.3.9.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
@@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
|
||||
'fusion_shipping',
|
||||
'hr',
|
||||
'mail',
|
||||
],
|
||||
|
||||
@@ -123,6 +123,86 @@ class FpDelivery(models.Model):
|
||||
'ir.attachment',
|
||||
string='Packing List',
|
||||
)
|
||||
|
||||
# ---- Phase A — outbound carrier + shipment link ----------------------
|
||||
# Mirrors the fields on fp.receiving. Populated by
|
||||
# fp.job._fp_create_delivery from the linked receiving when this
|
||||
# delivery is auto-created on job-done; shipping crew can override
|
||||
# at ship time.
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Carrier picked at receiving time; can be overridden by '
|
||||
'the shipping crew before issuing the label.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
help='The shipment record carrying weight, dimensions, label '
|
||||
'PDF, and tracking. Usually the same shipment that was '
|
||||
'created at receiving time.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id')
|
||||
def _compute_x_fc_outbound_shipment_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_outbound_shipment_count = (
|
||||
1 if rec.x_fc_outbound_shipment_id else 0
|
||||
)
|
||||
|
||||
@api.onchange('x_fc_carrier_id')
|
||||
def _onchange_x_fc_carrier_id(self):
|
||||
for rec in self:
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
|
||||
ship.carrier_id = rec.x_fc_carrier_id.id
|
||||
|
||||
def action_create_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if self.x_fc_outbound_shipment_id:
|
||||
return self.action_view_outbound_shipment()
|
||||
if 'fusion.shipment' not in self.env:
|
||||
raise UserError(_(
|
||||
'fusion_shipping module is not installed. '
|
||||
'Cannot create an outbound shipment.'
|
||||
))
|
||||
SO = self.env['sale.order'].sudo()
|
||||
so = False
|
||||
if self.job_ref:
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is not None:
|
||||
job = Job.sudo().search(
|
||||
[('name', '=', self.job_ref)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
vals = {
|
||||
'sale_order_id': so.id if so else False,
|
||||
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
|
||||
'status': 'draft',
|
||||
}
|
||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||
self.x_fc_outbound_shipment_id = shipment.id
|
||||
self.message_post(body=_(
|
||||
'Outbound shipment <b>%s</b> created (draft).'
|
||||
) % shipment.name)
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def action_view_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_outbound_shipment_id.name,
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': self.x_fc_outbound_shipment_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_delivery_shipping_fields
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase A — mirror carrier + outbound shipment fields on fp.delivery."""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestDeliveryShippingFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
|
||||
|
||||
def test_carrier_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_carrier_id', delivery._fields)
|
||||
|
||||
def test_outbound_shipment_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_outbound_shipment_id', delivery._fields)
|
||||
@@ -59,6 +59,16 @@
|
||||
statusbar_visible="draft,scheduled,en_route,delivered"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_create_outbound_shipment"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-truck">
|
||||
<field name="x_fc_outbound_shipment_count"
|
||||
widget="statinfo"
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
@@ -84,7 +94,9 @@
|
||||
<field name="vehicle_id"/>
|
||||
<field name="tdg_required" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group string="Documents">
|
||||
<group string="Outbound Shipping">
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="coc_attachment_id"/>
|
||||
<field name="packing_list_attachment_id"/>
|
||||
<field name="pod_id" readonly="1"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Notifications',
|
||||
'version': '19.0.6.4.0',
|
||||
'version': '19.0.6.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -22,6 +22,7 @@
|
||||
'fusion_plating_invoicing',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_reports',
|
||||
'fusion_shipping',
|
||||
'sale_management',
|
||||
'account',
|
||||
'mail',
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipment_labeled" model="fp.notification.template">
|
||||
<field name="name">Shipping Label Generated</field>
|
||||
<field name="trigger_event">shipment_labeled</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_shipment_labeled"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipped" model="fp.notification.template">
|
||||
<field name="name">Shipped / Delivered</field>
|
||||
<field name="trigger_event">shipped</field>
|
||||
|
||||
@@ -184,6 +184,70 @@
|
||||
fp.notification.template's `job_complete` trigger, defined
|
||||
in fp_notification_template_data.xml. -->
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4b. Shipping Label Generated (Info, #2B6CB0) -->
|
||||
<!-- Fires when fusion.shipment.tracking_number first lands. -->
|
||||
<!-- Customer gets the tracking link BEFORE the package goes -->
|
||||
<!-- out the door, so they can monitor from pickup. -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_shipment_labeled" model="mail.template">
|
||||
<field name="name">FP: Shipping Label Generated</field>
|
||||
<field name="model_id" ref="fusion_shipping.model_fusion_shipment"/>
|
||||
<field name="subject">Tracking #{{ object.tracking_number }} — your order is being prepared for shipment</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ (object.sale_order_id and object.sale_order_id.partner_id.email) or '' }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
Hi <t t-out="object.sale_order_id.partner_id.name or ''"/>, the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Shipment</th>
|
||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Sale Order</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.sale_order_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||
<td style="padding: 8px 4px;">Carrier</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.carrier_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Tracking Number</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace; font-weight: bold;"><t t-out="object.tracking_number or '—'"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div t-if="object.x_fc_tracking_url" style="margin: 24px 0; text-align: center;">
|
||||
<a t-att-href="object.x_fc_tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display: inline-block; padding: 12px 28px; background-color: #2B6CB0; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Track Shipment
|
||||
</a>
|
||||
</div>
|
||||
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
|
||||
<strong>What's next:</strong> Once the carrier collects the package, you'll receive a Shipped confirmation with the Certificate of Conformance attached.
|
||||
</div>
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -15,3 +15,4 @@ from . import account_payment
|
||||
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
||||
# from . import mrp_production
|
||||
from . import fp_delivery
|
||||
from . import fusion_shipment
|
||||
|
||||
@@ -16,6 +16,7 @@ TRIGGER_EVENTS = [
|
||||
('mo_complete', 'Manufacturing Complete'), # legacy, fired by mrp; kept for back-compat
|
||||
('job_confirmed', 'Plating Job Confirmed'), # Sub 11 — fp.job lifecycle
|
||||
('job_complete', 'Plating Job Complete'), # Sub 11 — fp.job.button_mark_done
|
||||
('shipment_labeled', 'Shipping Label Generated'), # Phase C — fired when tracking_number lands on fusion.shipment
|
||||
('shipped', 'Shipped / Delivered'),
|
||||
('invoice_posted', 'Invoice Posted'),
|
||||
('payment_received', 'Payment Received'),
|
||||
@@ -36,6 +37,7 @@ FP_TRIGGER_STREAM = {
|
||||
'mo_complete': 'qc',
|
||||
'job_confirmed': 'qc',
|
||||
'job_complete': 'qc',
|
||||
'shipment_labeled': 'certs',
|
||||
'shipped': 'certs',
|
||||
'invoice_posted': 'invoices',
|
||||
'payment_received': 'invoices',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase C — fire 'shipment_labeled' notification when tracking_number
|
||||
lands on a fusion.shipment for the first time.
|
||||
|
||||
Triggers regardless of how tracking got set: live API call or manual
|
||||
fallback wizard. Customer gets the tracking link as soon as the label
|
||||
is generated, not after the package physically ships (that's the
|
||||
existing 'shipped' event on fp.delivery)."""
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionShipment(models.Model):
|
||||
_inherit = 'fusion.shipment'
|
||||
|
||||
def write(self, vals):
|
||||
# Identify shipments that gain a tracking number for the first
|
||||
# time. Done BEFORE super().write so we can compare before/after.
|
||||
will_fire = self.browse()
|
||||
if 'tracking_number' in vals and vals.get('tracking_number'):
|
||||
will_fire = self.filtered(lambda s: not s.tracking_number)
|
||||
res = super().write(vals)
|
||||
if not will_fire:
|
||||
return res
|
||||
Dispatch = self.env.get('fp.notification.template')
|
||||
if Dispatch is None:
|
||||
return res
|
||||
for ship in will_fire:
|
||||
partner = (
|
||||
ship.sale_order_id.partner_id
|
||||
if ship.sale_order_id else False
|
||||
)
|
||||
if not partner:
|
||||
continue
|
||||
try:
|
||||
Dispatch._dispatch(
|
||||
'shipment_labeled',
|
||||
ship,
|
||||
partner,
|
||||
sale_order=ship.sale_order_id or False,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Shipment %s: shipment_labeled dispatch failed: %s',
|
||||
ship.name, e,
|
||||
)
|
||||
return res
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -102,6 +102,79 @@ class FpPortalJob(models.Model):
|
||||
tracking_ref = fields.Char(
|
||||
string='Tracking Reference',
|
||||
)
|
||||
x_fc_tracking_url = fields.Char(
|
||||
string='Tracking URL',
|
||||
compute='_compute_x_fc_tracking_url',
|
||||
help='Resolved carrier tracking URL with the tracking number '
|
||||
'substituted. Used by the portal template to render the '
|
||||
'tracking_ref as a clickable link. Walks portal job → '
|
||||
'fp.job → sale_order → fp.receiving → carrier.',
|
||||
)
|
||||
|
||||
@api.depends('tracking_ref')
|
||||
def _compute_x_fc_tracking_url(self):
|
||||
Job = self.env.get('fp.job')
|
||||
for rec in self:
|
||||
url = ''
|
||||
if rec.tracking_ref and Job is not None:
|
||||
job = Job.sudo().search(
|
||||
[('portal_job_id', '=', rec.id)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
recv = (
|
||||
so.x_fc_receiving_ids[:1]
|
||||
if so and 'x_fc_receiving_ids' in so._fields else False
|
||||
)
|
||||
carrier = (
|
||||
recv.x_fc_carrier_id
|
||||
if recv and 'x_fc_carrier_id' in recv._fields else False
|
||||
)
|
||||
tpl = (carrier.tracking_url or '') if carrier else ''
|
||||
if tpl:
|
||||
placeholder = '<shipmenttrackingnumber>'
|
||||
if placeholder in tpl:
|
||||
url = tpl.replace(placeholder, rec.tracking_ref)
|
||||
else:
|
||||
url = tpl + rec.tracking_ref
|
||||
rec.x_fc_tracking_url = url
|
||||
|
||||
# ---- Tracking history exposure ----------------------------------------
|
||||
# Pulls fusion.tracking.event records from the outbound shipment linked
|
||||
# via fp.job → fp.receiving → x_fc_outbound_shipment_id. Used by the
|
||||
# portal job page to render a timeline of carrier scan events.
|
||||
x_fc_tracking_event_ids = fields.Many2many(
|
||||
'fusion.tracking.event',
|
||||
string='Tracking Events',
|
||||
compute='_compute_x_fc_tracking_event_ids',
|
||||
)
|
||||
|
||||
@api.depends('tracking_ref')
|
||||
def _compute_x_fc_tracking_event_ids(self):
|
||||
Job = self.env.get('fp.job')
|
||||
Event = self.env.get('fusion.tracking.event')
|
||||
empty = self.env['fusion.tracking.event'] if Event is not None else None
|
||||
for rec in self:
|
||||
events = empty
|
||||
if Event is not None and Job is not None and rec.tracking_ref:
|
||||
job = Job.sudo().search(
|
||||
[('portal_job_id', '=', rec.id)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
recv = (
|
||||
so.x_fc_receiving_ids[:1]
|
||||
if so and 'x_fc_receiving_ids' in so._fields else False
|
||||
)
|
||||
ship = (
|
||||
recv.x_fc_outbound_shipment_id
|
||||
if recv and 'x_fc_outbound_shipment_id' in recv._fields
|
||||
else False
|
||||
)
|
||||
if ship:
|
||||
events = ship.tracking_event_ids.sorted(
|
||||
key=lambda e: e.event_datetime or fields.Datetime.now(),
|
||||
reverse=True,
|
||||
)
|
||||
rec.x_fc_tracking_event_ids = events
|
||||
coc_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Certificate of Conformance',
|
||||
|
||||
@@ -536,7 +536,17 @@
|
||||
</div>
|
||||
<div t-if="job.tracking_ref">
|
||||
<span class="o_fp_fact_label">Tracking </span>
|
||||
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
|
||||
<span class="o_fp_fact_value">
|
||||
<a t-if="job.x_fc_tracking_url"
|
||||
t-att-href="job.x_fc_tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</a>
|
||||
<t t-else="">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<div t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
|
||||
<span class="o_fp_fact_label">Ship to </span>
|
||||
@@ -594,6 +604,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking history (if shipment has events) -->
|
||||
<div t-if="job.x_fc_tracking_event_ids" class="o_fp_card"
|
||||
style="margin-top:1.25rem">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div style="font-weight:600;color:#111827;font-size:1rem">
|
||||
Tracking History
|
||||
</div>
|
||||
<span t-if="job.tracking_ref"
|
||||
style="font-size:.75rem;color:#6b7280;font-family:monospace">
|
||||
<a t-if="job.x_fc_tracking_url"
|
||||
t-att-href="job.x_fc_tracking_url"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<t t-out="job.tracking_ref"/>
|
||||
</a>
|
||||
<t t-else="" t-out="job.tracking_ref"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_timeline">
|
||||
<t t-foreach="job.x_fc_tracking_event_ids" t-as="evt">
|
||||
<div class="o_fp_timeline_item o_fp_timeline_done">
|
||||
<div class="o_fp_timeline_dot">●</div>
|
||||
<div class="o_fp_timeline_title"
|
||||
t-out="evt.event_description or 'Tracking update'"/>
|
||||
<div class="o_fp_timeline_time">
|
||||
<t t-if="evt.event_datetime"
|
||||
t-out="evt.event_datetime"
|
||||
t-options='{"widget": "datetime"}'/>
|
||||
<t t-elif="evt.event_date"
|
||||
t-out="evt.event_date"
|
||||
t-options='{"widget": "date"}'/>
|
||||
<t t-if="evt.event_site">
|
||||
<span style="color:#9ca3af"> ·
|
||||
<t t-out="evt.event_site"/>
|
||||
<t t-if="evt.event_province">,
|
||||
<t t-out="evt.event_province"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer notes (if any) -->
|
||||
<div t-if="job.notes" class="o_fp_card" style="margin-top:1.25rem">
|
||||
<div style="font-weight:600;color:#111827;font-size:1rem;margin-bottom:.6rem">Notes</div>
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.8.0',
|
||||
'version': '19.0.3.18.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -29,17 +29,23 @@ Provides:
|
||||
'price': 0.00,
|
||||
'currency': 'CAD',
|
||||
'depends': [
|
||||
'delivery',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_shipping',
|
||||
'sale_management',
|
||||
'stock',
|
||||
],
|
||||
'data': [
|
||||
'security/fp_receiving_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_receiving_sequence_data.xml',
|
||||
'data/delivery_carrier_seed_data.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_racking_inspection_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Seeds the 12 carrier records the plating shop uses that are NOT
|
||||
already provided by Odoo / fusion_shipping. All start with
|
||||
delivery_type='fixed'; Phase D will flip Purolator (and any others
|
||||
we add integrations for) to their real delivery_type.
|
||||
|
||||
noupdate=1 — these records are upserted once on install. Hand-edits
|
||||
on the carrier records (e.g. renaming "FedEx" to "FedEx Express")
|
||||
are preserved across module upgrades.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
<record id="delivery_carrier_ups" model="delivery.carrier">
|
||||
<field name="name">UPS</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_fedex" model="delivery.carrier">
|
||||
<field name="name">FedEx</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">21</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_usps" model="delivery.carrier">
|
||||
<field name="name">USPS</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">22</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_dhl" model="delivery.carrier">
|
||||
<field name="name">DHL</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">23</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_purolator" model="delivery.carrier">
|
||||
<field name="name">Purolator</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">24</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_cct" model="delivery.carrier">
|
||||
<field name="name">CCT</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_canpar" model="delivery.carrier">
|
||||
<field name="name">Canpar Express</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">26</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_gls_canada" model="delivery.carrier">
|
||||
<field name="name">GLS Canada</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">27</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_loomis" model="delivery.carrier">
|
||||
<field name="name">Loomis Express</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">28</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_day_ross" model="delivery.carrier">
|
||||
<field name="name">Day & Ross</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">29</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_dicom" model="delivery.carrier">
|
||||
<field name="name">Dicom Transportation</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_customer_dropoff" model="delivery.carrier">
|
||||
<field name="name">Customer Drop-off</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">31</field>
|
||||
</record>
|
||||
|
||||
<record id="delivery_carrier_local_delivery" model="delivery.carrier">
|
||||
<field name="name">Local Delivery</field>
|
||||
<field name="delivery_type">fixed</field>
|
||||
<field name="product_id" ref="delivery.product_product_delivery"/>
|
||||
<field name="fixed_price">0</field>
|
||||
<field name="sequence">32</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Name-match existing fp_receiving.carrier_name → x_fc_carrier_id.
|
||||
|
||||
Phase A of the shipping integration replaces the free-text carrier
|
||||
field with a Many2one to delivery.carrier. Existing records (16 on
|
||||
entech at write time) have free-text values like "FedEx", "Purolator"
|
||||
in carrier_name. This migration walks them and populates the new M2O
|
||||
when a unique case-insensitive name match exists.
|
||||
|
||||
delivery.carrier.name is jsonb (translatable) in Odoo 19 — match
|
||||
strips to the en_US translation. Ambiguous values stay as text in
|
||||
carrier_name for the operator to pick manually.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Skip if the field doesn't exist yet (defensive — the column is
|
||||
# added by the registry update that runs before post-migrate).
|
||||
cr.execute("""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'fp_receiving'
|
||||
AND column_name = 'x_fc_carrier_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning('x_fc_carrier_id column not present — skip.')
|
||||
return
|
||||
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving r
|
||||
SET x_fc_carrier_id = dc.id
|
||||
FROM delivery_carrier dc
|
||||
WHERE r.carrier_name IS NOT NULL
|
||||
AND r.carrier_name <> ''
|
||||
AND r.x_fc_carrier_id IS NULL
|
||||
AND LOWER(TRIM(r.carrier_name)) =
|
||||
LOWER(TRIM((dc.name->>'en_US')))
|
||||
""")
|
||||
matched = cr.rowcount
|
||||
_logger.info(
|
||||
'Receiving carrier migration: matched %d record(s) by name.',
|
||||
matched,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Backfill missing part metadata + received_qty on fp.receiving.line.
|
||||
|
||||
A bug in fp.receiving auto-create (now fixed in
|
||||
fusion_plating_receiving/models/sale_order.py) read
|
||||
``order.x_fc_part_catalog_id`` (the rarely-populated SO header field)
|
||||
instead of ``line.x_fc_part_catalog_id`` (the authoritative per-line
|
||||
field), leaving every auto-generated receiving line with an empty
|
||||
``part_number`` and ``part_catalog_id``. Same auto-create also forgot
|
||||
to prefill ``received_qty``.
|
||||
|
||||
This migration walks existing receiving records and rebuilds the line
|
||||
metadata from the linked SO's order lines via position-based zip — only
|
||||
when the receiving line count matches the SO line count (otherwise the
|
||||
mapping isn't safe and we leave the record alone for manual review).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Find candidates: receiving lines with empty part_catalog_id AND
|
||||
# empty part_number, scoped to receivings with a linked SO.
|
||||
cr.execute("""
|
||||
SELECT r.id AS receiving_id,
|
||||
r.sale_order_id AS so_id,
|
||||
array_agg(rl.id ORDER BY rl.id) AS line_ids
|
||||
FROM fp_receiving r
|
||||
JOIN fp_receiving_line rl ON rl.receiving_id = r.id
|
||||
WHERE r.sale_order_id IS NOT NULL
|
||||
AND (rl.part_catalog_id IS NULL
|
||||
AND (rl.part_number IS NULL OR rl.part_number = ''))
|
||||
GROUP BY r.id, r.sale_order_id
|
||||
""")
|
||||
candidates = cr.fetchall()
|
||||
if not candidates:
|
||||
_logger.info('Receiving line backfill: no candidates.')
|
||||
return
|
||||
|
||||
fixed = 0
|
||||
skipped = 0
|
||||
for receiving_id, so_id, recv_line_ids in candidates:
|
||||
# Pull the SO's order lines in stable order.
|
||||
cr.execute("""
|
||||
SELECT id, x_fc_part_catalog_id, product_uom_qty, name
|
||||
FROM sale_order_line
|
||||
WHERE order_id = %s
|
||||
ORDER BY sequence, id
|
||||
""", (so_id,))
|
||||
so_lines = cr.fetchall()
|
||||
if len(so_lines) != len(recv_line_ids):
|
||||
# Mismatch — don't risk corrupting a non-trivial mapping.
|
||||
skipped += 1
|
||||
continue
|
||||
# Receiving lines come ordered by id ascending (the create call
|
||||
# in sale_order.py emits them in order_line order, so id-order
|
||||
# = sequence-order on the SO side).
|
||||
for recv_line_id, (sol_id, part_id, qty, name) in zip(
|
||||
recv_line_ids, so_lines,
|
||||
):
|
||||
part_number = ''
|
||||
if part_id:
|
||||
cr.execute(
|
||||
"SELECT part_number FROM fp_part_catalog WHERE id = %s",
|
||||
(part_id,),
|
||||
)
|
||||
row = cr.fetchone()
|
||||
part_number = (row and row[0]) or ''
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving_line
|
||||
SET part_catalog_id = %s,
|
||||
part_number = %s,
|
||||
received_qty = COALESCE(NULLIF(received_qty, 0),
|
||||
%s)
|
||||
WHERE id = %s
|
||||
""", (
|
||||
part_id or None,
|
||||
part_number,
|
||||
int(qty or 0),
|
||||
recv_line_id,
|
||||
))
|
||||
fixed += 1
|
||||
_logger.info(
|
||||
'Receiving line backfill: fixed %d lines, skipped %d receivings '
|
||||
'(line-count mismatch).', fixed, skipped,
|
||||
)
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving_racking_link
|
||||
from . import sale_order
|
||||
from . import fusion_shipment
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Per-package row for outbound multi-piece shipments.
|
||||
|
||||
Each fp.receiving has zero-or-more fp.outbound.package rows. When the
|
||||
operator clicks Generate Outbound Label, one stock.package + one
|
||||
carrier label is generated per row.
|
||||
|
||||
Single-box scenario: the form auto-fills one row when the receiving's
|
||||
top-level weight/dim are set, so existing UX still works.
|
||||
Multi-box scenario: operator adds more rows. Each row gets its own
|
||||
tracking number + label PDF/ZPL stored back on the row after the API
|
||||
call returns.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpOutboundPackage(models.Model):
|
||||
_name = 'fp.outbound.package'
|
||||
_description = 'Fusion Plating — Outbound Package (per-box detail)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
weight = fields.Float(string='Weight', digits=(10, 3))
|
||||
length = fields.Float(string='Length', digits=(10, 2))
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
# Populated by the carrier API once Generate Label fires.
|
||||
tracking_number = fields.Char(readonly=True, copy=False)
|
||||
label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Label',
|
||||
ondelete='set null',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
# Computed convenience: filename of the label (for download UX).
|
||||
label_filename = fields.Char(
|
||||
related='label_attachment_id.name', readonly=True,
|
||||
)
|
||||
@@ -3,9 +3,15 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
"""Parts receiving record.
|
||||
@@ -70,8 +76,620 @@ class FpReceiving(models.Model):
|
||||
qty_match = fields.Boolean(
|
||||
string='Qty Match', compute='_compute_qty_match', store=True,
|
||||
)
|
||||
carrier_name = fields.Char(string='Carrier', help='Who delivered the parts (Purolator, customer drop-off, etc.).')
|
||||
carrier_name = fields.Char(
|
||||
string='Carrier (Legacy)',
|
||||
help='Legacy free-text carrier field. Kept for back-compat with '
|
||||
'records that predate the carrier_id M2O. New records use '
|
||||
'x_fc_carrier_id instead.',
|
||||
)
|
||||
carrier_tracking = fields.Char(string='Inbound Tracking #')
|
||||
|
||||
# ---- Phase A — outbound carrier + shipment link ----------------------
|
||||
# The receiver picks the OUTBOUND (return) carrier here; clicking
|
||||
# "Create Outbound Shipment" creates a draft fusion.shipment which
|
||||
# owns weight, dimensions, label PDF, tracking. The shop's workflow
|
||||
# generates the return label at receiving time so the printed label
|
||||
# can travel with the parts.
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
help='The shipment record carrying weight, dimensions, label PDF, '
|
||||
'and tracking. Created via the "Create Outbound Shipment" '
|
||||
'button on this form.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
x_fc_has_label = fields.Boolean(
|
||||
compute='_compute_x_fc_has_label',
|
||||
help='True when the linked outbound shipment has a label PDF '
|
||||
'attached. Drives the Print Label smart-button visibility.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id.label_attachment_id')
|
||||
def _compute_x_fc_has_label(self):
|
||||
for rec in self:
|
||||
rec.x_fc_has_label = bool(
|
||||
rec.x_fc_outbound_shipment_id
|
||||
and rec.x_fc_outbound_shipment_id.label_attachment_id
|
||||
)
|
||||
|
||||
# ---- Phase C — Outbound packaging fields -----------------------------
|
||||
# Operator enters these at receiving time so the shipping label can be
|
||||
# generated immediately. Pushed to the linked fusion.shipment when
|
||||
# action_generate_outbound_label fires.
|
||||
x_fc_weight = fields.Float(
|
||||
string='Weight', digits=(10, 3), tracking=True,
|
||||
help='Total package weight for outbound shipping. Used at label '
|
||||
'generation time.',
|
||||
)
|
||||
x_fc_weight_uom = fields.Selection(
|
||||
[('lb', 'lb'), ('kg', 'kg')],
|
||||
string='Weight UoM', default='lb', tracking=True,
|
||||
)
|
||||
x_fc_length = fields.Float(
|
||||
string='Length', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_width = fields.Float(
|
||||
string='Width', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_height = fields.Float(
|
||||
string='Height', digits=(10, 2), tracking=True,
|
||||
)
|
||||
x_fc_dim_uom = fields.Selection(
|
||||
[('in', 'in'), ('cm', 'cm')],
|
||||
string='Dim UoM', default='in', tracking=True,
|
||||
)
|
||||
|
||||
# Back-link to the synthetic stock.picking used at API-call time.
|
||||
# Set by _fp_build_shipping_picking; kept for debugging / traceability.
|
||||
x_fc_shipping_picking_id = fields.Many2one(
|
||||
'stock.picking', string='Shipping Picking',
|
||||
readonly=True, copy=False,
|
||||
help='The internal picking record used to drive the carrier API '
|
||||
'call. Hidden from operator UIs; kept for traceability.',
|
||||
)
|
||||
|
||||
# Per-package detail for multi-piece shipments (MPS). Each row
|
||||
# produces one stock.package + one carrier label. Single-box flow
|
||||
# still works: when no rows are entered, _fp_build_shipping_picking
|
||||
# falls back to the receiving's top-level weight/dim fields.
|
||||
x_fc_outbound_package_ids = fields.One2many(
|
||||
'fp.outbound.package', 'receiving_id',
|
||||
string='Outbound Packages',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id')
|
||||
def _compute_x_fc_outbound_shipment_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_outbound_shipment_count = (
|
||||
1 if rec.x_fc_outbound_shipment_id else 0
|
||||
)
|
||||
|
||||
@api.onchange('x_fc_carrier_id')
|
||||
def _onchange_x_fc_carrier_id(self):
|
||||
"""Propagate carrier change to a linked DRAFT shipment.
|
||||
|
||||
Once a shipment is confirmed / shipped / delivered, we leave it
|
||||
alone — changing the carrier on a non-draft shipment is a
|
||||
destructive operation that needs explicit user intent (cancel +
|
||||
re-create), not a side-effect of editing the receiving form.
|
||||
"""
|
||||
for rec in self:
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
|
||||
ship.carrier_id = rec.x_fc_carrier_id.id
|
||||
|
||||
# ---- Actions ----------------------------------------------------------
|
||||
def action_create_outbound_shipment(self):
|
||||
"""Create a draft fusion.shipment linked to this receiving.
|
||||
|
||||
Idempotent: if a shipment is already linked, just open it.
|
||||
Pre-fills carrier_type, sender + recipient name/address, and
|
||||
service_type from the carrier's defaults so the operator never
|
||||
sees an empty form.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_outbound_shipment_id:
|
||||
return self.action_view_outbound_shipment()
|
||||
if 'fusion.shipment' not in self.env:
|
||||
raise UserError(_(
|
||||
'fusion_shipping module is not installed. '
|
||||
'Cannot create an outbound shipment.'
|
||||
))
|
||||
vals = {
|
||||
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
|
||||
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
|
||||
'status': 'draft',
|
||||
}
|
||||
vals.update(self._fp_resolve_shipment_defaults())
|
||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||
self.x_fc_outbound_shipment_id = shipment.id
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound shipment <b>%s</b> created (draft).'
|
||||
)) % shipment.name)
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def _fp_resolve_shipment_defaults(self):
|
||||
"""Build the dict of fusion.shipment field values that can be
|
||||
derived from the receiving's context (carrier, SO, company).
|
||||
Used at creation time and re-used by the generate-label flow
|
||||
to refresh fields if the operator changes carrier mid-flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
vals = {}
|
||||
carrier = self.x_fc_carrier_id
|
||||
# carrier_type — Selection on fusion.shipment ('canada_post',
|
||||
# 'ups_rest', 'fedex_rest', etc.). Map from delivery_type by
|
||||
# stripping the 'fusion_' prefix (e.g. 'fusion_fedex_rest' →
|
||||
# 'fedex_rest'). Selection on the model may not include every
|
||||
# value our delivery_type uses; defensive against missing keys.
|
||||
if carrier and carrier.delivery_type:
|
||||
dt = carrier.delivery_type
|
||||
ct = dt[len('fusion_'):] if dt.startswith('fusion_') else dt
|
||||
Ship = self.env.get('fusion.shipment')
|
||||
if Ship is not None:
|
||||
valid_types = dict(
|
||||
Ship._fields['carrier_type'].selection
|
||||
)
|
||||
if ct in valid_types:
|
||||
vals['carrier_type'] = ct
|
||||
# service_type — carrier-specific. FedEx REST stores it on
|
||||
# carrier.fedex_rest_service_type; UPS REST has its own field.
|
||||
# Read whichever attribute exists.
|
||||
if carrier:
|
||||
for attr in ('fedex_rest_service_type', 'ups_rest_service_type',
|
||||
'dhl_rest_service_type'):
|
||||
if attr in carrier._fields and carrier[attr]:
|
||||
vals['service_type'] = carrier[attr]
|
||||
break
|
||||
# Sender from company partner; recipient from SO shipping address.
|
||||
company_partner = self.env.company.partner_id
|
||||
vals['sender_name'] = company_partner.name or ''
|
||||
vals['sender_address'] = self._fp_format_address(company_partner)
|
||||
so = self.sale_order_id
|
||||
if so:
|
||||
recipient = so.partner_shipping_id or so.partner_id
|
||||
vals['recipient_name'] = recipient.name or ''
|
||||
vals['recipient_address'] = self._fp_format_address(recipient)
|
||||
return vals
|
||||
|
||||
def _fp_format_address(self, partner):
|
||||
"""Single-line address string for the shipment record.
|
||||
fusion.shipment.sender_address / recipient_address are plain
|
||||
Char; we just need a readable rendering."""
|
||||
if not partner:
|
||||
return ''
|
||||
parts = [partner.street, partner.street2, partner.city,
|
||||
partner.state_id.code if partner.state_id else False,
|
||||
partner.zip,
|
||||
partner.country_id.name if partner.country_id else False]
|
||||
return ', '.join(p for p in parts if p)
|
||||
|
||||
def action_view_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_outbound_shipment_id.name,
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': self.x_fc_outbound_shipment_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ---- Phase C — Generate Outbound Label -------------------------------
|
||||
def action_generate_outbound_label(self):
|
||||
"""One-button label generation.
|
||||
|
||||
Branches on carrier.delivery_type:
|
||||
- 'fixed' (no API integration): opens manual entry wizard.
|
||||
- 'fusion_*' (API integration): synthesizes a stock.picking,
|
||||
calls the existing carrier.<provider>_send_shipping method,
|
||||
copies the result back to the linked fusion.shipment.
|
||||
- On API exception: falls back to the manual wizard with the
|
||||
error message in the note field.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(note=_(
|
||||
'Carrier "%s" has no API integration configured. Enter '
|
||||
'the label PDF and tracking number below to record the '
|
||||
'shipment manually.'
|
||||
) % carrier.name)
|
||||
# Ensure the shipment exists before we attempt the API call.
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
self.action_create_outbound_shipment()
|
||||
# Push the packaging info onto the shipment so it's the source
|
||||
# of truth post-generation.
|
||||
self._fp_sync_packaging_to_shipment()
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping(picking)
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Receiving %s: outbound label API call failed: %s',
|
||||
self.name, e,
|
||||
)
|
||||
return self._fp_open_manual_label_wizard(note=_(
|
||||
'Carrier API call failed:\n %s\n\nEnter the label '
|
||||
'PDF and tracking number below to record the shipment '
|
||||
'manually.'
|
||||
) % str(e))
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def _fp_validate_label_inputs(self):
|
||||
"""Gate: required inputs before label generation."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_carrier_id:
|
||||
raise UserError(_(
|
||||
'Pick an Outbound Carrier before generating a label.'
|
||||
))
|
||||
if not self.x_fc_weight or self.x_fc_weight <= 0:
|
||||
raise UserError(_(
|
||||
'Enter the Weight before generating a label.'
|
||||
))
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_(
|
||||
'Receiving "%s" is not linked to a sale order — '
|
||||
'cannot generate a shipping label.'
|
||||
) % self.name)
|
||||
if not self.sale_order_id.partner_shipping_id \
|
||||
and not self.sale_order_id.partner_id:
|
||||
raise UserError(_(
|
||||
'Sale order has no shipping address. Set one on '
|
||||
'%s before generating a label.'
|
||||
) % self.sale_order_id.name)
|
||||
|
||||
def _fp_open_manual_label_wizard(self, note=''):
|
||||
"""Open the small manual-entry wizard for label PDF + tracking."""
|
||||
self.ensure_one()
|
||||
# Ensure the shipment exists so the wizard has a target to write to.
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
self.action_create_outbound_shipment()
|
||||
Wizard = self.env.get('fp.label.manual.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Manual label wizard is not installed. Upgrade '
|
||||
'fusion_plating_receiving.'
|
||||
))
|
||||
wiz = Wizard.create({
|
||||
'receiving_id': self.id,
|
||||
'note': note or '',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Enter Label Manually — %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _fp_sync_packaging_to_shipment(self):
|
||||
"""Copy weight + dimensions from the receiving to the linked
|
||||
fusion.shipment so the shipment record carries the values used
|
||||
for label generation."""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship:
|
||||
return
|
||||
vals = {}
|
||||
if self.x_fc_weight:
|
||||
vals['weight'] = self.x_fc_weight
|
||||
if 'x_fc_length' in ship._fields:
|
||||
if self.x_fc_length:
|
||||
vals['x_fc_length'] = self.x_fc_length
|
||||
if self.x_fc_width:
|
||||
vals['x_fc_width'] = self.x_fc_width
|
||||
if self.x_fc_height:
|
||||
vals['x_fc_height'] = self.x_fc_height
|
||||
if self.x_fc_dim_uom:
|
||||
vals['x_fc_dim_uom'] = self.x_fc_dim_uom
|
||||
if self.x_fc_weight_uom:
|
||||
vals['x_fc_weight_uom'] = self.x_fc_weight_uom
|
||||
if vals:
|
||||
ship.sudo().write(vals)
|
||||
|
||||
def _fp_build_shipping_picking(self):
|
||||
"""Synthesize a stock.picking just to carry the data needed by
|
||||
carrier.send_shipping. The picking is auto-validated to 'done'
|
||||
state so it doesn't sit as draft in operator views.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Picking = self.env['stock.picking'].sudo()
|
||||
warehouse = self.env['stock.warehouse'].sudo().search(
|
||||
[('company_id', '=', self.env.company.id)], limit=1,
|
||||
)
|
||||
if not warehouse:
|
||||
raise UserError(_(
|
||||
'No warehouse configured for the company. Configure '
|
||||
'one in Settings > Warehouses before generating labels.'
|
||||
))
|
||||
picking_type = warehouse.out_type_id
|
||||
if not picking_type:
|
||||
raise UserError(_(
|
||||
'Warehouse "%s" has no outgoing picking type.'
|
||||
) % warehouse.name)
|
||||
so = self.sale_order_id
|
||||
partner = so.partner_shipping_id or so.partner_id
|
||||
# Use the first SO line's product as the synthetic move's product
|
||||
# (carrier APIs read product info for dimensions / customs forms).
|
||||
product = (so.order_line and so.order_line[0].product_id) or self.env.ref(
|
||||
'product.product_product_4', raise_if_not_found=False,
|
||||
)
|
||||
if not product:
|
||||
raise UserError(_(
|
||||
'No product available to synthesize the shipping picking.'
|
||||
))
|
||||
picking = Picking.create({
|
||||
'partner_id': partner.id,
|
||||
'picking_type_id': picking_type.id,
|
||||
'origin': so.name,
|
||||
'sale_id': so.id,
|
||||
'carrier_id': self.x_fc_carrier_id.id,
|
||||
'move_ids': [(0, 0, {
|
||||
# Odoo 19 dropped stock.move.name; description_picking
|
||||
# replaces it (see CLAUDE.md "stock.move.name removed").
|
||||
'description_picking': 'Outbound %s' % (self.name or ''),
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': product.uom_id.id,
|
||||
'location_id': picking_type.default_location_src_id.id,
|
||||
'location_dest_id': picking_type.default_location_dest_id.id,
|
||||
})],
|
||||
})
|
||||
# Force the picking's weight so the API helper reads our value
|
||||
# instead of the computed (zero) weight from the synthetic move.
|
||||
if 'weight' in picking._fields:
|
||||
picking.write({'weight': self.x_fc_weight})
|
||||
# Confirm + assign so move_lines exist; we then pre-pack them
|
||||
# into one stock.package carrying the operator-entered weight +
|
||||
# the carrier's default package type. Without an explicit
|
||||
# package, _get_packages_from_picking falls back to weight_bulk
|
||||
# which reads from product.weight (always 0 for our synthetic
|
||||
# move) → FedEx rejects with "weight 0.0 lb". Setting
|
||||
# package_type_id makes DeliveryPackage.packaging_type resolve
|
||||
# to the carrier-specific shipper_package_code (e.g.
|
||||
# 'YOUR_PACKAGING' for FedEx).
|
||||
picking.action_confirm()
|
||||
try:
|
||||
picking.action_assign()
|
||||
except Exception:
|
||||
pass
|
||||
Package = self.env.get('stock.package')
|
||||
if Package is not None and picking.move_line_ids:
|
||||
default_pkg_type = self._fp_resolve_carrier_default_package_type()
|
||||
# Build the list of (weight, dimensions) tuples — one per
|
||||
# outbound package. Multi-piece shipments use the per-row
|
||||
# data from x_fc_outbound_package_ids; single-piece falls
|
||||
# back to the receiving's top-level weight/dim fields.
|
||||
rows = self.x_fc_outbound_package_ids.filtered(
|
||||
lambda r: (r.weight or 0) > 0
|
||||
)
|
||||
if not rows:
|
||||
# Synthesize one virtual row from the top-level fields.
|
||||
rows = [type('Row', (), {
|
||||
'weight': self.x_fc_weight,
|
||||
'length': self.x_fc_length,
|
||||
'width': self.x_fc_width,
|
||||
'height': self.x_fc_height,
|
||||
'id': False,
|
||||
})()]
|
||||
ml = picking.move_line_ids[0]
|
||||
packages = Package
|
||||
for row in rows:
|
||||
pkg_vals = {'shipping_weight': row.weight or 0}
|
||||
if default_pkg_type:
|
||||
pkg_vals['package_type_id'] = default_pkg_type.id
|
||||
pkg = Package.sudo().create(pkg_vals)
|
||||
packages |= pkg
|
||||
# Spread move_line qty across packages via result_package_id.
|
||||
# Stock's pack flow allows multiple move lines, but our move
|
||||
# has a single line with qty=1. For multi-box, we split the
|
||||
# move_line by creating extra lines (one per package).
|
||||
if len(packages) == 1:
|
||||
ml.result_package_id = packages[0].id
|
||||
else:
|
||||
# First package keeps the existing move_line.
|
||||
ml.result_package_id = packages[0].id
|
||||
Move = picking.move_ids[0] if picking.move_ids else False
|
||||
if Move:
|
||||
MoveLine = self.env['stock.move.line'].sudo()
|
||||
for pkg in packages[1:]:
|
||||
MoveLine.create({
|
||||
'move_id': Move.id,
|
||||
'picking_id': picking.id,
|
||||
'product_id': Move.product_id.id,
|
||||
'product_uom_id': Move.product_uom.id,
|
||||
'quantity': 1,
|
||||
'location_id': Move.location_id.id,
|
||||
'location_dest_id': Move.location_dest_id.id,
|
||||
'result_package_id': pkg.id,
|
||||
})
|
||||
# Stash packages on the picking via a transient attr so
|
||||
# _fp_apply_shipping_result can walk them in the same order
|
||||
# the API processes them (FedEx returns labels in the
|
||||
# order packages were submitted).
|
||||
picking._fp_outbound_packages = packages
|
||||
self.x_fc_shipping_picking_id = picking.id
|
||||
return picking
|
||||
|
||||
def _fp_resolve_carrier_default_package_type(self):
|
||||
"""Return the stock.package.type to use for the synthetic
|
||||
outbound package. Reads the carrier's per-provider default
|
||||
(e.g. fedex_rest_default_package_type_id). Returns False when
|
||||
no default is configured — the API call will then fail with a
|
||||
clear PACKAGINGTYPE error pointing the admin at the setup.
|
||||
"""
|
||||
self.ensure_one()
|
||||
carrier = self.x_fc_carrier_id
|
||||
if not carrier:
|
||||
return False
|
||||
# Field name pattern is <delivery_type>_default_package_type_id
|
||||
# for the FedEx REST / UPS REST / etc. integrations.
|
||||
field_name = '%s_default_package_type_id' % (
|
||||
carrier.delivery_type or ''
|
||||
)
|
||||
# Strip the 'fusion_' prefix used by fusion_shipping.
|
||||
if field_name.startswith('fusion_'):
|
||||
field_name = field_name[len('fusion_'):]
|
||||
if field_name in carrier._fields:
|
||||
return carrier[field_name]
|
||||
return False
|
||||
|
||||
def _fp_apply_shipping_result(self, picking, shipping_data):
|
||||
"""Copy tracking + label(s) from the picking back to the linked
|
||||
fusion.shipment AND to the per-package rows for multi-piece
|
||||
shipments. shipping_data is the list returned by
|
||||
carrier.send_shipping — `[{exact_price, tracking_number}, ...]`,
|
||||
one dict per package, in submission order.
|
||||
|
||||
Multi-piece (MPS): walks shipping_data alongside the picking's
|
||||
packages and writes per-package tracking + label_attachment back
|
||||
onto the matching fp.outbound.package row. The shipment-level
|
||||
tracking_number stores the first package's tracking (so the
|
||||
chatter / portal / notification still has a single primary ref).
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship:
|
||||
return
|
||||
# All label attachments uploaded to the picking by the upstream
|
||||
# send_shipping. PDF for PDF mode, application/zpl-ish for ZPLII.
|
||||
# We accept any attachment created on this picking by the API
|
||||
# call (the upstream code uses message_post which creates them).
|
||||
label_atts = self.env['ir.attachment'].sudo().search([
|
||||
('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id),
|
||||
], order='id asc')
|
||||
# Per-package shipping_data list — one entry per package.
|
||||
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
||||
shipping_data
|
||||
]
|
||||
# Pair rows with their results. If user didn't enter per-row
|
||||
# data, fall back to a single virtual row scenario (no rows to
|
||||
# write back to).
|
||||
rows = self.x_fc_outbound_package_ids.filtered(
|
||||
lambda r: (r.weight or 0) > 0
|
||||
)
|
||||
# Walk both lists in parallel; carrier returns one tracking +
|
||||
# label per package in submission order. Some carriers return
|
||||
# one combined tracking_ref split by '+' — handle both.
|
||||
primary_tracking = ''
|
||||
per_pkg_trackings = []
|
||||
for sd in sd_list:
|
||||
tn = sd.get('tracking_number') or ''
|
||||
for part in tn.split('+'):
|
||||
if part:
|
||||
per_pkg_trackings.append(part)
|
||||
if not per_pkg_trackings and 'carrier_tracking_ref' in picking._fields:
|
||||
for part in (picking.carrier_tracking_ref or '').split('+'):
|
||||
if part:
|
||||
per_pkg_trackings.append(part)
|
||||
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
|
||||
# Write per-row labels + tracking. Attachments are paired by
|
||||
# index — N labels and N rows. Excess on either side is ignored.
|
||||
for idx, row in enumerate(rows):
|
||||
row_vals = {}
|
||||
if idx < len(per_pkg_trackings):
|
||||
row_vals['tracking_number'] = per_pkg_trackings[idx]
|
||||
if idx < len(label_atts):
|
||||
row_vals['label_attachment_id'] = label_atts[idx].id
|
||||
if row_vals:
|
||||
row.sudo().write(row_vals)
|
||||
# Shipment-level fields. Primary label = first attachment; mirror
|
||||
# all labels onto x_fc_label_attachment_ids for the multi-print UX.
|
||||
vals = {'status': 'confirmed'}
|
||||
if primary_tracking:
|
||||
vals['tracking_number'] = primary_tracking
|
||||
if label_atts:
|
||||
vals['label_attachment_id'] = label_atts[0].id
|
||||
if 'x_fc_label_attachment_ids' in ship._fields:
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
|
||||
# Link the synthetic stock.picking so the Transfer field shows
|
||||
# it on the shipment form. Also refresh sender/recipient/carrier
|
||||
# defaults in case the operator changed carrier between create
|
||||
# and generate.
|
||||
if 'picking_id' in ship._fields:
|
||||
vals['picking_id'] = picking.id
|
||||
for k, v in self._fp_resolve_shipment_defaults().items():
|
||||
# Only fill if blank; never overwrite an operator edit.
|
||||
if not ship[k]:
|
||||
vals[k] = v
|
||||
ship.sudo().write(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound label generated. Tracking: <b>%s</b>'
|
||||
)) % (tracking_number or '(see attached PDF)'))
|
||||
# Validate the synthetic picking so it lands in 'done' state
|
||||
# instead of sitting at 'ready'. The shipping label is the proof
|
||||
# of dispatch — keeping the picking open misleads anyone looking
|
||||
# at the warehouse view. Wrapped in try/except so any quirk in
|
||||
# the validation flow (e.g. zero on-hand stock) doesn't block
|
||||
# the label generation success path.
|
||||
if picking and picking.state not in ('done', 'cancel'):
|
||||
try:
|
||||
# skip_sms = bypass the SMS-on-delivery confirm wizard
|
||||
# (stock_sms intercepts button_validate otherwise).
|
||||
# skip_backorder = no backorder dialog when qty doesn't
|
||||
# reconcile (won't on a synthetic picking with no stock).
|
||||
# skip_immediate = bypass the immediate-transfer prompt.
|
||||
result = picking.with_context(
|
||||
skip_immediate=True,
|
||||
skip_backorder=True,
|
||||
skip_sms=True,
|
||||
).button_validate()
|
||||
# If button_validate still returned an action (a wizard
|
||||
# popped up despite the context flags), log and move on
|
||||
# — the label is already saved; manual validation later
|
||||
# is fine.
|
||||
if isinstance(result, dict) and result.get('res_model'):
|
||||
_logger.info(
|
||||
'Receiving %s: button_validate returned a wizard '
|
||||
'(%s); leaving picking %s in state %s.',
|
||||
self.name,
|
||||
result.get('res_model'),
|
||||
picking.name,
|
||||
picking.state,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Receiving %s: failed to auto-validate picking %s: %s',
|
||||
self.name, picking.name, e,
|
||||
)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the label PDF for printing.
|
||||
|
||||
Returns the standard Odoo download action so the operator can
|
||||
print from their browser. Phase F replaces this with auto-print
|
||||
to a network printer.
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship or not ship.label_attachment_id:
|
||||
raise UserError(_(
|
||||
'No outbound shipping label on this receiving. '
|
||||
'Generate the label first.'
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
|
||||
'target': 'new',
|
||||
}
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Sub 12 audit fix — discoverable handoff from fp.receiving (boxes
|
||||
# counted) to fp.racking.inspection (parts inspected by the racking
|
||||
# crew). The racking inspection is auto-created on fp.job.action_confirm
|
||||
# but until now there was no smart-button on the receiving form to find
|
||||
# it — racking crew had to navigate via a separate menu.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class FpReceivingRackingLink(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
racking_inspection_count = fields.Integer(
|
||||
string='Racking Inspections', compute='_compute_racking_inspection_count',
|
||||
)
|
||||
|
||||
def _compute_racking_inspection_count(self):
|
||||
Inspection = self.env['fp.racking.inspection'] \
|
||||
if 'fp.racking.inspection' in self.env else None
|
||||
for rec in self:
|
||||
if Inspection is None or not rec.sale_order_id:
|
||||
rec.racking_inspection_count = 0
|
||||
continue
|
||||
rec.racking_inspection_count = Inspection.search_count([
|
||||
('sale_order_id', '=', rec.sale_order_id.id),
|
||||
])
|
||||
|
||||
def action_view_racking_inspections(self):
|
||||
"""Open the racking inspection(s) for this receiving's SO. If
|
||||
none exists yet, default-create context lets the user spawn one
|
||||
with the SO context pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Inspection = self.env['fp.racking.inspection']
|
||||
domain = [('sale_order_id', '=', self.sale_order_id.id)] \
|
||||
if self.sale_order_id else []
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Racking Inspections'),
|
||||
'res_model': 'fp.racking.inspection',
|
||||
'view_mode': 'list,form',
|
||||
'domain': domain,
|
||||
'context': {
|
||||
'default_sale_order_id': self.sale_order_id.id
|
||||
if self.sale_order_id else False,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase C — extend fusion.shipment with dimension fields.
|
||||
|
||||
fusion_shipping's native model has `weight` but no length/width/height.
|
||||
The plating workflow needs all four captured at receiving time so the
|
||||
shipment record carries everything the carrier API would want. Added
|
||||
here (not in fusion_shipping) to keep the upstream module untouched.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionShipment(models.Model):
|
||||
_inherit = 'fusion.shipment'
|
||||
|
||||
x_fc_length = fields.Float(string='Length', digits=(10, 2))
|
||||
x_fc_width = fields.Float(string='Width', digits=(10, 2))
|
||||
x_fc_height = fields.Float(string='Height', digits=(10, 2))
|
||||
x_fc_dim_uom = fields.Selection(
|
||||
[('in', 'in'), ('cm', 'cm')],
|
||||
string='Dim UoM', default='in',
|
||||
)
|
||||
x_fc_weight_uom = fields.Selection(
|
||||
[('lb', 'lb'), ('kg', 'kg')],
|
||||
string='Weight UoM', default='lb',
|
||||
)
|
||||
|
||||
# Multi-piece label storage. label_attachment_id remains the
|
||||
# primary (first box) for backward-compat; this M2M holds the full
|
||||
# set so the operator can download any box's label individually.
|
||||
x_fc_label_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_shipment_label_attachment_rel',
|
||||
'shipment_id', 'attachment_id',
|
||||
string='All Labels',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Phase C — resolved carrier tracking URL with the tracking number
|
||||
# substituted into the carrier.tracking_url template. Used by the
|
||||
# shipment_labeled email template and any other place that needs a
|
||||
# working clickable tracking link. Single source of truth so both
|
||||
# email + portal stay consistent.
|
||||
x_fc_tracking_url = fields.Char(
|
||||
string='Tracking URL (resolved)',
|
||||
compute='_compute_x_fc_tracking_url',
|
||||
help='carrier.tracking_url with <shipmenttrackingnumber> replaced '
|
||||
'by tracking_number. Empty when the carrier has no URL '
|
||||
'template or there is no tracking number yet.',
|
||||
)
|
||||
|
||||
@api.depends('carrier_id.tracking_url', 'tracking_number')
|
||||
def _compute_x_fc_tracking_url(self):
|
||||
for rec in self:
|
||||
tpl = (rec.carrier_id.tracking_url or '') if rec.carrier_id else ''
|
||||
tn = rec.tracking_number or ''
|
||||
if not tpl or not tn:
|
||||
rec.x_fc_tracking_url = ''
|
||||
continue
|
||||
placeholder = '<shipmenttrackingnumber>'
|
||||
if placeholder in tpl:
|
||||
rec.x_fc_tracking_url = tpl.replace(placeholder, tn)
|
||||
else:
|
||||
rec.x_fc_tracking_url = tpl + tn
|
||||
|
||||
def write(self, vals):
|
||||
"""Sync the carrier tracking number + label to the customer
|
||||
portal job whenever they land on the shipment. The portal_job
|
||||
currently shows `delivery.name` as 'tracking' — wrong; the
|
||||
customer wants the carrier's actual tracking number so the
|
||||
clickable link goes to FedEx/UPS/etc."""
|
||||
res = super().write(vals)
|
||||
sync_keys = {'tracking_number', 'label_attachment_id', 'status'}
|
||||
if not sync_keys & set(vals.keys()):
|
||||
return res
|
||||
for ship in self:
|
||||
try:
|
||||
ship._fp_sync_to_portal_job()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Shipment %s: portal-job sync failed: %s',
|
||||
ship.name, e,
|
||||
)
|
||||
return res
|
||||
|
||||
def _fp_sync_to_portal_job(self):
|
||||
"""Walk shipment → SO → fp.job → fusion.plating.portal.job
|
||||
and push the carrier tracking number + label + delivery's
|
||||
packing slip onto the customer-facing record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is None:
|
||||
return
|
||||
jobs = Job.sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)],
|
||||
)
|
||||
if not jobs:
|
||||
return
|
||||
for job in jobs:
|
||||
portal = job.portal_job_id
|
||||
if not portal:
|
||||
continue
|
||||
vals = {}
|
||||
if self.tracking_number and portal.tracking_ref != self.tracking_number:
|
||||
vals['tracking_ref'] = self.tracking_number
|
||||
# Packing slip lives on the linked fp.delivery, not the
|
||||
# shipment. Walk it lazily here so a packing-slip generated
|
||||
# earlier on the delivery also lands on the portal job.
|
||||
delivery = job.delivery_id
|
||||
if (delivery
|
||||
and 'packing_list_attachment_id' in delivery._fields
|
||||
and delivery.packing_list_attachment_id
|
||||
and portal.packing_list_attachment_id !=
|
||||
delivery.packing_list_attachment_id):
|
||||
vals['packing_list_attachment_id'] = (
|
||||
delivery.packing_list_attachment_id.id
|
||||
)
|
||||
# Once a tracking number exists, the parts have been picked
|
||||
# by the carrier (or are about to be) — advance the portal
|
||||
# state to 'shipped' so the customer sees their order is
|
||||
# on its way. The 'delivered' status flips when FedEx
|
||||
# tracking reports the delivery.
|
||||
if self.tracking_number and portal.state in (
|
||||
'received', 'in_progress', 'ready_to_ship',
|
||||
):
|
||||
vals['state'] = 'shipped'
|
||||
if vals:
|
||||
portal.sudo().write(vals)
|
||||
@@ -22,25 +22,41 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create receiving record on SO confirmation."""
|
||||
"""Override to auto-create receiving record on SO confirmation.
|
||||
|
||||
Per-line metadata (part catalog, part number) is sourced from
|
||||
``sale.order.line.x_fc_part_catalog_id`` — NOT from the SO header.
|
||||
The header field exists too but is rarely populated; the line
|
||||
carries the authoritative part link in the configurator flow.
|
||||
|
||||
Each receiving line prefills ``received_qty`` to ``expected_qty``
|
||||
so the racking crew only types when the count is off (mirrors
|
||||
the header behaviour in fp_receiving.py:create).
|
||||
"""
|
||||
res = super().action_confirm()
|
||||
for order in self:
|
||||
# Only create if no receiving record exists yet
|
||||
if not order.x_fc_receiving_ids:
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
receiving_vals = {
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': [],
|
||||
}
|
||||
# Auto-create lines from SO lines
|
||||
for line in order.order_line:
|
||||
receiving_vals['line_ids'].append((0, 0, {
|
||||
'part_number': order.x_fc_part_catalog_id.part_number if order.x_fc_part_catalog_id else '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': int(line.product_uom_qty),
|
||||
}))
|
||||
self.env['fp.receiving'].create(receiving_vals)
|
||||
if order.x_fc_receiving_ids:
|
||||
continue
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
line_vals = []
|
||||
for line in order.order_line:
|
||||
part = (
|
||||
line.x_fc_part_catalog_id
|
||||
if 'x_fc_part_catalog_id' in line._fields else False
|
||||
)
|
||||
expected = int(line.product_uom_qty or 0)
|
||||
line_vals.append((0, 0, {
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': expected,
|
||||
'received_qty': expected,
|
||||
}))
|
||||
self.env['fp.receiving'].create({
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
return res
|
||||
|
||||
def action_view_receiving(self):
|
||||
|
||||
@@ -14,3 +14,9 @@ access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_rack
|
||||
access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
||||
access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,group_fp_receiving,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_carrier_fields
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase A — carrier field + outbound shipment link tests on fp.receiving.
|
||||
|
||||
See docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestCarrierFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'CarrierCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.so = cls.env['sale.order'].create({
|
||||
'partner_id': cls.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': cls.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
# Carrier records seeded by data/delivery_carrier_seed_data.xml.
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
cls.carrier_fedex = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_fedex',
|
||||
)
|
||||
|
||||
def _make_receiving(self, **kw):
|
||||
vals = {'sale_order_id': self.so.id}
|
||||
vals.update(kw)
|
||||
return self.env['fp.receiving'].create(vals)
|
||||
|
||||
# ---- Field existence ------------------------------------------------
|
||||
|
||||
def test_carrier_id_field_exists_on_receiving(self):
|
||||
recv = self._make_receiving()
|
||||
self.assertIn('x_fc_carrier_id', recv._fields)
|
||||
|
||||
def test_outbound_shipment_id_field_exists_on_receiving(self):
|
||||
recv = self._make_receiving()
|
||||
self.assertIn('x_fc_outbound_shipment_id', recv._fields)
|
||||
|
||||
# ---- action_create_outbound_shipment --------------------------------
|
||||
|
||||
def test_action_create_outbound_shipment_creates_draft(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
self.assertFalse(recv.x_fc_outbound_shipment_id)
|
||||
recv.action_create_outbound_shipment()
|
||||
self.assertTrue(recv.x_fc_outbound_shipment_id)
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
self.assertEqual(ship.status, 'draft')
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
self.assertEqual(ship.sale_order_id, self.so)
|
||||
|
||||
def test_action_create_outbound_shipment_idempotent(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
first_ship = recv.x_fc_outbound_shipment_id
|
||||
recv.action_create_outbound_shipment()
|
||||
# Second call must not create a new shipment.
|
||||
self.assertEqual(recv.x_fc_outbound_shipment_id, first_ship)
|
||||
count = self.env['fusion.shipment'].search_count([
|
||||
('sale_order_id', '=', self.so.id),
|
||||
])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
# ---- onchange propagation -------------------------------------------
|
||||
|
||||
def test_carrier_id_change_propagates_to_draft_shipment(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
# Onchange triggers via the Form helper — we simulate by calling
|
||||
# the handler directly after writing.
|
||||
recv.x_fc_carrier_id = self.carrier_fedex.id
|
||||
recv._onchange_x_fc_carrier_id()
|
||||
self.assertEqual(ship.carrier_id, self.carrier_fedex)
|
||||
|
||||
def test_carrier_id_change_does_not_propagate_to_confirmed_shipment(self):
|
||||
recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
|
||||
recv.action_create_outbound_shipment()
|
||||
ship = recv.x_fc_outbound_shipment_id
|
||||
# Confirm the shipment — propagation must stop.
|
||||
ship.status = 'confirmed'
|
||||
recv.x_fc_carrier_id = self.carrier_fedex.id
|
||||
recv._onchange_x_fc_carrier_id()
|
||||
# Confirmed shipment retains the original carrier.
|
||||
self.assertEqual(ship.carrier_id, self.carrier_ups)
|
||||
@@ -72,18 +72,41 @@
|
||||
type="object"
|
||||
invisible="state != 'discrepancy'"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
<button name="action_generate_outbound_label"
|
||||
type="object"
|
||||
string="Generate Outbound Label"
|
||||
class="btn-primary"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight"/>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
string="Print Label"
|
||||
class="btn-secondary"
|
||||
icon="fa-file-pdf-o"
|
||||
invisible="not x_fc_outbound_shipment_id"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,counted,staged,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_racking_inspections"
|
||||
<button name="action_create_outbound_shipment"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-search-plus">
|
||||
<field name="racking_inspection_count"
|
||||
icon="fa-truck">
|
||||
<field name="x_fc_outbound_shipment_count"
|
||||
widget="statinfo"
|
||||
string="Racking Inspections"/>
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_has_label">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">PDF</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_has_label" invisible="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
@@ -114,8 +137,46 @@
|
||||
<group string="Reception">
|
||||
<field name="received_by_id"/>
|
||||
<field name="received_date"/>
|
||||
<field name="carrier_name"/>
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="carrier_tracking"/>
|
||||
<field name="carrier_name"
|
||||
invisible="not carrier_name"
|
||||
readonly="1"
|
||||
string="Legacy Carrier"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Outbound Packaging"
|
||||
invisible="not x_fc_carrier_id">
|
||||
<group>
|
||||
<label for="x_fc_weight"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_weight"/>
|
||||
<field name="x_fc_weight_uom" nolabel="1"/>
|
||||
</div>
|
||||
<label for="x_fc_length" string="Dimensions (L×W×H)"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_length"
|
||||
placeholder="L"/>
|
||||
<span class="mx-1">×</span>
|
||||
<field name="x_fc_width"
|
||||
placeholder="W"/>
|
||||
<span class="mx-1">×</span>
|
||||
<field name="x_fc_height"
|
||||
placeholder="H"/>
|
||||
<field name="x_fc_dim_uom" nolabel="1"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<p class="text-muted" colspan="2">
|
||||
Enter the weight and dimensions of the
|
||||
packaging you'll use to ship the finished
|
||||
parts back. The system reuses the same
|
||||
boxes for the return shipment. Click
|
||||
<strong>Generate Outbound Label</strong>
|
||||
in the header once carrier + weight are
|
||||
set.
|
||||
</p>
|
||||
</group>
|
||||
<group string="Quantities (populated by racking crew)">
|
||||
<field name="expected_qty" readonly="1"/>
|
||||
@@ -124,6 +185,33 @@
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Multi-Piece Packages"
|
||||
name="outbound_packages"
|
||||
invisible="not x_fc_carrier_id">
|
||||
<p class="text-muted">
|
||||
For multi-box shipments, add one row per
|
||||
box with its weight + dimensions. The
|
||||
carrier API will return one tracking
|
||||
number + one label per row.
|
||||
<strong>Single-box flow:</strong> leave
|
||||
this empty and the top-level weight/dim
|
||||
fields above are used.
|
||||
</p>
|
||||
<field name="x_fc_outbound_package_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="weight"/>
|
||||
<field name="length"/>
|
||||
<field name="width"/>
|
||||
<field name="height"/>
|
||||
<field name="tracking_number"
|
||||
readonly="1"/>
|
||||
<field name="label_attachment_id"
|
||||
readonly="1"
|
||||
widget="many2one_binary"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Receiving Lines" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Phase C MPS — extends fusion_shipping's shipment form with the
|
||||
All Labels list (x_fc_label_attachment_ids, one entry per package
|
||||
on a multi-piece shipment).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fusion_shipment_form_mps_inherit" model="ir.ui.view">
|
||||
<field name="name">fusion.shipment.form.mps.inherit</field>
|
||||
<field name="model">fusion.shipment</field>
|
||||
<field name="inherit_id" ref="fusion_shipping.view_fusion_shipment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='labels']/group[1]" position="after">
|
||||
<separator string="All Labels (Multi-Piece)"
|
||||
invisible="not x_fc_label_attachment_ids"/>
|
||||
<field name="x_fc_label_attachment_ids"
|
||||
invisible="not x_fc_label_attachment_ids"
|
||||
options="{'no_create': True}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="mimetype"/>
|
||||
<field name="file_size"/>
|
||||
</list>
|
||||
</field>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_label_manual_wizard
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Manual outbound-label entry wizard.
|
||||
|
||||
Opens automatically from fp.receiving.action_generate_outbound_label
|
||||
when:
|
||||
- the chosen carrier has no API integration (delivery_type='fixed'), or
|
||||
- the carrier API call fails (network, credential, malformed response).
|
||||
|
||||
Operator pastes the label PDF from the carrier's web tool + types the
|
||||
tracking number. On confirm, both land on the linked fusion.shipment.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpLabelManualWizard(models.TransientModel):
|
||||
_name = 'fp.label.manual.wizard'
|
||||
_description = 'Fusion Plating — Manual Outbound Label Entry'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, readonly=True, ondelete='cascade',
|
||||
)
|
||||
receiving_name = fields.Char(related='receiving_id.name', readonly=True)
|
||||
carrier_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_carrier_id', readonly=True,
|
||||
)
|
||||
shipment_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_outbound_shipment_id', readonly=True,
|
||||
)
|
||||
note = fields.Text(
|
||||
string='Why Manual?', readonly=True,
|
||||
help='Explanatory message — set by the caller (no API, API '
|
||||
'failure, etc.).',
|
||||
)
|
||||
label_pdf = fields.Binary(string='Shipping Label PDF')
|
||||
label_filename = fields.Char(string='Filename')
|
||||
tracking_number = fields.Char(string='Tracking Number')
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not self.label_pdf:
|
||||
raise UserError(_(
|
||||
'Attach the shipping label PDF before confirming.'
|
||||
))
|
||||
if not (self.tracking_number or '').strip():
|
||||
raise UserError(_(
|
||||
'Enter the tracking number before confirming.'
|
||||
))
|
||||
ship = self.shipment_id
|
||||
if not ship:
|
||||
raise UserError(_(
|
||||
'No outbound shipment linked to this receiving — '
|
||||
'cannot save manual label.'
|
||||
))
|
||||
# Create the attachment, then write the shipment.
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.label_filename or 'shipping-label.pdf',
|
||||
'type': 'binary',
|
||||
'datas': self.label_pdf,
|
||||
'mimetype': 'application/pdf',
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': ship.id,
|
||||
})
|
||||
ship.sudo().write({
|
||||
'label_attachment_id': att.id,
|
||||
'tracking_number': self.tracking_number.strip(),
|
||||
'status': 'confirmed',
|
||||
})
|
||||
ship.message_post(body=Markup(_(
|
||||
'Manual label saved — tracking <b>%s</b>.'
|
||||
)) % self.tracking_number)
|
||||
self.receiving_id.message_post(body=Markup(_(
|
||||
'Outbound label entered manually. Tracking: <b>%s</b>'
|
||||
)) % self.tracking_number)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_label_manual_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.label.manual.wizard.form</field>
|
||||
<field name="model">fp.label.manual.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Manual Outbound Label Entry">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Enter Label Manually —
|
||||
<field name="receiving_name"
|
||||
readonly="1" nolabel="1" class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not note">
|
||||
<i class="fa fa-info-circle"/>
|
||||
<field name="note" nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="carrier_id" readonly="1"/>
|
||||
<field name="shipment_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Label Details"/>
|
||||
<group>
|
||||
<field name="label_pdf"
|
||||
filename="label_filename"/>
|
||||
<field name="label_filename" invisible="1"/>
|
||||
<field name="tracking_number"
|
||||
placeholder="e.g. 1Z999AA10123456784"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Save Label" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.26.1.0',
|
||||
'version': '19.0.26.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -244,7 +244,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
and 'x_fc_assigned_manager_id' in so_fields):
|
||||
pending_accept_sos = SO.search_count([
|
||||
('state', '=', 'sale'),
|
||||
('x_fc_receiving_status', '=', 'inspected'),
|
||||
('x_fc_receiving_status', '=', 'received'),
|
||||
('x_fc_assigned_manager_id', '=', False),
|
||||
])
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fusion Shipping",
|
||||
"version": "19.0.1.1.0",
|
||||
"version": "19.0.1.5.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||
|
||||
@@ -342,6 +342,34 @@ class FedexRequest:
|
||||
res.append({'number': partner.parent_id.vat, 'tinType': 'BUSINESS_NATIONAL'})
|
||||
return res
|
||||
|
||||
def _strip_customs_for_domestic(self, request_data):
|
||||
"""Remove customsClearanceDetail when shipper + recipient are in
|
||||
the same country and the service isn't FEDEX_REGIONAL_ECONOMY.
|
||||
|
||||
FedEx rejects domestic Ground/Express requests that carry a
|
||||
customs block (TOTALCUSTOMSVALUE.REQUIRED). The upstream model
|
||||
always builds the block; we strip it for clearly-domestic cases.
|
||||
Caller invokes this immediately before _send_fedex_request.
|
||||
"""
|
||||
rs = request_data.get('requestedShipment', {})
|
||||
shipper = rs.get('shipper') or {}
|
||||
ship_addr = shipper.get('address') or {}
|
||||
# Recipient lives under 'recipients' (list) for /ship and
|
||||
# 'recipient' (single) for /rate. Handle both shapes.
|
||||
rec = rs.get('recipients') or []
|
||||
if isinstance(rec, list) and rec:
|
||||
rec_addr = rec[0].get('address') or {}
|
||||
else:
|
||||
rec_addr = (rs.get('recipient') or {}).get('address') or {}
|
||||
ship_country = ship_addr.get('countryCode')
|
||||
rec_country = rec_addr.get('countryCode')
|
||||
if (ship_country and rec_country
|
||||
and ship_country == rec_country
|
||||
and self.service_type != 'FEDEX_REGIONAL_ECONOMY'
|
||||
# India domestic still uses customs per upstream logic.
|
||||
and not (ship_country == 'IN' and rec_country == 'IN')):
|
||||
rs.pop('customsClearanceDetail', None)
|
||||
|
||||
def _get_shipping_price(self, ship_from, ship_to, packages, currency):
|
||||
fedex_currency = _convert_curr_iso_fdx(currency)
|
||||
request_data = {
|
||||
@@ -364,6 +392,7 @@ class FedexRequest:
|
||||
}
|
||||
}
|
||||
self._add_extra_data_to_request(request_data, 'rate')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
||||
try:
|
||||
rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {})
|
||||
@@ -474,6 +503,7 @@ class FedexRequest:
|
||||
request_data['requestedShipment']['customsClearanceDetail']['customsOption'] = {'type': 'COURTESY_RETURN_LABEL'}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'ship')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -561,6 +591,7 @@ class FedexRequest:
|
||||
}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'return')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -597,6 +628,62 @@ class FedexRequest:
|
||||
return actual['totalNetChargeWithDutiesAndTaxes']
|
||||
return actual['totalNetCharge']
|
||||
|
||||
def track_shipment(self, tracking_nr):
|
||||
"""Call FedEx /track/v1/trackingnumbers and return the parsed
|
||||
scan-event list. Returns:
|
||||
{
|
||||
'tracking_number': '<str>',
|
||||
'status': '<str — latest status description>',
|
||||
'events': [
|
||||
{
|
||||
'date_time': '<ISO 8601 str>',
|
||||
'description': '<str>',
|
||||
'event_type': '<str — FedEx event code>',
|
||||
'city': '<str>',
|
||||
'state_province': '<str>',
|
||||
'country': '<str>',
|
||||
'signed_by': '<str — present on delivery events>',
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
Empty events list when FedEx returns no scans yet (newly-printed
|
||||
label that hasn't been picked up). Raises ValidationError on
|
||||
HTTP error.
|
||||
"""
|
||||
res = self._send_fedex_request("/track/v1/trackingnumbers", {
|
||||
'includeDetailedScans': True,
|
||||
'trackingInfo': [{
|
||||
'trackingNumberInfo': {
|
||||
'trackingNumber': tracking_nr,
|
||||
},
|
||||
}],
|
||||
})
|
||||
out = {'tracking_number': tracking_nr, 'status': '', 'events': []}
|
||||
try:
|
||||
results = (res.get('completeTrackResults') or [{}])[0]
|
||||
track = (results.get('trackResults') or [{}])[0]
|
||||
except (AttributeError, IndexError):
|
||||
return out
|
||||
latest = track.get('latestStatusDetail') or {}
|
||||
out['status'] = (
|
||||
latest.get('description')
|
||||
or latest.get('statusByLocale')
|
||||
or ''
|
||||
)
|
||||
for scan in (track.get('scanEvents') or []):
|
||||
addr = scan.get('scanLocation') or {}
|
||||
out['events'].append({
|
||||
'date_time': scan.get('date') or '',
|
||||
'description': scan.get('eventDescription') or '',
|
||||
'event_type': scan.get('eventType') or '',
|
||||
'city': addr.get('city') or '',
|
||||
'state_province': addr.get('stateOrProvinceCode') or '',
|
||||
'country': addr.get('countryCode') or '',
|
||||
'signed_by': track.get('deliveryDetails', {}).get(
|
||||
'receivedByName', '') or '',
|
||||
})
|
||||
return out
|
||||
|
||||
def cancel_shipment(self, tracking_nr):
|
||||
res = self._send_fedex_request('/ship/v1/shipments/cancel', {
|
||||
'accountNumber': {'value': self.account_number},
|
||||
|
||||
@@ -292,7 +292,13 @@ class FusionShipment(models.Model):
|
||||
# ── Tracking ──────────────────────────────────────────────
|
||||
|
||||
def action_refresh_tracking(self):
|
||||
"""Fetch latest tracking events from Canada Post VIS API."""
|
||||
"""Fetch latest tracking events from the carrier's API.
|
||||
|
||||
Dispatch by carrier_type:
|
||||
- canada_post → Canada Post VIS API (inline below)
|
||||
- fedex_rest → FedEx /track/v1/trackingnumbers
|
||||
- other carriers → not yet supported; raise with clear message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.tracking_number:
|
||||
raise ValidationError(
|
||||
@@ -301,6 +307,15 @@ class FusionShipment(models.Model):
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier linked to this shipment."))
|
||||
if self.carrier_type == 'fedex_rest':
|
||||
return self._refresh_tracking_fedex_rest()
|
||||
if self.carrier_type != 'canada_post':
|
||||
raise ValidationError(_(
|
||||
"Refresh Tracking is only wired to Canada Post and "
|
||||
"FedEx REST at this time. For %(carrier)s shipments, "
|
||||
"use the Track Shipment button to view live tracking "
|
||||
"on the carrier's website."
|
||||
) % {'carrier': carrier.name})
|
||||
|
||||
# VIS tracking uses /vis/ path, not /rs/
|
||||
if carrier.prod_environment:
|
||||
@@ -745,3 +760,70 @@ class FusionShipment(models.Model):
|
||||
attachment_ids=(
|
||||
self.return_label_attachment_id.ids
|
||||
if self.return_label_attachment_id else []))
|
||||
|
||||
# ── FedEx REST tracking ──────────────────────────────────────────
|
||||
|
||||
def _refresh_tracking_fedex_rest(self):
|
||||
"""Call FedEx /track/v1/trackingnumbers and load scan events.
|
||||
|
||||
Parses the response via the FedexRestRequest.track_shipment
|
||||
helper, replaces the shipment's tracking_event_ids with the
|
||||
latest events, and updates status to 'delivered' if the latest
|
||||
event indicates delivery. The 'delivered' transition cascades
|
||||
to the portal_job via the existing write() hook.
|
||||
"""
|
||||
self.ensure_one()
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
try:
|
||||
fedex = FedexRestRequest(self.carrier_id)
|
||||
result = fedex.track_shipment(self.tracking_number)
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
_("FedEx tracking error: %s") % str(e))
|
||||
# Replace events.
|
||||
self.tracking_event_ids.unlink()
|
||||
vals_list = []
|
||||
delivered = False
|
||||
for evt in result.get('events') or []:
|
||||
evt_date_str = ''
|
||||
evt_time_str = ''
|
||||
evt_datetime = False
|
||||
raw_dt = evt.get('date_time') or ''
|
||||
if raw_dt:
|
||||
# FedEx returns ISO 8601 like 2026-05-18T14:30:00-05:00.
|
||||
try:
|
||||
parsed = dt_mod.fromisoformat(raw_dt)
|
||||
# Strip tzinfo so it stores in Odoo's naive UTC fields.
|
||||
if parsed.tzinfo is not None:
|
||||
import pytz as _pytz
|
||||
parsed = parsed.astimezone(_pytz.UTC).replace(tzinfo=None)
|
||||
evt_datetime = parsed
|
||||
evt_date_str = parsed.strftime('%Y-%m-%d')
|
||||
evt_time_str = parsed.strftime('%H:%M:%S')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if (evt.get('event_type') or '').upper() == 'DL' or (
|
||||
'delivered' in (evt.get('description') or '').lower()):
|
||||
delivered = True
|
||||
vals_list.append({
|
||||
'shipment_id': self.id,
|
||||
'event_date': evt_date_str or False,
|
||||
'event_time': evt_time_str or '',
|
||||
'event_datetime': evt_datetime,
|
||||
'event_description': evt.get('description') or '',
|
||||
'event_type': evt.get('event_type') or '',
|
||||
'event_site': evt.get('city') or '',
|
||||
'event_province': evt.get('state_province') or '',
|
||||
'signatory_name': evt.get('signed_by') or '',
|
||||
})
|
||||
if vals_list:
|
||||
self.env['fusion.tracking.event'].create(vals_list)
|
||||
self.last_tracking_update = fields.Datetime.now()
|
||||
if delivered and self.status != 'delivered':
|
||||
self.status = 'delivered'
|
||||
self.delivery_date = fields.Datetime.now()
|
||||
self.message_post(body=_(
|
||||
"FedEx tracking refreshed: %(n)d event(s) loaded. Status: %(s)s"
|
||||
) % {'n': len(vals_list), 's': result.get('status') or '—'})
|
||||
|
||||
@@ -78,12 +78,17 @@
|
||||
string="Refresh Tracking"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
invisible="not tracking_number or status == 'cancelled'"/>
|
||||
invisible="not tracking_number or status == 'cancelled' or carrier_type not in ('canada_post', 'fedex_rest')"/>
|
||||
<button name="action_track_on_carrier" type="object"
|
||||
string="Track Shipment"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"
|
||||
invisible="not tracking_number"/>
|
||||
<button name="action_view_label" type="object"
|
||||
string="Print Shipping Label"
|
||||
class="btn-secondary"
|
||||
icon="fa-print"
|
||||
invisible="not label_attachment_id"/>
|
||||
<button name="action_create_return_label" type="object"
|
||||
string="Create Return Label"
|
||||
class="btn-warning"
|
||||
@@ -130,6 +135,14 @@
|
||||
<span class="o_stat_text">Events</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_label" type="object"
|
||||
class="oe_stat_button" icon="fa-print"
|
||||
invisible="not label_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">PDF</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
@@ -138,7 +151,8 @@
|
||||
<group>
|
||||
<group string="Shipment Details">
|
||||
<field name="tracking_number"/>
|
||||
<field name="shipment_id"/>
|
||||
<field name="shipment_id"
|
||||
invisible="carrier_type != 'canada_post'"/>
|
||||
<field name="carrier_id"/>
|
||||
<field name="carrier_type"/>
|
||||
<field name="service_type"/>
|
||||
|
||||
Reference in New Issue
Block a user