# Production Order Module — Implementation Plan

_Last updated: 2026-04-16 (rev 4) — **COMPLETE. Logic frozen.**_

---

## Goal

Create a wizard that produces **draft BOM(s)** ready for a planner to create MOs from.
**MOs are created manually by the planner** via the standard Dolibarr BOM card UI.

### Architectural Decision (2026-04-15)
- The module is a **BOM generator**, not an MO creator.
- Reason: MO creation is a human decision (planner responsibility).
- `createMO()` remains in `ProductionOrderService` but is NOT called from the wizard.
- After BOM creation → redirect to `/bom/bom_card.php?id=...` (planner clicks "Create MO" there).

---

## Target Output (confirmed from DB examples)

### SF BOM (forming — e.g. SF2371-00, job 1042)

```
BOM label: "Auto BOM for SF2371-00 (Job 1042)"
BOM qty = count(packs), status = Draft

Lines:
  position   30 → PRE-HEAT   (service)   ← routing op 30, WC PRE-HEAT
  position   60 → FORMING    (service)   ← routing op 60, WC FM3
  position   70 → VAQUA-BLAST(service)   ← routing op 70, WC VAQUA-BLAST
  position  110 → TRIMMING   (service)   ← routing op 110, WC CNC5
  position 1000 → 62133      (material)  qty=1  ← pack, ratio 1 sheet per unit
  position 1010 → 61111      (material)  qty=1  ← pack, ratio 1 sheet per unit
```

### FG BOM (trimming — e.g. FG2379-01, job 1042)

```
BOM label: "Auto BOM for FG2379-01 (Job 1042)"
BOM qty = 1, status = Draft

Lines:
  position    1 → SF2371-00  (material)  qty=1/N  ← SF parent, ratio = 1 / count(selected FGs)
  position   30 → PRE-HEAT   (service)             ← FG routing
  position   60 → FORMING    (service)             ← FG routing
  ... (FG has its own routing, may differ from SF)
```

---

## Golden Rules — BOM Quantities

### Rule 1: SF BOM header qty = count(packs)
```
pack input: "62133×10, 61111×10"  →  bom.qty = 2
```
Reflects how many material packs are being consumed in this run.

### Rule 2: Pack BOM line qty = 1 (ratio)
```
62133 qty=1, 61111 qty=1
```
One sheet per finished unit. Dolibarr scales automatically: MO qty × line qty = total consumption.  
The `×10` in the pack input is the MO qty (sheets per pack), NOT the BOM line qty.

### Rule 3: FG BOM SF parent qty = 1 / count(selected FGs)
```
2 FG selected  →  SF qty = 0.5   (1 SF forming produces 2 FG parts)
4 FG selected  →  SF qty = 0.25  (1 SF forming produces 4 FG parts)
1 FG selected  →  SF qty = 1.0
N FG selected  →  SF qty = 1/N
```
This ratio tells Dolibarr how much SF forming is consumed per FG unit produced.

---

**Golden Rule — Job Number Assignment:**
Every BOM gets its own unique sequential job number. Never shared.

```
SF  BOM → job N
FG1 BOM → job N+1
FG2 BOM → job N+2
FG3 BOM → job N+3
```

One pack/job = one SF BOM + N × FG BOMs (one per selected FG child product).
Job numbers are allocated in order: SF first, then FG children sorted alphabetically by ref.

---

## Routing Data Reality

- **272 products** have routing in `llx_productionrules_routing` (imported from CSV)
- Each product has its own per-product copy of routing rows (not a shared template)
- Many products share identical op sequences (13 products have the same FM6+LASER2+CHEMI sequence)
- Routing ops use `op_no` (10, 20, 30…) as sequence — `op_code` has been removed
- Workstations are linked via `llx_productionrules_routing_wc` → `llx_workstation_workstation`

---

## Missing Link: Workstation → Service Product

The routing gives us a **workstation** (FM6, CNC4, CHEMI1…).
The BOM needs a **service product** (FORMING, TRIMMING, CHEMI_CLEAN…).

**Solution**: add `fk_service_product` column to `llx_workstation_workstation`.

| Workstation | Service Product |
|---|---|
| FM2, FM3, FM4, FM5, FM6, FM7, FM8, FM9 | FORMING |
| CNC4, CNC5, LASER1, LASER2 | TRIMMING |
| CHEMI1, CHEMI2 | CHEMI_CLEAN |
| WASH | WASH |
| PACKAGE, PACK1, PACK2 | PACKAGE |
| DESPATCH | DESPATCH |
| ASSEMBLY1, ASSEMBLY2, ASSEMBLY3 | ASSEMBLY |
| VAQUA-BLAST | VAQUA-BLAST |
| PLASMA | PLASMA |
| BANDSAW | BANDSAW |
| DRILL | DRILL |
| INSPECTION, CMM | INSPECTION |

