Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.44% covered (warning)
64.44%
29 / 45
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
BomController
64.44% covered (warning)
64.44%
29 / 45
40.00% covered (danger)
40.00%
4 / 10
22.81
0.00% covered (danger)
0.00%
0 / 1
 index
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 store
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 edit
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 update
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 destroy
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 validatedBom
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 syncItems
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 manufacturableProducts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 materialProducts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Http\Controllers\Controller;
6use App\Models\admin\Product;
7use App\Models\Bom;
8use Illuminate\Http\RedirectResponse;
9use Illuminate\Http\Request;
10use Illuminate\View\View;
11
12/**
13 * Settings → Manufacturing → Bill of Materials. A BOM is "this product is
14 * made from these raw materials" — App\Services\Manufacturing\BomService
15 * explodes it into required quantities, and ProductionOrderController is
16 * where an operator actually spends materials against one.
17 */
18class BomController extends Controller
19{
20    public function index(): View
21    {
22        $boms = Bom::query()->with('product', 'items.material')->latest()->get();
23
24        return view('admin.manufacturing.boms.index', compact('boms'));
25    }
26
27    public function create(): View
28    {
29        return view('admin.manufacturing.boms.form', [
30            'bom' => new Bom(['is_active' => true, 'version' => 1]),
31            'products' => $this->manufacturableProducts(),
32            'materials' => $this->materialProducts(),
33        ]);
34    }
35
36    public function store(Request $request): RedirectResponse
37    {
38        $bom = Bom::create($this->validatedBom($request) + ['created_by' => $request->user()->id]);
39        $this->syncItems($bom, $request);
40
41        return redirect()->route('admin.manufacturing.boms.index')->with('success', 'BOM created.');
42    }
43
44    public function edit(Bom $bom): View
45    {
46        $bom->load('items');
47
48        return view('admin.manufacturing.boms.form', [
49            'bom' => $bom,
50            'products' => $this->manufacturableProducts(),
51            'materials' => $this->materialProducts(),
52        ]);
53    }
54
55    public function update(Request $request, Bom $bom): RedirectResponse
56    {
57        $bom->update($this->validatedBom($request));
58        $this->syncItems($bom, $request);
59
60        return redirect()->route('admin.manufacturing.boms.index')->with('success', 'BOM updated.');
61    }
62
63    public function destroy(Bom $bom): RedirectResponse
64    {
65        if ($bom->productionOrders()->exists()) {
66            return back()->with('error', 'This BOM has production orders against it and can\'t be deleted.');
67        }
68
69        $bom->delete();
70
71        return redirect()->route('admin.manufacturing.boms.index')->with('success', 'BOM deleted.');
72    }
73
74    private function validatedBom(Request $request): array
75    {
76        $data = $request->validate([
77            'product_id' => ['required', 'exists:products,id'],
78            'name' => ['nullable', 'string', 'max:150'],
79            'version' => ['nullable', 'integer', 'min:1'],
80            'is_active' => ['nullable', 'boolean'],
81            'note' => ['nullable', 'string', 'max:1000'],
82        ]);
83
84        $data['is_active'] = $request->boolean('is_active');
85        $data['version'] = $data['version'] ?? 1;
86
87        return $data;
88    }
89
90    /** Replace this BOM's material lines with whatever the picker posted. */
91    private function syncItems(Bom $bom, Request $request): void
92    {
93        $rows = collect($request->input('items', []))
94            ->filter(fn ($r) => filled($r['material_id'] ?? null) && filled($r['qty'] ?? null));
95
96        $bom->items()->delete();
97
98        foreach ($rows->values() as $row) {
99            $bom->items()->create([
100                'material_id' => $row['material_id'],
101                'qty' => (float) $row['qty'],
102                'unit' => $row['unit'] ?? 'pc',
103                'wastage_pct' => filled($row['wastage_pct'] ?? null) ? (float) $row['wastage_pct'] : 0,
104            ]);
105        }
106    }
107
108    /** Finished/semi-finished products — the "what are we making" list. */
109    private function manufacturableProducts()
110    {
111        return Product::query()->whereIn('type', ['finished', 'semi_finished'])->orderBy('name')->get(['id', 'name', 'type']);
112    }
113
114    /** Raw materials (and semi-finished goods, which can themselves be inputs) — the "what does it take" list. */
115    private function materialProducts()
116    {
117        return Product::query()->whereIn('type', ['raw_material', 'semi_finished'])->orderBy('name')->get(['id', 'name', 'type']);
118    }
119}