From 37896dcf02273735790e46106eed774373f52417 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Tue, 2 Jun 2026 20:43:24 +0200 Subject: [PATCH] feat: add new feature about defects --- CHANGELOG.md | 7 + .../Controllers/Api/V1/ReportController.php | 58 ++++ .../Api/V1/ScrapEntryController.php | 143 ++++++++++ .../Api/V1/ScrapReasonController.php | 80 ++++++ .../Web/Admin/ScrapReasonController.php | 138 ++++++++++ .../Web/Admin/ScrapReportController.php | 49 ++++ .../Web/Operator/ScrapController.php | 46 ++++ .../Web/Operator/WorkOrderController.php | 7 +- backend/app/Models/ScrapEntry.php | 72 +++++ backend/app/Models/ScrapReason.php | 72 +++++ backend/app/Models/WorkOrder.php | 41 +++ backend/app/Policies/ScrapEntryPolicy.php | 20 ++ backend/app/Policies/ScrapReasonPolicy.php | 15 ++ .../app/Services/Scrap/ScrapReportService.php | 158 +++++++++++ .../database/factories/ScrapEntryFactory.php | 31 +++ .../database/factories/ScrapReasonFactory.php | 38 +++ ...6_01_120000_create_scrap_reasons_table.php | 30 +++ ...6_01_120001_create_scrap_entries_table.php | 33 +++ backend/database/seeders/DatabaseSeeder.php | 1 + .../database/seeders/ScrapReasonsSeeder.php | 32 +++ backend/lang/en.json | 46 +++- backend/lang/pl.json | 46 +++- backend/lang/tr.json | 47 +++- .../admin/scrap-reasons/create.blade.php | 85 ++++++ .../views/admin/scrap-reasons/edit.blade.php | 84 ++++++ .../views/admin/scrap-reasons/index.blade.php | 127 +++++++++ .../views/admin/scrap-reports/index.blade.php | 252 ++++++++++++++++++ backend/resources/views/layouts/app.blade.php | 4 +- .../layouts/components/sidebar.blade.php | 6 + .../operator/work-order-detail.blade.php | 107 +++++++- backend/routes/api.php | 19 ++ backend/routes/web.php | 11 + backend/tests/Feature/Api/ScrapApiTest.php | 199 ++++++++++++++ backend/tests/Feature/ScrapTest.php | 69 +++++ .../Web/Admin/ScrapReasonControllerTest.php | 130 +++++++++ .../Web/Admin/ScrapReportControllerTest.php | 55 ++++ .../Web/Operator/ScrapReportingTest.php | 83 ++++++ 37 files changed, 2432 insertions(+), 9 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/V1/ScrapEntryController.php create mode 100644 backend/app/Http/Controllers/Api/V1/ScrapReasonController.php create mode 100644 backend/app/Http/Controllers/Web/Admin/ScrapReasonController.php create mode 100644 backend/app/Http/Controllers/Web/Admin/ScrapReportController.php create mode 100644 backend/app/Http/Controllers/Web/Operator/ScrapController.php create mode 100644 backend/app/Models/ScrapEntry.php create mode 100644 backend/app/Models/ScrapReason.php create mode 100644 backend/app/Policies/ScrapEntryPolicy.php create mode 100644 backend/app/Policies/ScrapReasonPolicy.php create mode 100644 backend/app/Services/Scrap/ScrapReportService.php create mode 100644 backend/database/factories/ScrapEntryFactory.php create mode 100644 backend/database/factories/ScrapReasonFactory.php create mode 100644 backend/database/migrations/2026_06_01_120000_create_scrap_reasons_table.php create mode 100644 backend/database/migrations/2026_06_01_120001_create_scrap_entries_table.php create mode 100644 backend/database/seeders/ScrapReasonsSeeder.php create mode 100644 backend/resources/views/admin/scrap-reasons/create.blade.php create mode 100644 backend/resources/views/admin/scrap-reasons/edit.blade.php create mode 100644 backend/resources/views/admin/scrap-reasons/index.blade.php create mode 100644 backend/resources/views/admin/scrap-reports/index.blade.php create mode 100644 backend/tests/Feature/Api/ScrapApiTest.php create mode 100644 backend/tests/Feature/ScrapTest.php create mode 100644 backend/tests/Feature/Web/Admin/ScrapReasonControllerTest.php create mode 100644 backend/tests/Feature/Web/Admin/ScrapReportControllerTest.php create mode 100644 backend/tests/Feature/Web/Operator/ScrapReportingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 208fc3de..9831ae4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). --- +## [Unreleased] + +### Added +- 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) +- 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) +- 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) + ## [0.13.0] - 2026-05-31 ### Added diff --git a/backend/app/Http/Controllers/Api/V1/ReportController.php b/backend/app/Http/Controllers/Api/V1/ReportController.php index a4c609bc..99aadb58 100644 --- a/backend/app/Http/Controllers/Api/V1/ReportController.php +++ b/backend/app/Http/Controllers/Api/V1/ReportController.php @@ -7,7 +7,9 @@ use App\Models\Batch; use App\Models\Issue; use App\Models\Line; +use App\Services\Scrap\ScrapReportService; use App\Support\Csv; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Carbon\Carbon; @@ -205,6 +207,62 @@ public function downtimeReport(Request $request) return response()->json(['data' => $report]); } + /** + * Pareto data by scrap reason (descending scrap quantity, cumulative share). + */ + public function scrapPareto(Request $request, ScrapReportService $service): JsonResponse + { + [$from, $to, $lineId] = $this->scrapReportRange($request); + + return response()->json(['data' => [ + 'period' => ['start' => $from->toDateString(), 'end' => $to->toDateString()], + 'line_id' => $lineId, + 'pareto' => $service->pareto($from, $to, $lineId), + 'by_category' => $service->byCategory($from, $to, $lineId), + 'generated_at' => now()->toIso8601String(), + ]]); + } + + /** + * Scrap rate per line over time (scrap qty / total produced) plus daily trend. + */ + public function scrapRate(Request $request, ScrapReportService $service): JsonResponse + { + [$from, $to, $lineId] = $this->scrapReportRange($request); + + return response()->json(['data' => [ + 'period' => ['start' => $from->toDateString(), 'end' => $to->toDateString()], + 'per_line' => $service->ratePerLine($from, $to), + 'trend' => $service->trend($from, $to, $lineId), + 'generated_at' => now()->toIso8601String(), + ]]); + } + + /** + * Resolve and validate the [from, to, lineId] window for scrap reports. + * Defaults to the last 30 days when no dates are supplied. + * + * @return array{0: Carbon, 1: Carbon, 2: int|null} + */ + private function scrapReportRange(Request $request): array + { + $validated = $request->validate([ + 'line_id' => ['nullable', 'integer', 'exists:lines,id'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + ]); + + $from = isset($validated['start_date']) + ? Carbon::parse($validated['start_date'])->startOfDay() + : today()->subDays(29)->startOfDay(); + $to = isset($validated['end_date']) + ? Carbon::parse($validated['end_date'])->endOfDay() + : today()->endOfDay(); + $lineId = isset($validated['line_id']) ? (int) $validated['line_id'] : null; + + return [$from, $to, $lineId]; + } + /** * Export report as CSV */ diff --git a/backend/app/Http/Controllers/Api/V1/ScrapEntryController.php b/backend/app/Http/Controllers/Api/V1/ScrapEntryController.php new file mode 100644 index 00000000..075121b2 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/ScrapEntryController.php @@ -0,0 +1,143 @@ +authorize('viewAny', ScrapEntry::class); + + // Tenant isolation: only entries whose work order is visible under the + // WorkOrder tenant scope (ScrapEntry itself has no tenant column). + // No-op for single-tenant installs (tenant scope is inactive there). + $query = ScrapEntry::query()->whereHas('workOrder')->with(['scrapReason', 'reportedBy', 'workOrder']); + if ($woId = $request->query('work_order_id')) { + $query->where('work_order_id', $woId); + } + if ($lineId = $request->query('line_id')) { + $query->whereHas('workOrder', fn ($q) => $q->where('line_id', $lineId)); + } + if ($reasonId = $request->query('scrap_reason_id')) { + $query->where('scrap_reason_id', $reasonId); + } + if ($from = $request->query('from')) { + $query->where('reported_at', '>=', $from); + } + if ($to = $request->query('to')) { + $query->where('reported_at', '<=', $to); + } + + $perPage = max(1, min((int) $request->query('per_page', 30), 100)); + $page = $query->orderByDesc('reported_at')->paginate($perPage); + + return response()->json([ + 'data' => $page->items(), + 'meta' => [ + 'current_page' => $page->currentPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + 'last_page' => $page->lastPage(), + ], + ]); + } + + /** + * List scrap entries for a single work order, with the work order totals. + */ + public function forWorkOrder(WorkOrder $workOrder): JsonResponse + { + $this->authorize('viewAny', ScrapEntry::class); + + $entries = $workOrder->scrapEntries() + ->with(['scrapReason', 'reportedBy', 'batchStep', 'shift']) + ->orderByDesc('reported_at') + ->get(); + + return response()->json([ + 'data' => $entries, + 'meta' => [ + 'work_order_id' => $workOrder->id, + 'total_scrap_qty' => $workOrder->totalScrapQty(), + 'quality_pct' => $workOrder->qualityPct(), + ], + ]); + } + + public function show(ScrapEntry $scrapEntry): JsonResponse + { + $this->authorize('view', $scrapEntry); + $this->assertTenantVisible($scrapEntry); + $scrapEntry->load(['scrapReason', 'reportedBy', 'workOrder', 'batchStep', 'shift']); + + return response()->json(['data' => $scrapEntry]); + } + + public function store(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->authorize('create', ScrapEntry::class); + $data = $request->validate([ + 'scrap_reason_id' => ['required', 'integer', Rule::exists('scrap_reasons', 'id')->where('is_active', true)], + 'quantity' => ['required', 'numeric', 'min:0.01', 'max:99999999'], + 'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'], + 'shift_id' => ['nullable', 'integer', 'exists:shifts,id'], + 'notes' => ['nullable', 'string'], + 'reported_at' => ['nullable', 'date'], + ]); + $data['work_order_id'] = $workOrder->id; + $data['reported_by'] = $request->user()->id; + $data['reported_at'] = $data['reported_at'] ?? now(); + + $entry = ScrapEntry::create($data); + + return response()->json([ + 'message' => 'Scrap recorded', + 'data' => $entry->load(['scrapReason', 'reportedBy']), + ], 201); + } + + public function update(Request $request, ScrapEntry $scrapEntry): JsonResponse + { + $this->authorize('update', $scrapEntry); + $this->assertTenantVisible($scrapEntry); + $data = $request->validate([ + 'scrap_reason_id' => ['sometimes', 'integer', Rule::exists('scrap_reasons', 'id')->where('is_active', true)], + 'quantity' => ['sometimes', 'numeric', 'min:0.01', 'max:99999999'], + 'batch_step_id' => ['sometimes', 'nullable', 'integer', 'exists:batch_steps,id'], + 'shift_id' => ['sometimes', 'nullable', 'integer', 'exists:shifts,id'], + 'notes' => ['sometimes', 'nullable', 'string'], + ]); + $scrapEntry->update($data); + + return response()->json(['message' => 'Scrap entry updated', 'data' => $scrapEntry->fresh(['scrapReason'])]); + } + + public function destroy(ScrapEntry $scrapEntry): JsonResponse + { + $this->authorize('delete', $scrapEntry); + $this->assertTenantVisible($scrapEntry); + $scrapEntry->delete(); + + return response()->json(['message' => 'Scrap entry deleted']); + } + + /** + * Guard against cross-tenant access to a directly-bound scrap entry. + * + * ScrapEntry has no tenant column; isolation rides on its work order, + * which carries the tenant scope. If the entry's work order is not + * visible to the current tenant, treat the entry as not found. + * No-op for single-tenant installs (the tenant scope is inactive). + */ + private function assertTenantVisible(ScrapEntry $scrapEntry): void + { + abort_unless($scrapEntry->workOrder()->exists(), 404); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/ScrapReasonController.php b/backend/app/Http/Controllers/Api/V1/ScrapReasonController.php new file mode 100644 index 00000000..5ce00438 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/ScrapReasonController.php @@ -0,0 +1,80 @@ +authorize('viewAny', ScrapReason::class); + + $query = ScrapReason::query(); + if (! $request->boolean('include_inactive')) { + $query->where('is_active', true); + } + if ($cat = $request->query('category')) { + $query->where('category', $cat); + } + + return response()->json(['data' => $query->ordered()->get()]); + } + + public function show(ScrapReason $scrapReason): JsonResponse + { + $this->authorize('view', $scrapReason); + + return response()->json(['data' => $scrapReason]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize('create', ScrapReason::class); + $data = $request->validate([ + 'code' => ['required', 'string', 'max:20', 'unique:scrap_reasons,code'], + 'name' => ['required', 'string', 'max:255'], + 'category' => ['required', Rule::in(ScrapReason::CATEGORIES)], + 'description' => ['nullable', 'string'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:65535'], + 'is_active' => ['nullable', 'boolean'], + ]); + $data['is_active'] = $data['is_active'] ?? true; + $data['sort_order'] = $data['sort_order'] ?? 0; + + $reason = ScrapReason::create($data); + + return response()->json(['message' => 'Scrap reason created', 'data' => $reason], 201); + } + + public function update(Request $request, ScrapReason $scrapReason): JsonResponse + { + $this->authorize('update', $scrapReason); + $data = $request->validate([ + 'code' => ['sometimes', 'required', 'string', 'max:20', Rule::unique('scrap_reasons', 'code')->ignore($scrapReason->id)], + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'category' => ['sometimes', 'required', Rule::in(ScrapReason::CATEGORIES)], + 'description' => ['sometimes', 'nullable', 'string'], + 'sort_order' => ['sometimes', 'integer', 'min:0', 'max:65535'], + 'is_active' => ['sometimes', 'boolean'], + ]); + $scrapReason->update($data); + + return response()->json(['message' => 'Scrap reason updated', 'data' => $scrapReason->fresh()]); + } + + public function destroy(ScrapReason $scrapReason): JsonResponse + { + $this->authorize('delete', $scrapReason); + if ($scrapReason->scrapEntries()->exists()) { + return response()->json(['message' => 'Cannot delete reason referenced by scrap entries.'], 422); + } + $scrapReason->delete(); + + return response()->json(['message' => 'Scrap reason deleted']); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/ScrapReasonController.php b/backend/app/Http/Controllers/Web/Admin/ScrapReasonController.php new file mode 100644 index 00000000..318c3458 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ScrapReasonController.php @@ -0,0 +1,138 @@ +orderBy('is_active', 'desc') + ->orderBy('sort_order') + ->orderBy('name'); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%"); + }); + } + + if ($category = $request->input('category')) { + $query->where('category', $category); + } + + $scrapReasons = $query->paginate(25)->withQueryString(); + + return view('admin.scrap-reasons.index', [ + 'scrapReasons' => $scrapReasons, + 'categories' => ScrapReason::CATEGORIES, + 'category' => $category, + 'search' => $search, + ]); + } + + /** + * Show the form for creating a new scrap reason. + */ + public function create() + { + return view('admin.scrap-reasons.create', [ + 'categories' => ScrapReason::CATEGORIES, + ]); + } + + /** + * Store a newly created scrap reason. + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'code' => 'required|string|max:20|unique:scrap_reasons,code', + 'name' => 'required|string|max:255', + 'category' => ['required', Rule::in(ScrapReason::CATEGORIES)], + 'description' => 'nullable|string|max:2000', + 'sort_order' => 'nullable|integer|min:0|max:65535', + 'is_active' => 'boolean', + ]); + + $validated['is_active'] = $request->boolean('is_active', true); + $validated['sort_order'] = $validated['sort_order'] ?? 0; + + ScrapReason::create($validated); + + return redirect()->route('admin.scrap-reasons.index') + ->with('success', __('Scrap reason created successfully.')); + } + + /** + * Show the form for editing a scrap reason. + */ + public function edit(ScrapReason $scrapReason) + { + return view('admin.scrap-reasons.edit', [ + 'scrapReason' => $scrapReason, + 'categories' => ScrapReason::CATEGORIES, + ]); + } + + /** + * Update the specified scrap reason. + */ + public function update(Request $request, ScrapReason $scrapReason) + { + $validated = $request->validate([ + 'code' => ['required', 'string', 'max:20', Rule::unique('scrap_reasons', 'code')->ignore($scrapReason->id)], + 'name' => 'required|string|max:255', + 'category' => ['required', Rule::in(ScrapReason::CATEGORIES)], + 'description' => 'nullable|string|max:2000', + 'sort_order' => 'nullable|integer|min:0|max:65535', + 'is_active' => 'boolean', + ]); + + $validated['is_active'] = $request->boolean('is_active'); + $validated['sort_order'] = $validated['sort_order'] ?? 0; + + $scrapReason->update($validated); + + return redirect()->route('admin.scrap-reasons.index') + ->with('success', __('Scrap reason updated successfully.')); + } + + /** + * Remove the specified scrap reason. + */ + public function destroy(ScrapReason $scrapReason) + { + if ($scrapReason->scrapEntries()->exists()) { + return redirect()->route('admin.scrap-reasons.index') + ->with('error', __('Cannot delete scrap reason with existing entries. Deactivate it instead.')); + } + + $scrapReason->delete(); + + return redirect()->route('admin.scrap-reasons.index') + ->with('success', __('Scrap reason deleted successfully.')); + } + + /** + * Toggle scrap reason active status. + */ + public function toggleActive(ScrapReason $scrapReason) + { + $scrapReason->update(['is_active' => ! $scrapReason->is_active]); + + $status = $scrapReason->is_active ? __('activated') : __('deactivated'); + + return redirect()->route('admin.scrap-reasons.index') + ->with('success', __('Scrap reason :status successfully.', ['status' => $status])); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/ScrapReportController.php b/backend/app/Http/Controllers/Web/Admin/ScrapReportController.php new file mode 100644 index 00000000..a573f59e --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ScrapReportController.php @@ -0,0 +1,49 @@ +validate([ + 'line_id' => ['nullable', 'integer', 'exists:lines,id'], + 'date_from' => ['nullable', 'date_format:Y-m-d'], + 'date_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'], + ]); + + $lineId = isset($validated['line_id']) ? (int) $validated['line_id'] : null; + $from = isset($validated['date_from']) + ? Carbon::parse($validated['date_from'])->startOfDay() + : today()->subDays(29)->startOfDay(); + $to = isset($validated['date_to']) + ? Carbon::parse($validated['date_to'])->endOfDay() + : today()->endOfDay(); + + $pareto = $this->scrapReports->pareto($from, $to, $lineId); + $byCategory = $this->scrapReports->byCategory($from, $to, $lineId); + $ratePerLine = $this->scrapReports->ratePerLine($from, $to); + $trend = $this->scrapReports->trend($from, $to, $lineId); + + return view('admin.scrap-reports.index', [ + 'lines' => Line::orderBy('name')->get(), + 'lineId' => $lineId, + 'dateFrom' => $from->toDateString(), + 'dateTo' => $to->toDateString(), + 'pareto' => $pareto, + 'byCategory' => $byCategory, + 'ratePerLine' => $ratePerLine, + 'trend' => $trend, + ]); + } +} diff --git a/backend/app/Http/Controllers/Web/Operator/ScrapController.php b/backend/app/Http/Controllers/Web/Operator/ScrapController.php new file mode 100644 index 00000000..6695a2c3 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Operator/ScrapController.php @@ -0,0 +1,46 @@ +validate([ + 'work_order_id' => 'required|exists:work_orders,id', + 'scrap_reason_id' => ['required', Rule::exists('scrap_reasons', 'id')->where('is_active', true)], + 'quantity' => 'required|numeric|min:0.01|max:99999999', + 'notes' => 'nullable|string|max:2000', + ]); + + $workOrder = WorkOrder::findOrFail($validated['work_order_id']); + + // Verify work order belongs to selected line + if ($workOrder->line_id != $request->session()->get('selected_line_id')) { + return back()->with('error', __('This work order does not belong to the selected line.')); + } + + ScrapEntry::create([ + 'work_order_id' => $workOrder->id, + 'scrap_reason_id' => $validated['scrap_reason_id'], + 'quantity' => $validated['quantity'], + // Attribute to the line's current shift automatically when one is running. + 'shift_id' => Shift::current($workOrder->line_id)?->id, + 'notes' => $validated['notes'] ?? null, + 'reported_by' => auth()->id(), + 'reported_at' => now(), + ]); + + return redirect()->back()->with('success', __('Scrap recorded successfully.')); + } +} diff --git a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php index d37094b0..66c5cae8 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Models\IssueType; use App\Models\LineStatus; +use App\Models\ScrapReason; use App\Models\WorkOrder; use App\Models\Workstation; use App\Services\WorkOrder\WorkOrderService; @@ -223,10 +224,14 @@ public function show(Request $request, WorkOrder $workOrder) 'batches.packagingChecklist', 'issues.issueType', 'issues.reportedBy', + 'scrapEntries.scrapReason', + 'scrapEntries.reportedBy', ]); $issueTypes = IssueType::where('is_active', true)->orderBy('name')->get(); + $scrapReasons = ScrapReason::active()->ordered()->get(); + // Only show workstations from this line (not all system workstations) $workstations = $workOrder->line ? Workstation::where('line_id', $workOrder->line_id)->where('is_active', true)->orderBy('name')->get() @@ -235,6 +240,6 @@ public function show(Request $request, WorkOrder $workOrder) // Auto-select workstation if operator is a workstation account $defaultWorkstationId = auth()->user()->workstation_id; - return view('operator.work-order-detail', compact('workOrder', 'issueTypes', 'workstations', 'defaultWorkstationId')); + return view('operator.work-order-detail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId')); } } diff --git a/backend/app/Models/ScrapEntry.php b/backend/app/Models/ScrapEntry.php new file mode 100644 index 00000000..758bf67e --- /dev/null +++ b/backend/app/Models/ScrapEntry.php @@ -0,0 +1,72 @@ + 'decimal:2', + 'reported_at' => 'datetime', + ]; + } + + /** + * The work order this scrap was reported against. + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * The reason code classifying this scrap. + */ + public function scrapReason(): BelongsTo + { + return $this->belongsTo(ScrapReason::class); + } + + /** + * The batch step the scrap was attributed to (optional). + */ + public function batchStep(): BelongsTo + { + return $this->belongsTo(BatchStep::class); + } + + /** + * The shift the scrap was reported on (optional). + */ + public function shift(): BelongsTo + { + return $this->belongsTo(Shift::class); + } + + /** + * The user who reported the scrap. + */ + public function reportedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reported_by'); + } +} diff --git a/backend/app/Models/ScrapReason.php b/backend/app/Models/ScrapReason.php new file mode 100644 index 00000000..5fd1b83b --- /dev/null +++ b/backend/app/Models/ScrapReason.php @@ -0,0 +1,72 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + } + + /** + * Get the scrap entries recorded against this reason. + */ + public function scrapEntries(): HasMany + { + return $this->hasMany(ScrapEntry::class); + } + + /** + * Scope to get only active scrap reasons. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope ordering reasons the way operators expect to see them. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('name'); + } +} diff --git a/backend/app/Models/WorkOrder.php b/backend/app/Models/WorkOrder.php index 337083ce..6e0c136a 100644 --- a/backend/app/Models/WorkOrder.php +++ b/backend/app/Models/WorkOrder.php @@ -145,6 +145,14 @@ public function eans(): HasMany return $this->hasMany(WorkOrderEan::class); } + /** + * Get the scrap entries reported against this work order. + */ + public function scrapEntries(): HasMany + { + return $this->hasMany(ScrapEntry::class); + } + /** * Get the open blocking issues for this work order. */ @@ -180,6 +188,39 @@ public function isComplete(): bool return (float) $this->produced_qty >= (float) $this->planned_qty; } + /** + * Total scrap quantity reported against this work order. + * + * Reads from the loaded relation when available (avoids an extra query + * after eager loading) and otherwise aggregates in the database. + */ + public function totalScrapQty(): float + { + if ($this->relationLoaded('scrapEntries')) { + return (float) $this->scrapEntries->sum('quantity'); + } + + return (float) $this->scrapEntries()->sum('quantity'); + } + + /** + * Quality metric for this work order: the share of produced units that + * were not scrapped, as a percentage. Scrap entries automatically pull + * this down. Returns null when nothing has been produced yet. + */ + public function qualityPct(): ?float + { + $produced = (float) $this->produced_qty; + + if ($produced <= 0) { + return null; + } + + $good = max(0.0, $produced - $this->totalScrapQty()); + + return round(($good / $produced) * 100, 2); + } + /** * Scope to filter by status. */ diff --git a/backend/app/Policies/ScrapEntryPolicy.php b/backend/app/Policies/ScrapEntryPolicy.php new file mode 100644 index 00000000..ac63314c --- /dev/null +++ b/backend/app/Policies/ScrapEntryPolicy.php @@ -0,0 +1,20 @@ +hasAnyRole(['Admin', 'Supervisor', 'Operator']); } + public function update(User $u, ScrapEntry $e): bool + { + if ($u->hasAnyRole(['Admin', 'Supervisor'])) return true; + // Operator can edit their own entry + return $e->reported_by === $u->id; + } + public function delete(User $u, ScrapEntry $e): bool { return $u->hasAnyRole(['Admin', 'Supervisor']); } +} diff --git a/backend/app/Policies/ScrapReasonPolicy.php b/backend/app/Policies/ScrapReasonPolicy.php new file mode 100644 index 00000000..c8a83aa1 --- /dev/null +++ b/backend/app/Policies/ScrapReasonPolicy.php @@ -0,0 +1,15 @@ +hasRole('Admin'); } + public function update(User $u, ScrapReason $r): bool { return $u->hasRole('Admin'); } + public function delete(User $u, ScrapReason $r): bool { return $u->hasRole('Admin'); } +} diff --git a/backend/app/Services/Scrap/ScrapReportService.php b/backend/app/Services/Scrap/ScrapReportService.php new file mode 100644 index 00000000..ba67f37f --- /dev/null +++ b/backend/app/Services/Scrap/ScrapReportService.php @@ -0,0 +1,158 @@ +with(['scrapReason', 'workOrder:id,line_id']) + ->whereBetween('reported_at', [$from, $to]) + ->when($lineId, fn ($q) => $q->whereHas('workOrder', fn ($w) => $w->where('line_id', $lineId))) + ->get(); + } + + /** + * Pareto of scrap quantity by reason, sorted descending, with each reason's + * share and the running cumulative share. + */ + public function pareto(Carbon $from, Carbon $to, ?int $lineId = null): array + { + $entries = $this->entries($from, $to, $lineId); + + $reasons = $entries + ->groupBy('scrap_reason_id') + ->map(function (Collection $rows) { + $reason = $rows->first()->scrapReason; + + return [ + 'scrap_reason_id' => $reason?->id, + 'code' => $reason?->code, + 'name' => $reason?->name ?? __('Unknown'), + 'category' => $reason?->category, + 'qty' => round((float) $rows->sum('quantity'), 2), + 'entries' => $rows->count(), + ]; + }) + ->sortByDesc('qty') + ->values(); + + $total = round((float) $reasons->sum('qty'), 2); + $running = 0.0; + + $reasons = $reasons->map(function (array $row) use ($total, &$running) { + $running += $row['qty']; + $row['pct'] = $total > 0 ? round(($row['qty'] / $total) * 100, 2) : 0.0; + $row['cumulative_pct'] = $total > 0 ? round(($running / $total) * 100, 2) : 0.0; + + return $row; + })->values(); + + return [ + 'total_qty' => $total, + 'total_entries' => $entries->count(), + 'reasons' => $reasons->all(), + ]; + } + + /** + * Scrap quantity grouped by 5M category (material/machine/method/man/environment). + */ + public function byCategory(Carbon $from, Carbon $to, ?int $lineId = null): array + { + return $this->entries($from, $to, $lineId) + ->groupBy(fn (ScrapEntry $e) => $e->scrapReason?->category ?? 'unknown') + ->map(fn (Collection $rows, $category) => [ + 'category' => $category, + 'qty' => round((float) $rows->sum('quantity'), 2), + 'entries' => $rows->count(), + ]) + ->sortByDesc('qty') + ->values() + ->all(); + } + + /** + * Scrap rate per line over the period: total scrap quantity divided by the + * quantity produced (completed batches) on that line. + */ + public function ratePerLine(Carbon $from, Carbon $to): array + { + $scrapByLine = ScrapEntry::query() + ->join('work_orders', 'scrap_entries.work_order_id', '=', 'work_orders.id') + ->whereBetween('scrap_entries.reported_at', [$from, $to]) + ->whereNotNull('work_orders.line_id') + ->groupBy('work_orders.line_id') + ->selectRaw('work_orders.line_id as line_id, COALESCE(SUM(scrap_entries.quantity), 0) as scrap_qty') + ->pluck('scrap_qty', 'line_id'); + + $producedByLine = Batch::query() + ->join('work_orders', 'batches.work_order_id', '=', 'work_orders.id') + ->where('batches.status', Batch::STATUS_DONE) + ->whereBetween('batches.completed_at', [$from, $to]) + ->whereNotNull('work_orders.line_id') + ->groupBy('work_orders.line_id') + ->selectRaw('work_orders.line_id as line_id, COALESCE(SUM(batches.produced_qty), 0) as produced_qty') + ->pluck('produced_qty', 'line_id'); + + $lineIds = $scrapByLine->keys()->merge($producedByLine->keys())->unique()->values(); + + if ($lineIds->isEmpty()) { + return []; + } + + $lineNames = Line::whereIn('id', $lineIds)->pluck('name', 'id'); + + return $lineIds + ->map(function ($lineId) use ($scrapByLine, $producedByLine, $lineNames) { + $scrap = round((float) $scrapByLine->get($lineId, 0), 2); + $produced = round((float) $producedByLine->get($lineId, 0), 2); + + return [ + 'line_id' => (int) $lineId, + 'line_name' => $lineNames->get($lineId, '#' . $lineId), + 'scrap_qty' => $scrap, + 'produced_qty' => $produced, + 'scrap_rate_pct' => $produced > 0 ? round(($scrap / $produced) * 100, 2) : null, + ]; + }) + ->sortByDesc('scrap_qty') + ->values() + ->all(); + } + + /** + * Daily scrap-quantity trend across the period. + */ + public function trend(Carbon $from, Carbon $to, ?int $lineId = null): array + { + return $this->entries($from, $to, $lineId) + ->groupBy(fn (ScrapEntry $e) => $e->reported_at->toDateString()) + ->map(fn (Collection $rows, $date) => [ + 'date' => $date, + 'qty' => round((float) $rows->sum('quantity'), 2), + 'entries' => $rows->count(), + ]) + ->sortKeys() + ->values() + ->all(); + } +} diff --git a/backend/database/factories/ScrapEntryFactory.php b/backend/database/factories/ScrapEntryFactory.php new file mode 100644 index 00000000..546a1300 --- /dev/null +++ b/backend/database/factories/ScrapEntryFactory.php @@ -0,0 +1,31 @@ + + */ +class ScrapEntryFactory extends Factory +{ + protected $model = ScrapEntry::class; + + public function definition(): array + { + return [ + 'work_order_id' => WorkOrder::factory(), + 'scrap_reason_id' => ScrapReason::factory(), + 'quantity' => fake()->randomFloat(2, 1, 100), + 'batch_step_id' => null, + 'shift_id' => null, + 'notes' => fake()->optional()->sentence(), + 'reported_by' => User::factory(), + 'reported_at' => now(), + ]; + } +} diff --git a/backend/database/factories/ScrapReasonFactory.php b/backend/database/factories/ScrapReasonFactory.php new file mode 100644 index 00000000..47e810ff --- /dev/null +++ b/backend/database/factories/ScrapReasonFactory.php @@ -0,0 +1,38 @@ + + */ +class ScrapReasonFactory extends Factory +{ + protected $model = ScrapReason::class; + + public function definition(): array + { + static $counter = 1; + + return [ + 'code' => 'SCR-' . str_pad((string) $counter++, 3, '0', STR_PAD_LEFT), + 'name' => fake()->words(3, true), + 'description' => fake()->optional()->sentence(), + 'category' => fake()->randomElement(ScrapReason::CATEGORIES), + 'is_active' => true, + 'sort_order' => 0, + ]; + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } + + public function category(string $category): static + { + return $this->state(['category' => $category]); + } +} diff --git a/backend/database/migrations/2026_06_01_120000_create_scrap_reasons_table.php b/backend/database/migrations/2026_06_01_120000_create_scrap_reasons_table.php new file mode 100644 index 00000000..b1235f70 --- /dev/null +++ b/backend/database/migrations/2026_06_01_120000_create_scrap_reasons_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('code', 20)->unique(); + $table->string('name'); + $table->text('description')->nullable(); + // 5M defect taxonomy (Ishikawa): material, machine, method, man, environment + $table->enum('category', ['material', 'machine', 'method', 'man', 'environment']); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['is_active', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('scrap_reasons'); + } +}; diff --git a/backend/database/migrations/2026_06_01_120001_create_scrap_entries_table.php b/backend/database/migrations/2026_06_01_120001_create_scrap_entries_table.php new file mode 100644 index 00000000..ac06254e --- /dev/null +++ b/backend/database/migrations/2026_06_01_120001_create_scrap_entries_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('scrap_reason_id')->constrained()->restrictOnDelete(); + $table->decimal('quantity', 12, 2); + $table->foreignId('batch_step_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('shift_id')->nullable()->constrained()->nullOnDelete(); + $table->text('notes')->nullable(); + $table->foreignId('reported_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reported_at'); + $table->timestamps(); + + $table->index('work_order_id'); + $table->index('scrap_reason_id'); + $table->index('reported_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('scrap_entries'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 626407dc..527cf28f 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ public function run(): void ViewTemplateSeeder::class, MaterialTypesSeeder::class, DowntimeReasonsSeeder::class, + ScrapReasonsSeeder::class, LabelTemplatesSeeder::class, ]); } diff --git a/backend/database/seeders/ScrapReasonsSeeder.php b/backend/database/seeders/ScrapReasonsSeeder.php new file mode 100644 index 00000000..fbbe83e7 --- /dev/null +++ b/backend/database/seeders/ScrapReasonsSeeder.php @@ -0,0 +1,32 @@ + 'DIM-OOS', 'name' => 'Dimension out of spec', 'category' => ScrapReason::CATEGORY_METHOD], + ['code' => 'SURF-DEF', 'name' => 'Surface defect', 'category' => ScrapReason::CATEGORY_MATERIAL], + ['code' => 'WRONG-MAT', 'name' => 'Wrong material', 'category' => ScrapReason::CATEGORY_MATERIAL], + ['code' => 'MACH-FAIL', 'name' => 'Machine malfunction', 'category' => ScrapReason::CATEGORY_MACHINE], + ['code' => 'OP-ERR', 'name' => 'Operator error', 'category' => ScrapReason::CATEGORY_MAN], + ]; + + foreach ($reasons as $index => $reason) { + ScrapReason::firstOrCreate( + ['code' => $reason['code']], + [ + 'name' => $reason['name'], + 'category' => $reason['category'], + 'is_active' => true, + 'sort_order' => $index, + ] + ); + } + } +} diff --git a/backend/lang/en.json b/backend/lang/en.json index 1bdb7123..3a17c69c 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -2150,5 +2150,49 @@ "— Select site —": "— Select site —", "— Select —": "— Select —", "— no plan (ad-hoc) —": "— no plan (ad-hoc) —", - "← Back to Site": "← Back to Site" + "← Back to Site": "← Back to Site", + "Scrap Reasons": "Scrap Reasons", + "Code or name": "Code or name", + "All categories": "All categories", + "Entries": "Entries", + "No scrap reasons yet": "No scrap reasons yet", + "New Scrap Reason": "New Scrap Reason", + "Define a reason operators can pick when reporting scrap": "Define a reason operators can pick when reporting scrap", + "— Select category —": "— Select category —", + "5M defect taxonomy (Ishikawa)": "5M defect taxonomy (Ishikawa)", + "Sort order": "Sort order", + "Edit Scrap Reason": "Edit Scrap Reason", + "Scrap Reports": "Scrap Reports", + "Total scrap quantity": "Total scrap quantity", + "Scrap entries": "Scrap entries", + "Distinct reasons": "Distinct reasons", + "Top reason": "Top reason", + "of total": "of total", + "Scrap Pareto by reason": "Scrap Pareto by reason", + "No scrap reported in this period.": "No scrap reported in this period.", + "Cumulative %": "Cumulative %", + "Scrap by category": "Scrap by category", + "No data.": "No data.", + "Scrap rate per line": "Scrap rate per line", + "Scrap rate": "Scrap rate", + "Scrap trend over time": "Scrap trend over time", + "Report": "Report", + "Total scrap": "Total scrap", + "No scrap reported.": "No scrap reported.", + "more": "more", + "Report Scrap": "Report Scrap", + "— Select reason —": "— Select reason —", + "Additional details…": "Additional details…", + "Scrap reason created successfully.": "Scrap reason created successfully.", + "Scrap reason updated successfully.": "Scrap reason updated successfully.", + "Cannot delete scrap reason with existing entries. Deactivate it instead.": "Cannot delete scrap reason with existing entries. Deactivate it instead.", + "Scrap reason deleted successfully.": "Scrap reason deleted successfully.", + "activated": "activated", + "deactivated": "deactivated", + "Scrap reason :status successfully.": "Scrap reason :status successfully.", + "This work order does not belong to the selected line.": "This work order does not belong to the selected line.", + "Scrap recorded successfully.": "Scrap recorded successfully.", + "Machine": "Machine", + "Man": "Man", + "Environment": "Environment" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index ed71dd04..7cfd8566 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -2150,5 +2150,49 @@ "— Select site —": "— Wybierz zakład —", "— Select —": "— Wybierz —", "— no plan (ad-hoc) —": "— bez planu (ad-hoc) —", - "← Back to Site": "← Powrót do zakładu" + "← Back to Site": "← Powrót do zakładu", + "Scrap Reasons": "Przyczyny odpadów", + "Code or name": "Kod lub nazwa", + "All categories": "Wszystkie kategorie", + "Entries": "Wpisy", + "No scrap reasons yet": "Brak przyczyn odpadów", + "New Scrap Reason": "Nowa przyczyna odpadów", + "Define a reason operators can pick when reporting scrap": "Zdefiniuj przyczynę, którą operatorzy wybiorą przy zgłaszaniu odpadów", + "— Select category —": "— Wybierz kategorię —", + "5M defect taxonomy (Ishikawa)": "Taksonomia wad 5M (Ishikawa)", + "Sort order": "Kolejność", + "Edit Scrap Reason": "Edytuj przyczynę odpadów", + "Scrap Reports": "Raporty odpadów", + "Total scrap quantity": "Łączna ilość odpadów", + "Scrap entries": "Wpisy odpadów", + "Distinct reasons": "Różne przyczyny", + "Top reason": "Główna przyczyna", + "of total": "całości", + "Scrap Pareto by reason": "Pareto odpadów wg przyczyny", + "No scrap reported in this period.": "Brak zgłoszonych odpadów w tym okresie.", + "Cumulative %": "Skumulowany %", + "Scrap by category": "Odpady wg kategorii", + "No data.": "Brak danych.", + "Scrap rate per line": "Wskaźnik odpadów na linię", + "Scrap rate": "Wskaźnik odpadów", + "Scrap trend over time": "Trend odpadów w czasie", + "Report": "Zgłoś", + "Total scrap": "Łączne odpady", + "No scrap reported.": "Brak zgłoszonych odpadów.", + "more": "więcej", + "Report Scrap": "Zgłoś odpad", + "— Select reason —": "— Wybierz przyczynę —", + "Additional details…": "Dodatkowe szczegóły…", + "Scrap reason created successfully.": "Przyczyna odpadu utworzona.", + "Scrap reason updated successfully.": "Przyczyna odpadu zaktualizowana.", + "Cannot delete scrap reason with existing entries. Deactivate it instead.": "Nie można usunąć przyczyny odpadu z istniejącymi wpisami. Zamiast tego dezaktywuj ją.", + "Scrap reason deleted successfully.": "Przyczyna odpadu usunięta.", + "activated": "aktywowana", + "deactivated": "dezaktywowana", + "Scrap reason :status successfully.": "Przyczyna odpadu :status.", + "This work order does not belong to the selected line.": "To zlecenie nie należy do wybranej linii.", + "Scrap recorded successfully.": "Odpad zarejestrowany.", + "Machine": "Maszyna", + "Man": "Człowiek", + "Environment": "Środowisko" } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 16f36e59..a7011118 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1251,5 +1251,50 @@ "— Select factory —": "— Fabrika seçin —", "— Select type —": "— Tip seçin —", "— Unassigned —": "— Atanmamış —", - "← Back": "← Geri" + "← Back": "← Geri", + "Scrap Reasons": "Fire Nedenleri", + "Code or name": "Kod veya ad", + "All categories": "Tüm kategoriler", + "Entries": "Kayıtlar", + "No scrap reasons yet": "Henüz fire nedeni yok", + "New Scrap Reason": "Yeni Fire Nedeni", + "Define a reason operators can pick when reporting scrap": "Operatörlerin fire bildirirken seçebileceği bir neden tanımlayın", + "— Select category —": "— Kategori seçin —", + "5M defect taxonomy (Ishikawa)": "5M hata sınıflandırması (Ishikawa)", + "Sort order": "Sıralama", + "Edit Scrap Reason": "Fire Nedenini Düzenle", + "Scrap Reports": "Fire Raporları", + "Total scrap quantity": "Toplam fire miktarı", + "Scrap entries": "Fire kayıtları", + "Distinct reasons": "Farklı nedenler", + "Top reason": "En sık neden", + "of total": "toplamın", + "Scrap Pareto by reason": "Nedene göre fire Pareto", + "No scrap reported in this period.": "Bu dönemde fire bildirilmedi.", + "Cumulative %": "Kümülatif %", + "Scrap by category": "Kategoriye göre fire", + "No data.": "Veri yok.", + "Scrap rate per line": "Hat başına fire oranı", + "Scrap rate": "Fire oranı", + "Scrap trend over time": "Zamana göre fire eğilimi", + "Report": "Bildir", + "Total scrap": "Toplam fire", + "No scrap reported.": "Fire bildirilmedi.", + "more": "daha", + "Report Scrap": "Fire Bildir", + "— Select reason —": "— Neden seçin —", + "Additional details…": "Ek ayrıntılar…", + "Scrap reason created successfully.": "Fire nedeni başarıyla oluşturuldu.", + "Scrap reason updated successfully.": "Fire nedeni başarıyla güncellendi.", + "Cannot delete scrap reason with existing entries. Deactivate it instead.": "Kaydı olan fire nedeni silinemez. Bunun yerine pasifleştirin.", + "Scrap reason deleted successfully.": "Fire nedeni başarıyla silindi.", + "activated": "etkinleştirildi", + "deactivated": "pasifleştirildi", + "Scrap reason :status successfully.": "Fire nedeni başarıyla :status.", + "This work order does not belong to the selected line.": "Bu iş emri seçili hatta ait değil.", + "Scrap recorded successfully.": "Fire başarıyla kaydedildi.", + "Machine": "Makine", + "Man": "İnsan", + "Environment": "Çevre", + "Method": "Yöntem" } diff --git a/backend/resources/views/admin/scrap-reasons/create.blade.php b/backend/resources/views/admin/scrap-reasons/create.blade.php new file mode 100644 index 00000000..156dadbd --- /dev/null +++ b/backend/resources/views/admin/scrap-reasons/create.blade.php @@ -0,0 +1,85 @@ +@extends('layouts.app') + +@section('title', __('New Scrap Reason')) + +@php + $categoryLabel = fn ($c) => __(ucfirst($c)); +@endphp + +@section('content') + + +
+
+
+

