Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.59% |
70 / 71 |
|
80.00% |
4 / 5 |
CRAP | |
0.00% |
0 / 1 |
| ProductionService | |
98.59% |
70 / 71 |
|
80.00% |
4 / 5 |
18 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| create | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
| complete | |
97.62% |
41 / 42 |
|
0.00% |
0 / 1 |
9 | |||
| cancel | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| nextNumber | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services\Manufacturing; |
| 4 | |
| 5 | use App\Enums\ProductionStatus; |
| 6 | use App\Models\Bom; |
| 7 | use App\Models\ProductionConsumption; |
| 8 | use App\Models\ProductionOrder; |
| 9 | use App\Models\ProductionOutput; |
| 10 | use App\Models\Warehouse; |
| 11 | use App\Services\Stock\LocationInventoryService; |
| 12 | use Illuminate\Support\Facades\Auth; |
| 13 | use Illuminate\Support\Facades\DB; |
| 14 | |
| 15 | /** |
| 16 | * The write side of Manufacturing. Stock for a material/finished product is |
| 17 | * still just Product::stock (the same source of truth |
| 18 | * App\Service\StockService::syncStockForProduct() already reads for every |
| 19 | * other simple product) — production consumption/output adjusts that same |
| 20 | * column rather than inventing a parallel stock system, then re-syncs so |
| 21 | * the derived Stock row (what the POS/storefront actually check) reflects |
| 22 | * it immediately. Every adjustment is also logged to stock_movements. |
| 23 | */ |
| 24 | class ProductionService |
| 25 | { |
| 26 | public function __construct( |
| 27 | private readonly BomService $bomService, |
| 28 | private readonly LocationInventoryService $inventory, |
| 29 | ) {} |
| 30 | |
| 31 | /** A Planned order with its material requirements captured at this qty. */ |
| 32 | public function create(Bom $bom, float $plannedQty, ?string $note = null, ?int $sourceWarehouseId = null, ?int $outputWarehouseId = null): ProductionOrder |
| 33 | { |
| 34 | return DB::transaction(function () use ($bom, $plannedQty, $note, $sourceWarehouseId, $outputWarehouseId) { |
| 35 | $warehouse = feature('multi_warehouse') ? Warehouse::active()->findOrFail($sourceWarehouseId) : null; |
| 36 | $order = ProductionOrder::create([ |
| 37 | 'number' => $this->nextNumber(), |
| 38 | 'bom_id' => $bom->id, |
| 39 | 'planned_qty' => $plannedQty, |
| 40 | 'status' => ProductionStatus::Planned->value, |
| 41 | 'note' => $note, |
| 42 | 'branch_id' => $warehouse?->branch_id, |
| 43 | 'source_warehouse_id' => $warehouse?->id, |
| 44 | 'output_warehouse_id' => feature('multi_warehouse') ? ($outputWarehouseId ?: $warehouse?->id) : null, |
| 45 | 'created_by' => Auth::id(), |
| 46 | ]); |
| 47 | |
| 48 | foreach ($this->bomService->requirements($bom, $plannedQty) as $req) { |
| 49 | ProductionConsumption::create([ |
| 50 | 'production_order_id' => $order->id, |
| 51 | 'material_id' => $req['material']->id, |
| 52 | 'planned_qty' => $req['required'], |
| 53 | ]); |
| 54 | } |
| 55 | |
| 56 | return $order; |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Consume materials, produce the finished good, cost it, mark Completed. |
| 62 | * Re-checks stock right now (not just at create() time — stock may have |
| 63 | * moved since) and throws rather than partially committing. |
| 64 | * |
| 65 | * @throws \RuntimeException if any material is short |
| 66 | */ |
| 67 | public function complete(ProductionOrder $order, ?float $actualQty = null, ?float $laborCost = null, ?float $overheadCost = null): ProductionOrder |
| 68 | { |
| 69 | if (! $order->statusEnum()->isOpen()) { |
| 70 | throw new \RuntimeException("Order {$order->number} is already {$order->status} — it can't be completed again."); |
| 71 | } |
| 72 | |
| 73 | $actualQty ??= (float) $order->planned_qty; |
| 74 | $bom = $order->bom; |
| 75 | |
| 76 | return DB::transaction(function () use ($order, $bom, $actualQty, $laborCost, $overheadCost) { |
| 77 | $requirements = $this->bomService->requirements($bom, $actualQty); |
| 78 | $short = $requirements->first(fn ($r) => ! $r['sufficient']); |
| 79 | |
| 80 | if ($short) { |
| 81 | throw new \RuntimeException( |
| 82 | "Not enough \"{$short['material']->name}\" — need {$short['required']}, have {$short['available']}." |
| 83 | ); |
| 84 | } |
| 85 | |
| 86 | $materialCost = 0.0; |
| 87 | |
| 88 | foreach ($order->consumptions as $consumption) { |
| 89 | $req = $requirements->firstWhere('material.id', $consumption->material_id); |
| 90 | $qty = $req['required']; |
| 91 | $material = $req['material']; |
| 92 | |
| 93 | $this->inventory->deduct($material, null, (float) $qty, $order->source_warehouse_id, $order, 'production_out'); |
| 94 | |
| 95 | $consumption->update(['actual_qty' => $qty]); |
| 96 | $materialCost += $qty * (float) ($material->cost_price ?? $material->price ?? 0); |
| 97 | } |
| 98 | |
| 99 | $product = $bom->product; |
| 100 | $this->inventory->receive($product, null, (float) $actualQty, $order->output_warehouse_id, $order, 'production_in'); |
| 101 | |
| 102 | ProductionOutput::create([ |
| 103 | 'production_order_id' => $order->id, |
| 104 | 'product_id' => $product->id, |
| 105 | 'qty' => $actualQty, |
| 106 | ]); |
| 107 | |
| 108 | $unitCost = null; |
| 109 | |
| 110 | // Advanced costing (production_costing flag) — labor/overhead are |
| 111 | // only ever set when that flag is on; without it this whole block |
| 112 | // is skipped and the finished product's cost_price is untouched. |
| 113 | if (feature('production_costing') && ($laborCost !== null || $overheadCost !== null)) { |
| 114 | $totalCost = $materialCost + (float) ($laborCost ?? 0) + (float) ($overheadCost ?? 0); |
| 115 | $unitCost = $actualQty > 0 ? round($totalCost / $actualQty, 4) : null; |
| 116 | |
| 117 | if ($unitCost !== null) { |
| 118 | $product->update(['cost_price' => $unitCost]); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | $order->update([ |
| 123 | 'status' => ProductionStatus::Completed->value, |
| 124 | 'actual_qty' => $actualQty, |
| 125 | 'labor_cost' => $laborCost, |
| 126 | 'overhead_cost' => $overheadCost, |
| 127 | 'unit_cost' => $unitCost, |
| 128 | 'completed_at' => now(), |
| 129 | ]); |
| 130 | |
| 131 | return $order->fresh(['consumptions', 'outputs']); |
| 132 | }); |
| 133 | } |
| 134 | |
| 135 | public function cancel(ProductionOrder $order): ProductionOrder |
| 136 | { |
| 137 | if (! $order->statusEnum()->isOpen()) { |
| 138 | throw new \RuntimeException("Order {$order->number} is already {$order->status}."); |
| 139 | } |
| 140 | |
| 141 | $order->update(['status' => ProductionStatus::Cancelled->value]); |
| 142 | |
| 143 | return $order; |
| 144 | } |
| 145 | |
| 146 | private function nextNumber(): string |
| 147 | { |
| 148 | $dateCode = date('Ymd'); |
| 149 | $randomCode = strtoupper(substr(uniqid(), -5)); |
| 150 | |
| 151 | return "PO-{$dateCode}-{$randomCode}"; |
| 152 | } |
| 153 | } |