changes
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user