{{ __('New Scrap Reason') }}

+

{{ __('Define a reason operators can pick when reporting scrap') }}

+
+ {{ __('← Back') }} +
+ +
+
+ @csrf + +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + +

{{ __('5M defect taxonomy (Ishikawa)') }}

+ @error('category')

{{ $message }}

@enderror +
+ +
+ + + @error('sort_order')

{{ $message }}

@enderror +
+ +
+ + + @error('description')

{{ $message }}

@enderror +
+ +
+ +
+
+ +
+ {{ __('Cancel') }} + +
+
+
+
+@endsection diff --git a/backend/resources/views/admin/scrap-reasons/edit.blade.php b/backend/resources/views/admin/scrap-reasons/edit.blade.php new file mode 100644 index 00000000..64095dc8 --- /dev/null +++ b/backend/resources/views/admin/scrap-reasons/edit.blade.php @@ -0,0 +1,84 @@ +@extends('layouts.app') + +@section('title', __('Edit Scrap Reason')) + +@php + $categoryLabel = fn ($c) => __(ucfirst($c)); +@endphp + +@section('content') + + +
+
+
+

{{ __('Edit Scrap Reason') }}

+

{{ $scrapReason->code }}

+
+ {{ __('← Back') }} +
+ +
+
+ @csrf + @method('PUT') + +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('category')

