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..d73f4fed --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ScrapReasonController.php @@ -0,0 +1,120 @@ +get(['id']) + ->mapWithKeys(fn ($r) => [$r->id => $r->scrap_entries_count]); + + return Inertia::render('admin/scrap-reasons/Index', [ + 'counts' => $counts, + ]); + } + + /** + * Show the form for creating a new scrap reason. + */ + public function create() + { + return Inertia::render('admin/scrap-reasons/Create'); + } + + /** + * 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 Inertia::render('admin/scrap-reasons/Edit', [ + 'scrapReason' => $scrapReason->only('id', 'code', 'name', 'category', 'description', 'sort_order', 'is_active'), + ]); + } + + /** + * 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..f7461ff1 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ScrapReportController.php @@ -0,0 +1,43 @@ +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(); + + return Inertia::render('admin/scrap-reports/Index', [ + 'lines' => Line::orderBy('name')->get(), + 'lineId' => $lineId, + 'dateFrom' => $from->toDateString(), + 'dateTo' => $to->toDateString(), + 'pareto' => $this->scrapReports->pareto($from, $to, $lineId), + 'ratePerLine' => $this->scrapReports->ratePerLine($from, $to), + ]); + } +} 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 f2a4632e..36764903 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; @@ -233,10 +234,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() @@ -253,6 +258,6 @@ public function show(Request $request, WorkOrder $workOrder) ->orderBy('name') ->get(['id', 'name', 'type', 'size', 'barcode_format', 'is_default']); - return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates')); + return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates')); } } 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 c03a536e..434cc55b 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. Accepts a single value or an array — mobile * uses `?status[]=A&status[]=B` to match multiple statuses at once. 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/app/Sync/ShapeRegistry.php b/backend/app/Sync/ShapeRegistry.php index a164fa39..c1ef3341 100644 --- a/backend/app/Sync/ShapeRegistry.php +++ b/backend/app/Sync/ShapeRegistry.php @@ -38,6 +38,10 @@ class ShapeRegistry 'table' => 'anomaly_reasons', 'columns' => ['id', 'code', 'name', 'category', 'description', 'is_active', 'created_at', 'updated_at'], ], + 'scrap_reasons' => [ + 'table' => 'scrap_reasons', + 'columns' => ['id', 'code', 'name', 'category', 'description', 'is_active', 'sort_order', 'created_at', 'updated_at'], + ], 'companies' => [ 'table' => 'companies', 'columns' => ['id', 'code', 'name', 'tax_id', 'type', 'email', 'phone', 'address', 'is_active', 'created_at', 'updated_at'], 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 fc69b6b3..2974f59b 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -2921,5 +2921,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 d0b41782..47f90653 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -2921,5 +2921,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 865ac55b..c6fa134d 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1252,6 +1252,51 @@ "— Select type —": "— Tip seçin —", "— Unassigned —": "— Atanmamış —", "← 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", "Add tag": "Etiket ekle", "All connectivity": "Tüm bağlantılar", "Anonymous": "Anonim", diff --git a/backend/resources/js/Pages/admin/scrap-reasons/Create.jsx b/backend/resources/js/Pages/admin/scrap-reasons/Create.jsx new file mode 100644 index 00000000..75c8b8bc --- /dev/null +++ b/backend/resources/js/Pages/admin/scrap-reasons/Create.jsx @@ -0,0 +1,23 @@ +import { Head } from '@inertiajs/react'; +import AppLayout from '../../../layouts/AppLayout'; +import ResourceForm from '../../../components/ResourceForm'; +import { SCRAP_REASON_FIELDS } from './fields'; + +export default function ScrapReasonCreate() { + return ( +
+
+
Which reasons cause the most scrap (Pareto), and scrap rate per line.
+| = 3 ? 'text-right' : 'text-left'}`}>{h} | + ))} +|||||
|---|---|---|---|---|---|
| {r.code} | +{r.name} | +{CATEGORY_LABELS[r.category] ?? r.category} | +{fmt(r.qty)} | +{num(r.pct).toFixed(1)}% | +{num(r.cumulative_pct).toFixed(1)}% | +
| = 1 ? 'text-right' : 'text-left'}`}>{h} | + ))} +|||
|---|---|---|---|
| {r.line_name} | +{fmt(r.scrap_qty)} | +{fmt(r.produced_qty)} | ++ {r.scrap_rate_pct != null ? num(r.scrap_rate_pct).toFixed(2) + '%' : '—'} + | +
+ >
+ );
+}
+
+ScrapReportsIndex.layout = (page) =>
+ ); +} + +function Kpi({ label, value, sub }) { + return ( +
{label}
+{value}
+ {sub &&{sub}
} ++ ); +} + +function Card({ title, children }) { + return ( +
+ ); +} + +function Empty({ children }) { + return
{children}
; +} diff --git a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx index 8363e6d5..27537860 100644 --- a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx +++ b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx @@ -919,15 +919,123 @@ function ReportIssueModal({ workOrder, issueTypes, onClose }) { ); } +// --------------------------------------------------------------------------- +// Report Scrap Modal +// --------------------------------------------------------------------------- + +function ReportScrapModal({ workOrder, scrapReasons, onClose }) { + const form = useForm({ + work_order_id: workOrder.id, + scrap_reason_id: '', + quantity: '', + notes: '', + }); + + const submit = (e) => { + e.preventDefault(); + form.post('/operator/scrap', { onSuccess: onClose }); + }; + + return ( +
+ ); +} + // --------------------------------------------------------------------------- // Main page // --------------------------------------------------------------------------- export default function WorkOrderDetail() { - const { workOrder, issueTypes = [], workstations = [], defaultWorkstationId, line, labelTemplates = [] } = usePage().props; + const { workOrder, issueTypes = [], scrapReasons = [], workstations = [], defaultWorkstationId, line, labelTemplates = [] } = usePage().props; const [createBatchOpen, setCreateBatchOpen] = useState(false); const [reportIssueOpen, setReportIssueOpen] = useState(false); + const [reportScrapOpen, setReportScrapOpen] = useState(false); const plannedQty = workOrder.planned_qty ?? 0; const producedQty = workOrder.produced_qty ?? 0; @@ -936,6 +1044,11 @@ export default function WorkOrderDetail() { const canCreateBatch = !['DONE', 'CANCELLED', 'BLOCKED'].includes(workOrder.status); const canReportIssue = !['DONE', 'CANCELLED'].includes(workOrder.status); + const canReportScrap = scrapReasons.length > 0 && !['DONE', 'CANCELLED'].includes(workOrder.status); + + const scrapEntries = workOrder.scrap_entries ?? []; + const totalScrap = scrapEntries.reduce((sum, e) => sum + Number(e.quantity ?? 0), 0); + const qualityPct = producedQty > 0 ? (Math.max(0, producedQty - totalScrap) / producedQty) * 100 : null; const dueDateStr = workOrder.due_date; const dueDatePast = dueDateStr && new Date(dueDateStr) < new Date() && workOrder.status !== 'DONE'; @@ -1168,6 +1281,64 @@ export default function WorkOrderDetail() { )} + + {/* Scrap */} +
No scrap reported.
+ ) : ( ++ {entry.notes.length > 80 ? `${entry.notes.slice(0, 80)}…` : entry.notes} +
+ )} ++ {entry.reported_at ? new Date(entry.reported_at).toLocaleString() : ''} + {entry.reported_by ? ` by ${entry.reported_by.name}` : ''} +
++{scrapEntries.length - 5} more
+ )} +
@@ -1189,6 +1360,14 @@ export default function WorkOrderDetail() {
onClose={() => setReportIssueOpen(false)}
/>
)}
+
+ {reportScrapOpen && (
+