Rules for BOM line generation from routing:
- Op has workstation AND workstation has `fk_service_product` → add service line (position = op_no)
- Op has no workstation / workstation has no service product → skip (admin/planning ops like TOOL CALL-OFF)
- Subcontract ops → use `SUBCON` service or specific subcon service product (2001-2014)

---

## SF Auto-Lookup

When creating a PO for an FG product, auto-find its SF parent:

```sql
SELECT sf.rowid, sf.ref FROM llx_product sf
JOIN llx_product_extrafields sf_ef ON sf_ef.fk_object = sf.rowid
JOIN llx_product_extrafields fg_ef ON fg_ef.form_tool = sf_ef.form_tool
  AND fg_ef.fk_object = [fg_product_id]
WHERE sf.ref LIKE 'SF%'
  AND SUBSTRING_INDEX(SUBSTRING_INDEX(sf.ref,'-',1),'SF',-1)
    = SUBSTRING_INDEX(SUBSTRING_INDEX(fg.ref,'-',1),'FG',-1)
ORDER BY sf.ref DESC LIMIT 1
```

Results from DB validation:
- 96 FG → exactly 1 SF match
- 23 FG → 2 SF (revisions) → ORDER BY ref DESC LIMIT 1 picks latest
- 2133 FG → 0 SF → single MO only (no SF stage)

---

## Pack Material

User enters pack (raw material) in Step 1 — optional:
- Autocomplete searches products WHERE `ref REGEXP '^[0-9]+$'`
- Pack qty field (defaults to MO qty)
- Goes into BOM at position 1000+

---

## Implementation Steps

| # | Task | Status |
|---|---|---|
| 1 | DB: `fk_service_product` on workstation table + full mapping | ✅ done |
| 2 | Wizard Step 1/2, BomResolver, AJAX, JS | ✅ done |
| 3 | `ProductionOrderService::createBom()` — BomLine direct insert, Draft, fk_default_workstation | ✅ done |
| 4 | `findSfParent()` via form_tool extrafield | ✅ done |
| 5 | Pivot to BOM-only output (no MO creation from wizard) | ✅ done |
| 6 | SF → FG children lookup + multi-BOM preview | ✅ done |
| 7 | Job number allocation: SF=N, FG1=N+1, FG2=N+2... | ✅ done |
| 8 | Multi-pack input (`62133×10, 61111×10`), `parsePacks()` | ✅ done |
| 9 | `bom.qty = count(packs)`, `bomline.qty = 1` (ratio), `sfQty = 1/N` | ✅ done |
| 10 | `prod_mo_material_overrides` — real pack qty per BOM line | ✅ done |
| 11 | `prod_bom_mo_qty` — `qty_to_produce = sum(all pack qtys)` | ✅ done |
| 12 | End-to-end test | ✅ done |

**All logic is complete and tested. No further changes to BOM generation logic planned.**
**Only remaining work: UI/CSS polish (separate task, not urgent).**

---

## Routing Data Reality

### Files
```
productionorder/
  production_order.php          ← main wizard page
  class/
    ProductionOrderService.class.php  ← business logic
    BomResolver.class.php             ← BOM component lookup
  ajax/
    get_product_preview.php     ← AJAX: routing + materials for a product
    suggest_sf_parent.php       ← AJAX: SF autocomplete based on FG STOCKCODES mapping
  admin/
    setup.php                   ← module config
  js/
    productionorder.js          ← wizard UI, quantity parser, preview panel
  css/
    productionorder.css
  sql/
    install.sql                 ← llx_productionorder_draft
    uninstall.sql
  docs/
    implementation_plan.md      ← this file
    data_model.md
```

---

## Page Flow: `production_order.php`

```
┌─────────────────────────────────────────────────────┐
│  STEP 1 — Product + Quantity                        │
│  [Product ref ▼] [Qty / pack notation] [Job #]      │
│  [PREVIEW →]                                         │
└─────────────────────────────────────────────────────┘
        ↓ AJAX get_product_preview.php
┌─────────────────────────────────────────────────────┐
│  STEP 2 — Preview (editable)                        │
│                                                     │
│  ROUTING (from confirmed routing)                   │
│  10  LASER1   Laser profiling                       │
│  20  HEM1     Hemming                               │
│  30  ASSEMBLY1 Assembly + inspection                │
│  [+ Add op] [reorder]                               │
│                                                     │
│  MATERIALS (from BOM, qty scaled to total_qty)      │
│  Seq  Ref            Description        Qty   FI    │
│  10   M1950-1350...  Sheet material     2.0   ✓     │
│  20   B9-RIVET-...   Rivet              40         │
│  [+ Add material] [override qty]                    │
│                                                     │
│  [← Back]  [CREATE MO (draft) →]                   │
└─────────────────────────────────────────────────────┘
        ↓ POST production_order.php?action=create
┌─────────────────────────────────────────────────────┐
│  MO created → link to standard Dolibarr MO card     │
└─────────────────────────────────────────────────────┘
```