{{ $message }}

@enderror +
+ +
+ + + @error('sort_order')

{{ $message }}

@enderror +
+ +
+ + + @error('description')

{{ $message }}

@enderror +
+ +
+ +
+
+ +
+ {{ __('Cancel') }} + +
+
+
+
+@endsection diff --git a/backend/resources/views/admin/scrap-reasons/index.blade.php b/backend/resources/views/admin/scrap-reasons/index.blade.php new file mode 100644 index 00000000..dc625299 --- /dev/null +++ b/backend/resources/views/admin/scrap-reasons/index.blade.php @@ -0,0 +1,127 @@ +@extends('layouts.app') + +@section('title', __('Scrap Reasons')) + +@php + $categoryLabel = fn ($c) => $c ? __(ucfirst($c)) : '—'; +@endphp + +@section('content') + + +
+
+

{{ __('Scrap Reasons') }}

+ + + + + {{ __('Add Reason') }} + +
+ +
+
+
+ + +
+
+ + +
+ + @if($search || $category) + {{ __('Reset') }} + @endif +
+ +
+ + + + + + + + + + + + + @forelse($scrapReasons as $reason) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Code') }}{{ __('Name') }}{{ __('Category') }}{{ __('Entries') }}{{ __('Status') }}{{ __('Actions') }}
{{ $reason->code }}{{ $reason->name }}{{ $categoryLabel($reason->category) }}{{ $reason->scrap_entries_count }} + @if($reason->is_active) + {{ __('Active') }} + @else + {{ __('Inactive') }} + @endif + +
+ + + + + +
+ @csrf + +
+
+ @csrf + @method('DELETE') + +
+
+
+ + + +

