Skip to content

Commit a2990c3

Browse files
committed
Phases 161-165: Manufacturing/MRP Module
- Module: app/Modules/Manufacturing/ (new module) - Phase 161: BillOfMaterials model + BomLine; BomController (CRUD + inline lines); React pages: BillsOfMaterials/Index, Create, Edit, Show - Phase 162: WorkCenter model; WorkCenterController CRUD; React pages: WorkCenters/Index, Create, Edit - Phase 163: ManufacturingOrder + MoComponent models; MO number = MO-YYYY-NNNNN; confirm/start/complete/cancel lifecycle; fromBom() auto-populates components; React pages: ManufacturingOrders/Index, Create, Show - Phase 164: WorkOrder model; nested controller under MOs; start/finish/cancel transitions; React pages: WorkOrders shown in MO/Show - Phase 165: ManufacturingDashboardController; ManufacturingReportController; React pages: Manufacturing/Dashboard, Reports/ProductionOutput, Reports/BomCost - ManufacturingServiceProvider registered via CoreServiceProvider - Sidebar: Manufacturing section added - 21 tests, all passing https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 5d3cdb5 commit a2990c3

44 files changed

Lines changed: 4254 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Modules\Finance\Providers\FinanceServiceProvider;
1010
use App\Modules\HR\Providers\HRServiceProvider;
1111
use App\Modules\Inventory\Providers\InventoryServiceProvider;
12+
use App\Modules\Manufacturing\Providers\ManufacturingServiceProvider;
1213
use Illuminate\Support\Facades\Gate;
1314
use Illuminate\Support\ServiceProvider;
1415

@@ -19,6 +20,7 @@ public function register(): void
1920
$this->app->register(InventoryServiceProvider::class);
2021
$this->app->register(FinanceServiceProvider::class);
2122
$this->app->register(HRServiceProvider::class);
23+
$this->app->register(ManufacturingServiceProvider::class);
2224
}
2325

