Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.59% covered (success)
98.59%
70 / 71
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProductionService
98.59% covered (success)
98.59%
70 / 71
80.00% covered (warning)
80.00%
4 / 5
18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 complete
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
9
 cancel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 nextNumber
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services\Manufacturing;
4
5use App\Enums\ProductionStatus;
6use App\Models\Bom;
7use App\Models\ProductionConsumption;
8use App\Models\ProductionOrder;
9use App\Models\ProductionOutput;
10use App\Models\Warehouse;
11use App\Services\Stock\LocationInventoryService;
12use Illuminate\Support\Facades\Auth;
13use 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 */
24class 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}