{{ __('No scrap reasons yet') }}

+ {{ __('Add Reason') }} +
+
+ @if($scrapReasons->hasPages()) +
{{ $scrapReasons->links() }}
+ @endif +
+
+@endsection diff --git a/backend/resources/views/admin/scrap-reports/index.blade.php b/backend/resources/views/admin/scrap-reports/index.blade.php new file mode 100644 index 00000000..905ecc32 --- /dev/null +++ b/backend/resources/views/admin/scrap-reports/index.blade.php @@ -0,0 +1,252 @@ +@extends('layouts.app') + +@section('title', __('Scrap Reports')) + +@php + $categoryLabel = fn ($c) => $c ? __(ucfirst($c)) : __('Unknown'); + $reasons = $pareto['reasons'] ?? []; + $topReason = $reasons[0] ?? null; +@endphp + +@section('content') + + +
+

{{ __('Scrap Reports') }}

+ + {{-- Filters --}} +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {{-- KPI cards --}} +
+
+

{{ __('Total scrap quantity') }}

+

{{ number_format($pareto['total_qty'] ?? 0, 2) }}

+
+
+

{{ __('Scrap entries') }}

+

{{ number_format($pareto['total_entries'] ?? 0) }}

+
+
+

{{ __('Distinct reasons') }}