2426
public function boot(): void
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace App\Modules\Manufacturing\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Product;
7+
use App\Modules\Manufacturing\Models\BillOfMaterials;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class BomController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$this->authorize('viewAny', BillOfMaterials::class);
18+
19+
$boms = BillOfMaterials::with('product')
20+
->withCount('lines')
21+
->when($request->search, fn ($q) => $q->where('name', 'like', "%{$request->search}%")
22+
->orWhere('code', 'like', "%{$request->search}%"))
23+
->orderBy('name')
24+
->paginate(25)
25+
->withQueryString();
26+
27+
return Inertia::render('Manufacturing/BillsOfMaterials/Index', [
28+
'boms' => $boms,
29+
'filters' => $request->only(['search']),
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', BillOfMaterials::class);
36+
37+
return Inertia::render('Manufacturing/BillsOfMaterials/Create', [
38+
'products' => Product::orderBy('name')->get(['id', 'name', 'sku']),
39+
]);
40+
}
41+
42+
public function store(Request $request): RedirectResponse
43+
{
44+
$this->authorize('create', BillOfMaterials::class);
45+
46+
$validated = $request->validate([
47+
'product_id' => 'required|exists:products,id',
48+
'name' => 'required|string|max:255',
49+
'code' => 'nullable|string|max:100',
50+
'type' => 'required|in:manufacture,kit,subcontracting',
51+
'qty_per_bom' => 'required|numeric|min:0.0001',
52+
'uom' => 'nullable|string|max:50',
53+
'is_active' => 'boolean',
54+
'notes' => 'nullable|string',
55+
'lines' => 'array',
56+
'lines.*.component_id' => 'required|exists:products,id',
57+
'lines.*.quantity' => 'required|numeric|min:0.0001',
58+
'lines.*.uom' => 'nullable|string|max:50',
59+
'lines.*.sequence' => 'nullable|integer|min:1',
60+
'lines.*.is_optional' => 'boolean',
61+
'lines.*.notes' => 'nullable|string',
62+
]);
63+
64+
$bom = BillOfMaterials::create([
65+
...$validated,
66+
'tenant_id' => auth()->user()->tenant_id,
67+
]);
68+
69+
foreach ($validated['lines'] ?? [] as $line) {
70+
$bom->lines()->create($line);
71+
}
72+
73+
return redirect()->route('manufacturing.boms.index')
74+
->with('success', 'Bill of Materials created successfully.');
75+
}
76+
77+
public function show(BillOfMaterials $bom): Response
78+
{
79+
$this->authorize('view', $bom);
80+
81+
$bom->load(['product', 'lines.component']);
82+
83+
return Inertia::render('Manufacturing/BillsOfMaterials/Show', [
84+
'bom' => $bom,
85+
]);
86+
}
87+
88+
public function edit(BillOfMaterials $bom): Response
89+
{
90+
$this->authorize('update', $bom);
91+
92+
$bom->load(['product', 'lines.component']);
93+
94+
return Inertia::render('Manufacturing/BillsOfMaterials/Edit', [
95+
'bom' => $bom,
96+
'products' => Product::orderBy('name')->get(['id', 'name', 'sku']),
97+
]);
98+
}
99+
100+
public function update(Request $request, BillOfMaterials $bom): RedirectResponse
101+
{
102+
$this->authorize('update', $bom);
103+
104+
$validated = $request->validate([
105+
'product_id' => 'required|exists:products,id',
106+
'name' => 'required|string|max:255',
107+
'code' => 'nullable|string|max:100',
108+
'type' => 'required|in:manufacture,kit,subcontracting',
109+
'qty_per_bom' => 'required|numeric|min:0.0001',
110+
'uom' => 'nullable|string|max:50',
111+
'is_active' => 'boolean',
112+
'notes' => 'nullable|string',
113+
'lines' => 'array',
114+
'lines.*.component_id' => 'required|exists:products,id',
115+
'lines.*.quantity' => 'required|numeric|min:0.0001',
116+
'lines.*.uom' => 'nullable|string|max:50',
117+
'lines.*.sequence' => 'nullable|integer|min:1',
118+
'lines.*.is_optional' => 'boolean',
119+
'lines.*.notes' => 'nullable|string',
120+
]);
121+
122+
$bom->update($validated);
123+
124+
// Sync lines: delete old, insert new
125+
$bom->lines()->delete();
126+
foreach ($validated['lines'] ?? [] as $line) {
127+
$bom->lines()->create($line);
128+
}
129+
130+
return redirect()->route('manufacturing.boms.show', $bom)
131+
->with('success', 'Bill of Materials updated successfully.');
132+
}
133+
134+
public function destroy(BillOfMaterials $bom): RedirectResponse
135+
{
136+
$this->authorize('delete', $bom);
137+
138+
$bom->delete();
139+
140+
return redirect()->route('manufacturing.boms.index')
141+
->with('success', 'Bill of Materials deleted successfully.');
142+
}
143+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace App\Modules\Manufacturing\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Manufacturing\Models\ManufacturingOrder;
7+
use App\Modules\Manufacturing\Models\WorkCenter;
8+
use App\Modules\Manufacturing\Models\WorkOrder;
9+
use Illuminate\Support\Carbon;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class ManufacturingDashboardController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$now = Carbon::now();
18+
$startOfMonth = $now->copy()->startOfMonth();
19+
$startOfWeek = $now->copy()->startOfWeek();
20+
$endOfWeek = $now->copy()->endOfWeek();
21+
22+
$openMos = ManufacturingOrder::whereIn('status', ['draft', 'confirmed'])->count();
23+
$inProgressMos = ManufacturingOrder::where('status', 'in_progress')->count();
24+
25+
$doneMos = ManufacturingOrder::where('status', 'done')
26+
->where('finish_date', '>=', $startOfMonth->toDateString())
27+
->count();
28+
29+
$scheduledThisWeek = ManufacturingOrder::whereBetween('scheduled_date', [
30+
$startOfWeek->toDateString(), $endOfWeek->toDateString(),
31+
])->count();
32+
33+
$efficiencyData = ManufacturingOrder::where('status', 'done')
34+
->where('finish_date', '>=', $startOfMonth->toDateString())
35+
->where('qty_to_produce', '>', 0)
36+
->get(['qty_produced', 'qty_to_produce']);
37+
38+
$efficiency = $efficiencyData->isNotEmpty()
39+
? round($efficiencyData->avg(fn ($mo) => ($mo->qty_produced / $mo->qty_to_produce) * 100), 1)
40+
: 0;
41+
42+
$recentMos = ManufacturingOrder::with('product')
43+
->orderByDesc('created_at')
44+
->limit(5)
45+
->get(['id', 'mo_number', 'product_id', 'status', 'qty_to_produce', 'scheduled_date']);
46+
47+
$workCenterUtilization = WorkCenter::withCount([
48+
'workOrders as open_work_orders_count' => fn ($q) => $q->whereIn('status', ['pending', 'in_progress']),
49+
'workOrders as done_work_orders_count' => fn ($q) => $q->where('status', 'done'),
50+
])->orderBy('name')->get(['id', 'name', 'code']);
51+
52+
return Inertia::render('Manufacturing/Dashboard', [
53+
'stats' => [
54+
'openMos' => $openMos,
55+
'inProgressMos' => $inProgressMos,
56+
'doneMos' => $doneMos,
57+
'scheduledThisWeek' => $scheduledThisWeek,
58+
'efficiency' => $efficiency,
59+
],
60+
'recentMos' => $recentMos,
61+
'workCenterUtilization' => $workCenterUtilization,
62+
]);
63+
}
64+
}

0 commit comments

Comments
 (0)