---

## `ProductionOrderService::createMO()`

```php
createMO(User $user, array $params): int|false
  $params = [
    'fk_product'    => int,
    'qty'           => float,         // parsed from notation
    'job_number'    => int|null,      // auto-assigned if null
    'label'         => string|null,   // MO label override
    'routing_ops'   => array,         // from preview (may be edited)
    'materials'     => array,         // from preview (may be overridden)
    'entity'        => int,
  ]
```

### Steps inside `createMO()`:

1. **Validate** confirmed routing exists (warn if not — allow override)
2. **Resolve job_number** (MAX+1 from `llx_mrp_mo_extrafields.job_number`)
3. **INSERT `llx_mrp_mo`** — status=1 (draft), fk_product, qty, label, entity
4. **INSERT `llx_mrp_mo_extrafields`** — job_number
5. **INSERT `llx_mrp_production`** — for each routing op:
   - `fk_mo`, `fk_product`=service product (may be null), `fk_default_workstation`, `rank`=op_no, `label`, `qty`=1, `role`='produced'
6. **INSERT `llx_mrp_production`** — for each material:
   - `fk_mo`, `fk_product`=material, `qty`=bom_qty×total_qty (scaled), `role`='consumed', `position`=seq_no
7. **INSERT `llx_mrp_mo_optracking`** — for each routing op:
   - `fk_mo`, `op_rank`=op_no, `op_code`, `fk_workstation`, `qty_done`=0, `status`=0
8. Return `$mo->id`

---

## `BomResolver::getMaterials()`

```php
getMaterials(DoliDB $db, int $fkProduct, float $qty, int $entity): array
```

Priority:
1. If product has BOM in `llx_bom_bom` → use Dolibarr BOM lines
2. Else if product exists in imported BOM CSV data (future: `llx_productionorder_bom_import`) → use that
3. Else → return empty (user adds manually in preview)

Scaling: `component_qty = bom_line_qty × qty`

---

## Draft Save Table: `llx_productionorder_draft`

Stores in-progress wizard state (session alternative — survives page reload):

```sql
CREATE TABLE llx_productionorder_draft (
  rowid       INT AUTO_INCREMENT PRIMARY KEY,
  entity      INT NOT NULL DEFAULT 1,
  fk_user     INT NOT NULL,
  fk_product  INT,
  qty         DECIMAL(10,3),
  job_number  INT,
  draft_data  LONGTEXT,   -- JSON: routing_ops + materials + overrides
  datec       DATETIME,
  tms         TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_draft_user (fk_user, entity)
) ENGINE=InnoDB;
```

---

## Permissions

| Right | Key | Default |
|---|---|---|
| View | `productionorder->read` | off |
| Create MO | `productionorder->write` | off |
| Override materials | `productionorder->override_materials` | off |

---

## Reference: `productionrules-master`

**`productionrules-master` is NOT used by this module and is NOT a dependency.**
It is a reference example only — consulted to understand the expected BOM creation
behaviour (multi-pack parsing, material storage pattern, autocomplete logic).
All implementation in `productionorder` is independent.

---

## Dependency on `routingmanager`

**The `routingmanager` module is used exclusively for routing management.**
Its ruleset engine, BOM generation, and SO-rules flows are NOT used by this module.

| Feature | Source |
|---|---|
| Routing operations | `ProductionRoutingService::getRoutingByProduct()` |
| Confirmed status check | `llx_productionrules_routing_confirm` |

All other logic (qty×pack, BOM generation, material overrides) is implemented
**directly in `productionorder`** — no dependency on `routingmanager` class files.

## Known Issues / Open Work

- **UI/CSS polish** — Step 1 and Step 2 layout improvements (deferred, separate task)
- No other known issues

---

## Closed / Resolved

- ~~`BOM::create` failed with "Field 'Label' is required"~~ → fixed: `$bom->label = productRef`
- ~~MO qty pre-fills as 1~~ → fixed: `prod_bom_mo_qty.qty_to_produce = sum(all pack qtys)`
- ~~Pack line qty was raw sheets instead of ratio~~ → fixed: `bomline.qty = 1`
- ~~`findSfParent()` extrafield join~~ → verified correct column name in this DB

---