+

{{ count($reasons) }}

+
+
+

{{ __('Top reason') }}

+

{{ $topReason['name'] ?? '—' }}

+ @if($topReason) +

{{ number_format($topReason['pct'], 1) }}% {{ __('of total') }}

+ @endif +
+
+ + {{-- Pareto --}} +
+

{{ __('Scrap Pareto by reason') }}

+ @if(empty($reasons)) +

{{ __('No scrap reported in this period.') }}

+ @else + +
+ + + + + + + + + + + + + @foreach($reasons as $row) + + + + + + + + + @endforeach + +
{{ __('Code') }}{{ __('Reason') }}{{ __('Category') }}{{ __('Quantity') }}{{ __('% of Total') }}{{ __('Cumulative %') }}
{{ $row['code'] }}{{ $row['name'] }}{{ $categoryLabel($row['category']) }}{{ number_format($row['qty'], 2) }}{{ number_format($row['pct'], 1) }}%{{ number_format($row['cumulative_pct'], 1) }}%
+
+ @endif +
+ +
+ {{-- By category --}} +
+

{{ __('Scrap by category') }}

+ @if(empty($byCategory)) +

{{ __('No data.') }}

+ @else + + @endif +
+ + {{-- Scrap rate per line --}} +
+

{{ __('Scrap rate per line') }}

