Skip to content

Commit a915b72

Browse files
Merge pull request #53 from Mes-Open/feature/13-scarp-reasons-codes
Feature/13 scarp reasons codes
2 parents cc28a5d + bfa031d commit a915b72

38 files changed

Lines changed: 2242 additions & 7 deletions

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
88
## [Unreleased]
99

1010
### Added
11+
- Scrap reason codes - categorized defect tracking per work order: `scrap_reasons` with a 5M Ishikawa category (material/machine/method/man/environment), admin CRUD + activate/deactivate, and 5 seeded default reasons (#13)
12+
- Operator scrap reporting on the work order detail page (reason, quantity, notes); `scrap_entries` link to the work order and optionally to a batch step and shift, with a per-work-order total scrap quantity and a derived quality % metric (#13)
13+
- Scrap reports: Pareto by reason, scrap rate per line, and scrap trend over time (Chart.js), plus REST API endpoints `reports/scrap-pareto`, `reports/scrap-rate`, scrap-reason read/CRUD and scrap-entry report/list (#13)
1114
- Work Order History: relocated Reports into its own nav group (between Production and Structure) and turned it into a read-only historical analysis view over finished orders (DONE / CANCELLED / REJECTED). Filter by status, line, product type, full-text (order no. / LOT) and date — with day presets (today, yesterday, last 7/30 days, this/last month, custom range, all time). Summary aggregates (orders, produced, planned, avg execution time, on-time %), CSV export, and a deep per-order drill-down: execution timeline, batches with assigned LOTs, steps with start/end times, duration and operator, material genealogy (consumed lots), quality checks and issues raised. All execution data is retained indefinitely.
1215

1316
### Changed
14-
- Reports nav entry moved out of the Admin group into a dedicated Reports group; the previous aggregate KPI dashboard was replaced by the Work Order History view.
17+
- Reports nav entry moved out of the Admin group into a dedicated Reports group (Scrap Reports moved alongside it); the previous aggregate KPI dashboard was replaced by the Work Order History view.
1518

1619
---
1720

backend/app/Http/Controllers/Api/V1/ReportController.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
use App\Models\Batch;
88
use App\Models\Issue;
99
use App\Models\Line;
10+
use App\Services\Scrap\ScrapReportService;
1011
use App\Support\Csv;
12+
use Illuminate\Http\JsonResponse;
1113
use Illuminate\Http\Request;
1214
use Illuminate\Support\Facades\DB;
1315
use Carbon\Carbon;
@@ -205,6 +207,62 @@ public function downtimeReport(Request $request)
205207
return response()->json(['data' => $report]);
206208
}
207209

210+
/**
211+
* Pareto data by scrap reason (descending scrap quantity, cumulative share).
212+
*/
213+
public function scrapPareto(Request $request, ScrapReportService $service): JsonResponse
214+
{
215+
[$from, $to, $lineId] = $this->scrapReportRange($request);
216+
217+
return response()->json(['data' => [
218+
'period' => ['start' => $from->toDateString(), 'end' => $to->toDateString()],
219+
'line_id' => $lineId,
220+
'pareto' => $service->pareto($from, $to, $lineId),
221+
'by_category' => $service->byCategory($from, $to, $lineId),
222+
'generated_at' => now()->toIso8601String(),
223+
]]);
224+
}
225+
226+
/**
227+
* Scrap rate per line over time (scrap qty / total produced) plus daily trend.
228+
*/
229+
public function scrapRate(Request $request, ScrapReportService $service): JsonResponse
230+
{
231+
[$from, $to, $lineId] = $this->scrapReportRange($request);
232+
233+
return response()->json(['data' => [
234+
'period' => ['start' => $from->toDateString(), 'end' => $to->toDateString()],
235+
'per_line' => $service->ratePerLine($from, $to),
236+
'trend' => $service->trend($from, $to, $lineId),
237+
'generated_at' => now()->toIso8601String(),
238+
]]);
239+
}
240+
241+
/**
242+
* Resolve and validate the [from, to, lineId] window for scrap reports.
243+
* Defaults to the last 30 days when no dates are supplied.
244+
*
245+
* @return array{0: Carbon, 1: Carbon, 2: int|null}
246+
*/
247+
private function scrapReportRange(Request $request): array
248+
{
249+
$validated = $request->validate([
250+
'line_id' => ['nullable', 'integer', 'exists:lines,id'],
251+
'start_date' => ['nullable', 'date'],
252+
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
253+
]);
254+
255+
$from = isset($validated['start_date'])
256+
? Carbon::parse($validated['start_date'])->startOfDay()
257+
: today()->subDays(29)->startOfDay();
258+
$to = isset($validated['end_date'])
259+
? Carbon::parse($validated['end_date'])->endOfDay()
260+
: today()->endOfDay();
261+
$lineId = isset($validated['line_id']) ? (int) $validated['line_id'] : null;
262+
263+
return [$from, $to, $lineId];
264+
}
265+
208266
/**
209267
* Export report as CSV
210268
*/
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\ScrapEntry;
7+
use App\Models\WorkOrder;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
12+
class ScrapEntryController extends Controller
13+
{
14+
public function index(Request $request): JsonResponse
15+
{
16+
$this->authorize('viewAny', ScrapEntry::class);
17+
18+
// Tenant isolation: only entries whose work order is visible under the
19+
// WorkOrder tenant scope (ScrapEntry itself has no tenant column).
20+
// No-op for single-tenant installs (tenant scope is inactive there).
21+
$query = ScrapEntry::query()->whereHas('workOrder')->with(['scrapReason', 'reportedBy', 'workOrder']);
22+
if ($woId = $request->query('work_order_id')) {
23+
$query->where('work_order_id', $woId);
24+
}
25+
if ($lineId = $request->query('line_id')) {
26+
$query->whereHas('workOrder', fn ($q) => $q->where('line_id', $lineId));
27+
}
28+
if ($reasonId = $request->query('scrap_reason_id')) {
29+
$query->where('scrap_reason_id', $reasonId);
30+
}
31+
if ($from = $request->query('from')) {
32+
$query->where('reported_at', '>=', $from);
33+
}
34+
if ($to = $request->query('to')) {
35+
$query->where('reported_at', '<=', $to);
36+
}
37+
38+
$perPage = max(1, min((int) $request->query('per_page', 30), 100));
39+
$page = $query->orderByDesc('reported_at')->paginate($perPage);
40+
41+
return response()->json([
42+
'data' => $page->items(),
43+
'meta' => [
44+
'current_page' => $page->currentPage(),
45+
'per_page' => $page->perPage(),
46+
'total' => $page->total(),
47+
'last_page' => $page->lastPage(),
48+
],
49+
]);
50+
}
51+
52+
/**
53+
* List scrap entries for a single work order, with the work order totals.
54+
*/
55+
public function forWorkOrder(WorkOrder $workOrder): JsonResponse
56+
{
57+
$this->authorize('viewAny', ScrapEntry::class);
58+
59+
$entries = $workOrder->scrapEntries()
60+
->with(['scrapReason', 'reportedBy', 'batchStep', 'shift'])
61+
->orderByDesc('reported_at')
62+
->get();
63+
64+
return response()->json([
65+
'data' => $entries,
66+
'meta' => [
67+
'work_order_id' => $workOrder->id,
68+
'total_scrap_qty' => $workOrder->totalScrapQty(),
69+
'quality_pct' => $workOrder->qualityPct(),
70+
],
71+
]);
72+
}
73+
74+
public function show(ScrapEntry $scrapEntry): JsonResponse
75+
{
76+
$this->authorize('view', $scrapEntry);
77+
$this->assertTenantVisible($scrapEntry);
78+
$scrapEntry->load(['scrapReason', 'reportedBy', 'workOrder', 'batchStep', 'shift']);
79+
80+
return response()->json(['data' => $scrapEntry]);
81+
}
82+
83+
public function store(Request $request, WorkOrder $workOrder): JsonResponse
84+
{
85+
$this->authorize('create', ScrapEntry::class);
86+
$data = $request->validate([
87+
'scrap_reason_id' => ['required', 'integer', Rule::exists('scrap_reasons', 'id')->where('is_active', true)],
88+
'quantity' => ['required', 'numeric', 'min:0.01', 'max:99999999'],
89+
'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'],
90+
'shift_id' => ['nullable', 'integer', 'exists:shifts,id'],
91+
'notes' => ['nullable', 'string'],
92+
'reported_at' => ['nullable', 'date'],
93+
]);
94+
$data['work_order_id'] = $workOrder->id;
95+
$data['reported_by'] = $request->user()->id;
96+
$data['reported_at'] = $data['reported_at'] ?? now();
97+
98+
$entry = ScrapEntry::create($data);
99+
100+
return response()->json([
101+
'message' => 'Scrap recorded',
102+
'data' => $entry->load(['scrapReason', 'reportedBy']),
103+
], 201);
104+
}
105+
106+
public function update(Request $request, ScrapEntry $scrapEntry): JsonResponse
107+
{
108+
$this->authorize('update', $scrapEntry);
109+
$this->assertTenantVisible($scrapEntry);
110+
$data = $request->validate([
111+
'scrap_reason_id' => ['sometimes', 'integer', Rule::exists('scrap_reasons', 'id')->where('is_active', true)],
112+
'quantity' => ['sometimes', 'numeric', 'min:0.01', 'max:99999999'],
113+
'batch_step_id' => ['sometimes', 'nullable', 'integer', 'exists:batch_steps,id'],
114+
'shift_id' => ['sometimes', 'nullable', 'integer', 'exists:shifts,id'],
115+
'notes' => ['sometimes', 'nullable', 'string'],
116+
]);
117+
$scrapEntry->update($data);
118+
119+
return response()->json(['message' => 'Scrap entry updated', 'data' => $scrapEntry->fresh(['scrapReason'])]);
120+
}
121+
122+
public function destroy(ScrapEntry $scrapEntry): JsonResponse
123+
{
124+
$this->authorize('delete', $scrapEntry);
125+
$this->assertTenantVisible($scrapEntry);
126+
$scrapEntry->delete();
127+
128+
return response()->json(['message' => 'Scrap entry deleted']);
129+
}
130+
131+
/**
132+
* Guard against cross-tenant access to a directly-bound scrap entry.
133+
*
134+
* ScrapEntry has no tenant column; isolation rides on its work order,
135+
* which carries the tenant scope. If the entry's work order is not
136+
* visible to the current tenant, treat the entry as not found.
137+
* No-op for single-tenant installs (the tenant scope is inactive).
138+
*/
139+
private function assertTenantVisible(ScrapEntry $scrapEntry): void
140+
{
141+
abort_unless($scrapEntry->workOrder()->exists(), 404);
142+
}
143+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\ScrapReason;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Validation\Rule;
10+
11+
class ScrapReasonController extends Controller
12+
{
13+
public function index(Request $request): JsonResponse
14+
{
15+
$this->authorize('viewAny', ScrapReason::class);
16+
17+
$query = ScrapReason::query();
18+
if (! $request->boolean('include_inactive')) {
19+
$query->where('is_active', true);
20+
}
21+
if ($cat = $request->query('category')) {
22+
$query->where('category', $cat);
23+
}
24+
25+
return response()->json(['data' => $query->ordered()->get()]);
26+
}
27+
28+
public function show(ScrapReason $scrapReason): JsonResponse
29+
{
30+
$this->authorize('view', $scrapReason);
31+
32+
return response()->json(['data' => $scrapReason]);
33+
}
34+
35+
public function store(Request $request): JsonResponse
36+
{
37+
$this->authorize('create', ScrapReason::class);
38+
$data = $request->validate([
39+
'code' => ['required', 'string', 'max:20', 'unique:scrap_reasons,code'],
40+
'name' => ['required', 'string', 'max:255'],
41+
'category' => ['required', Rule::in(ScrapReason::CATEGORIES)],
42+
'description' => ['nullable', 'string'],
43+
'sort_order' => ['nullable', 'integer', 'min:0', 'max:65535'],
44+
'is_active' => ['nullable', 'boolean'],
45+
]);
46+
$data['is_active'] = $data['is_active'] ?? true;
47+
$data['sort_order'] = $data['sort_order'] ?? 0;
48+
49+
$reason = ScrapReason::create($data);
50+
51+
return response()->json(['message' => 'Scrap reason created', 'data' => $reason], 201);
52+
}
53+
54+
public function update(Request $request, ScrapReason $scrapReason): JsonResponse
55+
{
56+
$this->authorize('update', $scrapReason);
57+
$data = $request->validate([
58+
'code' => ['sometimes', 'required', 'string', 'max:20', Rule::unique('scrap_reasons', 'code')->ignore($scrapReason->id)],
59+
'name' => ['sometimes', 'required', 'string', 'max:255'],
60+
'category' => ['sometimes', 'required', Rule::in(ScrapReason::CATEGORIES)],
61+
'description' => ['sometimes', 'nullable', 'string'],
62+
'sort_order' => ['sometimes', 'integer', 'min:0', 'max:65535'],
63+
'is_active' => ['sometimes', 'boolean'],
64+
]);
65+
$scrapReason->update($data);
66+
67+
return response()->json(['message' => 'Scrap reason updated', 'data' => $scrapReason->fresh()]);
68+
}
69+
70+
public function destroy(ScrapReason $scrapReason): JsonResponse
71+
{
72+
$this->authorize('delete', $scrapReason);
73+
if ($scrapReason->scrapEntries()->exists()) {
74+
return response()->json(['message' => 'Cannot delete reason referenced by scrap entries.'], 422);
75+
}
76+
$scrapReason->delete();
77+
78+
return response()->json(['message' => 'Scrap reason deleted']);
79+
}
80+
}

0 commit comments

Comments
 (0)