This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

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

View File

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

View File

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

View File

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