+ @if(empty($ratePerLine)) +

{{ __('No data.') }}

+ @else +
+ + + + + + + + + + + @foreach($ratePerLine as $row) + + + + + + + @endforeach + +
{{ __('Line') }}{{ __('Scrap') }}{{ __('Produced') }}{{ __('Scrap rate') }}
{{ $row['line_name'] }}{{ number_format($row['scrap_qty'], 2) }}{{ number_format($row['produced_qty'], 2) }} + {{ $row['scrap_rate_pct'] !== null ? number_format($row['scrap_rate_pct'], 2) . '%' : '—' }} +
+
+ @endif +
+
+ + {{-- Trend --}} +
+

{{ __('Scrap trend over time') }}

+ @if(empty($trend)) +

{{ __('No scrap reported in this period.') }}

+ @else + + @endif +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/backend/resources/views/layouts/app.blade.php b/backend/resources/views/layouts/app.blade.php index 0edad30b..587d6099 100644 --- a/backend/resources/views/layouts/app.blade.php +++ b/backend/resources/views/layouts/app.blade.php @@ -43,7 +43,7 @@ hr: {{ request()->routeIs('admin.workers.*', 'admin.crews.*', 'admin.skills.*', 'admin.wage-groups.*') ? 'true' : 'false' }}, maintenance: {{ request()->routeIs('admin.maintenance-events.*', 'admin.tools.*', 'admin.workstations.*') ? 'true' : 'false' }}, connectivity: {{ request()->routeIs('admin.connectivity.*') ? 'true' : 'false' }}, - adminGroup: {{ request()->routeIs('admin.users.*', 'admin.audit-logs*', 'admin.settings*') ? 'true' : 'false' }}, + adminGroup: {{ request()->routeIs('admin.users.*', 'admin.audit-logs*', 'admin.settings*', 'admin.scrap-reports.*') ? 'true' : 'false' }}, modulesGroup: {{ request()->routeIs('admin.modules.*') ? 'true' : 'false' }}, toggle() { const sb = this.$refs.sidebar; @@ -95,7 +95,7 @@ @if(request()->routeIs('admin.connectivity.*')) connectivity = true; @endif - @if(request()->routeIs('admin.users.*', 'admin.reports', 'admin.audit-logs', 'admin.modules.*')) + @if(request()->routeIs('admin.users.*', 'admin.reports', 'admin.scrap-reports.*', 'admin.audit-logs', 'admin.modules.*')) adminGroup = true; @endif @if(request()->routeIs('admin.modules.*')) diff --git a/backend/resources/views/layouts/components/sidebar.blade.php b/backend/resources/views/layouts/components/sidebar.blade.php index 711a19c9..64a28b77 100644 --- a/backend/resources/views/layouts/components/sidebar.blade.php +++ b/backend/resources/views/layouts/components/sidebar.blade.php @@ -426,6 +426,9 @@ class="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition- {{ __('Anomaly Reasons') }} + + {{ __('Scrap Reasons') }} + @foreach($menuRegistry->getItems('production') as $item) {{ $item['label'] }} @@ -706,6 +709,9 @@ class="mt-0.5 ml-4 space-y-0.5 border-l border-slate-700/60 pl-3"> {{ __('Reports') }} + + {{ __('Scrap Reports') }} + {{ __('Activity Logs') }} diff --git a/backend/resources/views/operator/work-order-detail.blade.php b/backend/resources/views/operator/work-order-detail.blade.php index 874898df..a8e1cf68 100644 --- a/backend/resources/views/operator/work-order-detail.blade.php +++ b/backend/resources/views/operator/work-order-detail.blade.php @@ -4,7 +4,7 @@ @section('content')
+ x-data="{ createBatchOpen: false, reportIssueOpen: false, reportScrapOpen: false }"> {{-- Header --}}
@@ -422,6 +422,59 @@ class="text-sm text-red-600 hover:text-red-800 font-medium border border-red-200 @endif
+ {{-- Scrap --}} + @php + $totalScrap = $workOrder->totalScrapQty(); + $qualityPct = $workOrder->qualityPct(); + @endphp +
+
+

{{ __('Scrap') }}

+ @if(!in_array($workOrder->status, ['DONE','CANCELLED']) && $scrapReasons->isNotEmpty()) + + @endif +
+ +
+ {{ __('Total scrap') }}: + {{ number_format($totalScrap, 2) }} +
+ @if($qualityPct !== null) +
+ {{ __('Quality') }}: + {{ number_format($qualityPct, 1) }}% +
+ @endif + + @if($workOrder->scrapEntries->isEmpty()) +

{{ __('No scrap reported.') }}

+ @else +
+ @foreach($workOrder->scrapEntries->take(5) as $entry) +
+
+ {{ $entry->scrapReason?->name ?? __('Unknown') }} + {{ number_format($entry->quantity, 2) }} +
+ @if($entry->notes) +

{{ Str::limit($entry->notes, 80) }}

+ @endif +

+ {{ $entry->reported_at->diffForHumans() }} + @if($entry->reportedBy) {{ __('by') }} {{ $entry->reportedBy->name }} @endif +

+
+ @endforeach + @if($workOrder->scrapEntries->count() > 5) +

+{{ $workOrder->scrapEntries->count() - 5 }} {{ __('more') }}

+ @endif +
+ @endif +
+
@@ -429,7 +482,7 @@ class="text-sm text-red-600 hover:text-red-800 font-medium border border-red-200
-
+
-
+
+ + {{-- Report Scrap Modal --}} +
+
+
+
+

{{ __('Report Scrap') }}

+
+ @csrf + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+
diff --git a/backend/routes/api.php b/backend/routes/api.php index 72a1260c..4c93a7f5 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -37,6 +37,8 @@ use App\Http\Controllers\Api\V1\ProductTypeController; use App\Http\Controllers\Api\V1\QualityCheckController; use App\Http\Controllers\Api\V1\ReportController; +use App\Http\Controllers\Api\V1\ScrapEntryController; +use App\Http\Controllers\Api\V1\ScrapReasonController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\SkillController; use App\Http\Controllers\Api\V1\SubassemblyController; @@ -136,6 +138,8 @@ Route::get('/cost-sources/{cost_source}', [CostSourceController::class, 'show']); Route::get('/anomaly-reasons', [AnomalyReasonController::class, 'index']); Route::get('/anomaly-reasons/{anomaly_reason}', [AnomalyReasonController::class, 'show']); + Route::get('/scrap-reasons', [ScrapReasonController::class, 'index']); + Route::get('/scrap-reasons/{scrapReason}', [ScrapReasonController::class, 'show']); Route::get('/subassemblies', [SubassemblyController::class, 'index']); Route::get('/subassemblies/{subassembly}', [SubassemblyController::class, 'show']); Route::get('/shifts', [ShiftController::class, 'index']); @@ -211,6 +215,14 @@ Route::delete('/production-anomalies/{productionAnomaly}', [ProductionAnomalyController::class, 'destroy']); Route::post('/production-anomalies/{productionAnomaly}/process', [ProductionAnomalyController::class, 'process']); + // Scrap entries (operators can record against a work order; admins/supers manage) + Route::get('/scrap-entries', [ScrapEntryController::class, 'index']); + Route::get('/scrap-entries/{scrapEntry}', [ScrapEntryController::class, 'show']); + Route::get('/work-orders/{workOrder}/scrap-entries', [ScrapEntryController::class, 'forWorkOrder']); + Route::post('/work-orders/{workOrder}/scrap-entries', [ScrapEntryController::class, 'store']); + Route::patch('/scrap-entries/{scrapEntry}', [ScrapEntryController::class, 'update']); + Route::delete('/scrap-entries/{scrapEntry}', [ScrapEntryController::class, 'destroy']); + // Additional Costs (admin/supervisor only — policy enforced) Route::get('/work-orders/{workOrder}/additional-costs', [AdditionalCostController::class, 'index']); Route::post('/work-orders/{workOrder}/additional-costs', [AdditionalCostController::class, 'store']); @@ -365,6 +377,11 @@ Route::patch('/anomaly-reasons/{anomaly_reason}', [AnomalyReasonController::class, 'update']); Route::delete('/anomaly-reasons/{anomaly_reason}', [AnomalyReasonController::class, 'destroy']); + // Scrap reasons + Route::post('/scrap-reasons', [ScrapReasonController::class, 'store']); + Route::match(['put', 'patch'], '/scrap-reasons/{scrapReason}', [ScrapReasonController::class, 'update']); + Route::delete('/scrap-reasons/{scrapReason}', [ScrapReasonController::class, 'destroy']); + // Subassemblies Route::post('/subassemblies', [SubassemblyController::class, 'store']); Route::patch('/subassemblies/{subassembly}', [SubassemblyController::class, 'update']); @@ -530,6 +547,8 @@ Route::get('/reports/production-summary', [ReportController::class, 'productionSummary']); Route::get('/reports/batch-completion', [ReportController::class, 'batchCompletion']); Route::get('/reports/downtime', [ReportController::class, 'downtimeReport']); + Route::get('/reports/scrap-pareto', [ReportController::class, 'scrapPareto']); + Route::get('/reports/scrap-rate', [ReportController::class, 'scrapRate']); Route::get('/reports/export-csv', [ReportController::class, 'exportCsv']); }); diff --git a/backend/routes/web.php b/backend/routes/web.php index 65640543..aa421efb 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -31,6 +31,8 @@ use App\Http\Controllers\Web\Admin\ProductionAnomalyController; use App\Http\Controllers\Web\Admin\ReportController as AdminReportController; use App\Http\Controllers\Web\Admin\SchedulePlannerController; +use App\Http\Controllers\Web\Admin\ScrapReasonController; +use App\Http\Controllers\Web\Admin\ScrapReportController; // Gate 3 — Basics use App\Http\Controllers\Web\Admin\SiteController; use App\Http\Controllers\Web\Admin\SkillController; @@ -50,6 +52,7 @@ // Gate 7 — Maintenance use App\Http\Controllers\Web\Operator\IssueController as OperatorIssueController; use App\Http\Controllers\Web\Operator\LineController as OperatorLineController; +use App\Http\Controllers\Web\Operator\ScrapController as OperatorScrapController; use App\Http\Controllers\Web\Operator\WorkOrderController as OperatorWorkOrderController; use App\Http\Controllers\Web\Operator\ProductionCorrectionController; use App\Http\Controllers\Web\Operator\WorkstationController as OperatorWorkstationController; @@ -197,6 +200,7 @@ Route::post('/batch/{batch}/packaging-checklist', [OperatorBatchController::class, 'packagingChecklist'])->name('batch.packaging-checklist'); Route::post('/batch/{batch}/release', [OperatorBatchController::class, 'release'])->name('batch.release'); Route::post('/issue', [OperatorIssueController::class, 'store'])->name('issue.store'); + Route::post('/scrap', [OperatorScrapController::class, 'store'])->name('scrap.store'); // Workstation production view Route::get('/workstation', [OperatorWorkstationController::class, 'index'])->name('workstation'); @@ -464,6 +468,10 @@ Route::resource('anomaly-reasons', AnomalyReasonController::class)->except(['show']); Route::post('/anomaly-reasons/{anomalyReason}/toggle-active', [AnomalyReasonController::class, 'toggleActive'])->name('anomaly-reasons.toggle-active'); + // Scrap Reasons + Route::resource('scrap-reasons', ScrapReasonController::class)->except(['show']); + Route::post('/scrap-reasons/{scrapReason}/toggle-active', [ScrapReasonController::class, 'toggleActive'])->name('scrap-reasons.toggle-active'); + // ── Gate 4: HR ─────────────────────────────────────────────────────── // Wage Groups Route::resource('wage-groups', WageGroupController::class)->except(['show']); @@ -494,6 +502,9 @@ Route::post('/production-anomalies/{productionAnomaly}/process', [ProductionAnomalyController::class, 'process'])->name('production-anomalies.process'); Route::delete('/production-anomalies/{productionAnomaly}', [ProductionAnomalyController::class, 'destroy'])->name('production-anomalies.destroy'); + // Scrap reporting (Pareto, scrap rate per line, trend) + Route::get('/scrap-reports', [ScrapReportController::class, 'index'])->name('scrap-reports.index'); + // Inspection Plans (admin CRUD) Route::resource('inspection-plans', \App\Http\Controllers\Web\Admin\InspectionPlanController::class)->except(['show']); diff --git a/backend/tests/Feature/Api/ScrapApiTest.php b/backend/tests/Feature/Api/ScrapApiTest.php new file mode 100644 index 00000000..0fcade3b --- /dev/null +++ b/backend/tests/Feature/Api/ScrapApiTest.php @@ -0,0 +1,199 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $this->admin = User::factory()->create(); + $this->admin->assignRole('Admin'); + $this->adminToken = $this->admin->createToken('test')->plainTextToken; + + $this->operator = User::factory()->create(); + $this->operator->assignRole('Operator'); + $this->operatorToken = $this->operator->createToken('test')->plainTextToken; + } + + private function asAdmin() + { + return $this->withHeader('Authorization', "Bearer {$this->adminToken}"); + } + + private function asOperator() + { + return $this->withHeader('Authorization', "Bearer {$this->operatorToken}"); + } + + // ── Scrap reasons ──────────────────────────────────────────────────────── + + public function test_index_returns_only_active_reasons_by_default(): void + { + ScrapReason::factory()->create(['code' => 'ACT-1', 'is_active' => true]); + ScrapReason::factory()->inactive()->create(['code' => 'INACT-1']); + + $response = $this->asOperator()->getJson('/api/v1/scrap-reasons'); + + $response->assertOk(); + $codes = collect($response->json('data'))->pluck('code'); + $this->assertTrue($codes->contains('ACT-1')); + $this->assertFalse($codes->contains('INACT-1')); + } + + public function test_admin_can_create_reason(): void + { + $this->asAdmin()->postJson('/api/v1/scrap-reasons', [ + 'code' => 'API-1', 'name' => 'Via API', 'category' => 'machine', + ])->assertStatus(201)->assertJsonPath('data.code', 'API-1'); + + $this->assertDatabaseHas('scrap_reasons', ['code' => 'API-1', 'category' => 'machine']); + } + + public function test_operator_cannot_create_reason(): void + { + $this->asOperator()->postJson('/api/v1/scrap-reasons', [ + 'code' => 'API-1', 'name' => 'Via API', 'category' => 'machine', + ])->assertStatus(403); + + $this->assertDatabaseMissing('scrap_reasons', ['code' => 'API-1']); + } + + public function test_unauthenticated_cannot_read_reasons(): void + { + $this->getJson('/api/v1/scrap-reasons')->assertStatus(401); + } + + // ── Scrap entries ──────────────────────────────────────────────────────── + + public function test_operator_can_report_scrap_against_work_order(): void + { + $wo = WorkOrder::factory()->create(); + $reason = ScrapReason::factory()->create(); + + $response = $this->asOperator()->postJson("/api/v1/work-orders/{$wo->id}/scrap-entries", [ + 'scrap_reason_id' => $reason->id, + 'quantity' => 12, + 'notes' => 'API report', + ]); + + $response->assertStatus(201)->assertJsonPath('data.quantity', '12.00'); + $this->assertDatabaseHas('scrap_entries', [ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'reported_by' => $this->operator->id, + ]); + } + + public function test_reporting_against_inactive_reason_fails_validation(): void + { + $wo = WorkOrder::factory()->create(); + $reason = ScrapReason::factory()->inactive()->create(); + + $this->asOperator()->postJson("/api/v1/work-orders/{$wo->id}/scrap-entries", [ + 'scrap_reason_id' => $reason->id, + 'quantity' => 12, + ])->assertStatus(422)->assertJsonValidationErrors('scrap_reason_id'); + } + + public function test_work_order_scrap_listing_includes_totals_and_quality(): void + { + $wo = WorkOrder::factory()->create(['produced_qty' => 200]); + $reason = ScrapReason::factory()->create(); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $reason->id, 'quantity' => 50]); + + $response = $this->asOperator()->getJson("/api/v1/work-orders/{$wo->id}/scrap-entries"); + + $response->assertOk() + ->assertJsonPath('meta.total_scrap_qty', 50) + ->assertJsonPath('meta.quality_pct', 75); // (200-50)/200 + } + + // ── Reports ────────────────────────────────────────────────────────────── + + public function test_scrap_pareto_returns_reasons_sorted_by_quantity(): void + { + $wo = WorkOrder::factory()->create(); + $big = ScrapReason::factory()->create(['code' => 'BIG']); + $small = ScrapReason::factory()->create(['code' => 'SMALL']); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $big->id, 'quantity' => 90, 'reported_at' => now()]); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $small->id, 'quantity' => 10, 'reported_at' => now()]); + + $response = $this->asAdmin()->getJson('/api/v1/reports/scrap-pareto'); + + $response->assertOk(); + $reasons = $response->json('data.pareto.reasons'); + $this->assertSame('BIG', $reasons[0]['code']); + $this->assertEquals(90, $reasons[0]['pct']); // 90 / 100 + $this->assertEquals(100, $reasons[1]['cumulative_pct']); + } + + public function test_scrap_rate_per_line(): void + { + $wo = WorkOrder::factory()->create(); + Batch::factory()->done()->create(['work_order_id' => $wo->id, 'target_qty' => 100, 'produced_qty' => 100]); + $reason = ScrapReason::factory()->create(); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $reason->id, 'quantity' => 25, 'reported_at' => now()]); + + $response = $this->asAdmin()->getJson('/api/v1/reports/scrap-rate'); + + $response->assertOk(); + $perLine = collect($response->json('data.per_line'))->firstWhere('line_id', $wo->line_id); + $this->assertNotNull($perLine); + $this->assertEquals(25, $perLine['scrap_qty']); + $this->assertEquals(100, $perLine['produced_qty']); + $this->assertEquals(25, $perLine['scrap_rate_pct']); + } + + public function test_operator_cannot_access_scrap_reports(): void + { + $this->asOperator()->getJson('/api/v1/reports/scrap-pareto')->assertStatus(403); + } + + public function test_scrap_entries_are_isolated_per_tenant(): void + { + $tenantA = Tenant::create(['name' => 'Tenant A']); + $tenantB = Tenant::create(['name' => 'Tenant B']); + + $operatorA = User::factory()->create(['tenant_id' => $tenantA->id]); + $operatorA->assignRole('Operator'); + $tokenA = $operatorA->createToken('test')->plainTextToken; + + $reason = ScrapReason::factory()->create(); + + $woA = WorkOrder::factory()->create(['tenant_id' => $tenantA->id]); + $entryA = ScrapEntry::factory()->create(['work_order_id' => $woA->id, 'scrap_reason_id' => $reason->id]); + + $woB = WorkOrder::factory()->create(['tenant_id' => $tenantB->id]); + $entryB = ScrapEntry::factory()->create(['work_order_id' => $woB->id, 'scrap_reason_id' => $reason->id]); + + $authA = $this->withHeader('Authorization', "Bearer {$tokenA}"); + + // Listing only returns the caller's tenant entries. + $ids = collect($authA->getJson('/api/v1/scrap-entries')->json('data'))->pluck('id'); + $this->assertTrue($ids->contains($entryA->id)); + $this->assertFalse($ids->contains($entryB->id), 'Cross-tenant scrap entry leaked into listing'); + + // A cross-tenant entry is not directly readable; an own entry is. + $authA->getJson("/api/v1/scrap-entries/{$entryB->id}")->assertStatus(404); + $authA->getJson("/api/v1/scrap-entries/{$entryA->id}")->assertStatus(200); + } +} diff --git a/backend/tests/Feature/ScrapTest.php b/backend/tests/Feature/ScrapTest.php new file mode 100644 index 00000000..68bc5923 --- /dev/null +++ b/backend/tests/Feature/ScrapTest.php @@ -0,0 +1,69 @@ +create([ + 'code' => 'TEST-1', + 'category' => ScrapReason::CATEGORY_MACHINE, + ]); + + $entry = ScrapEntry::factory()->create([ + 'scrap_reason_id' => $reason->id, + 'quantity' => 12.5, + ]); + + $this->assertDatabaseHas('scrap_reasons', ['code' => 'TEST-1', 'category' => 'machine']); + $this->assertDatabaseHas('scrap_entries', ['scrap_reason_id' => $reason->id]); + $this->assertSame('12.50', (string) $entry->quantity); + $this->assertTrue($reason->scrapEntries()->whereKey($entry->id)->exists()); + } + + public function test_work_order_total_scrap_and_quality_accessors(): void + { + $wo = WorkOrder::factory()->create(['produced_qty' => 100]); + $reason = ScrapReason::factory()->create(); + + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $reason->id, 'quantity' => 10]); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $reason->id, 'quantity' => 15]); + + $this->assertEqualsWithDelta(25.0, $wo->totalScrapQty(), 0.001); + // good = 100 - 25 = 75 → 75% + $this->assertEqualsWithDelta(75.0, $wo->qualityPct(), 0.001); + } + + public function test_quality_pct_is_null_when_nothing_produced(): void + { + $wo = WorkOrder::factory()->create(['produced_qty' => 0]); + + $this->assertNull($wo->qualityPct()); + } + + public function test_quality_pct_clamps_to_zero_when_scrap_exceeds_production(): void + { + $wo = WorkOrder::factory()->create(['produced_qty' => 10]); + $reason = ScrapReason::factory()->create(); + ScrapEntry::factory()->create(['work_order_id' => $wo->id, 'scrap_reason_id' => $reason->id, 'quantity' => 25]); + + $this->assertSame(0.0, $wo->qualityPct()); + } + + public function test_default_scrap_reasons_are_seeded(): void + { + $this->seed(\Database\Seeders\ScrapReasonsSeeder::class); + + $this->assertDatabaseCount('scrap_reasons', 5); + $this->assertDatabaseHas('scrap_reasons', ['code' => 'MACH-FAIL', 'category' => 'machine']); + } +} diff --git a/backend/tests/Feature/Web/Admin/ScrapReasonControllerTest.php b/backend/tests/Feature/Web/Admin/ScrapReasonControllerTest.php new file mode 100644 index 00000000..46116e01 --- /dev/null +++ b/backend/tests/Feature/Web/Admin/ScrapReasonControllerTest.php @@ -0,0 +1,130 @@ +admin = User::factory()->create(); + $this->admin->assignRole('Admin'); + } + + public function test_admin_can_list_scrap_reasons(): void + { + ScrapReason::factory()->create(['name' => 'Burr on edge']); + + $response = $this->actingAs($this->admin)->get(route('admin.scrap-reasons.index')); + + $response->assertOk(); + $response->assertSee('Burr on edge'); + } + + public function test_admin_can_create_scrap_reason(): void + { + $response = $this->actingAs($this->admin)->post(route('admin.scrap-reasons.store'), [ + 'code' => 'WELD-CRACK', + 'name' => 'Weld crack', + 'category' => ScrapReason::CATEGORY_METHOD, + 'is_active' => '1', + ]); + + $response->assertRedirect(route('admin.scrap-reasons.index')); + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('scrap_reasons', [ + 'code' => 'WELD-CRACK', + 'category' => 'method', + 'is_active' => true, + ]); + } + + public function test_category_must_be_a_valid_5m_value(): void + { + $response = $this->actingAs($this->admin)->post(route('admin.scrap-reasons.store'), [ + 'code' => 'BAD', + 'name' => 'Bad category', + 'category' => 'gremlins', + ]); + + $response->assertSessionHasErrors('category'); + } + + public function test_code_must_be_unique(): void + { + ScrapReason::factory()->create(['code' => 'DUP-1']); + + $response = $this->actingAs($this->admin)->post(route('admin.scrap-reasons.store'), [ + 'code' => 'DUP-1', + 'name' => 'Duplicate', + 'category' => ScrapReason::CATEGORY_MAN, + ]); + + $response->assertSessionHasErrors('code'); + } + + public function test_admin_can_update_scrap_reason(): void + { + $reason = ScrapReason::factory()->create(['name' => 'Old']); + + $response = $this->actingAs($this->admin)->put(route('admin.scrap-reasons.update', $reason), [ + 'code' => $reason->code, + 'name' => 'New name', + 'category' => ScrapReason::CATEGORY_MACHINE, + ]); + + $response->assertRedirect(route('admin.scrap-reasons.index')); + $this->assertDatabaseHas('scrap_reasons', ['id' => $reason->id, 'name' => 'New name', 'category' => 'machine']); + } + + public function test_admin_can_toggle_active(): void + { + $reason = ScrapReason::factory()->create(['is_active' => true]); + + $this->actingAs($this->admin)->post(route('admin.scrap-reasons.toggle-active', $reason)); + + $this->assertFalse($reason->fresh()->is_active); + } + + public function test_reason_with_entries_cannot_be_deleted(): void + { + $reason = ScrapReason::factory()->create(); + ScrapEntry::factory()->create(['scrap_reason_id' => $reason->id]); + + $response = $this->actingAs($this->admin)->delete(route('admin.scrap-reasons.destroy', $reason)); + + $response->assertSessionHas('error'); + $this->assertDatabaseHas('scrap_reasons', ['id' => $reason->id]); + } + + public function test_unused_reason_can_be_deleted(): void + { + $reason = ScrapReason::factory()->create(); + + $this->actingAs($this->admin)->delete(route('admin.scrap-reasons.destroy', $reason)); + + $this->assertDatabaseMissing('scrap_reasons', ['id' => $reason->id]); + } + + public function test_non_admin_cannot_manage_scrap_reasons(): void + { + Role::findOrCreate('Operator', 'web'); + $operator = User::factory()->create(); + $operator->assignRole('Operator'); + + $this->actingAs($operator)->get(route('admin.scrap-reasons.index'))->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Web/Admin/ScrapReportControllerTest.php b/backend/tests/Feature/Web/Admin/ScrapReportControllerTest.php new file mode 100644 index 00000000..0eb3ccd0 --- /dev/null +++ b/backend/tests/Feature/Web/Admin/ScrapReportControllerTest.php @@ -0,0 +1,55 @@ +admin = User::factory()->create(); + $this->admin->assignRole('Admin'); + } + + public function test_report_page_renders_with_pareto_data(): void + { + $reason = ScrapReason::factory()->create(['name' => 'Porosity', 'code' => 'POR-1']); + $wo = WorkOrder::factory()->create(['produced_qty' => 100]); + ScrapEntry::factory()->create([ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'quantity' => 20, + 'reported_at' => now(), + ]); + + $response = $this->actingAs($this->admin)->get(route('admin.scrap-reports.index')); + + $response->assertOk(); + $response->assertSee('Porosity'); + $response->assertSee('POR-1'); + } + + public function test_invalid_date_range_is_rejected(): void + { + $response = $this->actingAs($this->admin)->get(route('admin.scrap-reports.index', [ + 'date_from' => '2026-06-10', + 'date_to' => '2026-06-01', + ])); + + $response->assertSessionHasErrors('date_to'); + } +} diff --git a/backend/tests/Feature/Web/Operator/ScrapReportingTest.php b/backend/tests/Feature/Web/Operator/ScrapReportingTest.php new file mode 100644 index 00000000..f5b63374 --- /dev/null +++ b/backend/tests/Feature/Web/Operator/ScrapReportingTest.php @@ -0,0 +1,83 @@ +operator = User::factory()->create(); + $this->operator->assignRole('Operator'); + } + + public function test_operator_can_report_scrap_against_work_order_on_their_line(): void + { + $wo = WorkOrder::factory()->create(); + $reason = ScrapReason::factory()->create(['is_active' => true]); + + $response = $this->actingAs($this->operator) + ->withSession(['selected_line_id' => $wo->line_id]) + ->post(route('operator.scrap.store'), [ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'quantity' => 7.5, + 'notes' => 'Cracked during handling', + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + $this->assertDatabaseHas('scrap_entries', [ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'quantity' => 7.5, + 'reported_by' => $this->operator->id, + ]); + } + + public function test_scrap_against_wrong_line_is_blocked(): void + { + $wo = WorkOrder::factory()->create(); + $reason = ScrapReason::factory()->create(); + + $response = $this->actingAs($this->operator) + ->withSession(['selected_line_id' => $wo->line_id + 999]) + ->post(route('operator.scrap.store'), [ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'quantity' => 5, + ]); + + $response->assertSessionHas('error'); + $this->assertDatabaseMissing('scrap_entries', ['work_order_id' => $wo->id]); + } + + public function test_inactive_reason_is_rejected(): void + { + $wo = WorkOrder::factory()->create(); + $reason = ScrapReason::factory()->inactive()->create(); + + $response = $this->actingAs($this->operator) + ->withSession(['selected_line_id' => $wo->line_id]) + ->post(route('operator.scrap.store'), [ + 'work_order_id' => $wo->id, + 'scrap_reason_id' => $reason->id, + 'quantity' => 5, + ]); + + $response->assertSessionHasErrors('scrap_reason_id'); + } +}