Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions backend/app/Http/Controllers/Api/V1/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
143 changes: 143 additions & 0 deletions backend/app/Http/Controllers/Api/V1/ScrapEntryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\ScrapEntry;
use App\Models\WorkOrder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class ScrapEntryController extends Controller
{
public function index(Request $request): JsonResponse
{
$this->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);
}
}
80 changes: 80 additions & 0 deletions backend/app/Http/Controllers/Api/V1/ScrapReasonController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\ScrapReason;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class ScrapReasonController extends Controller
{
public function index(Request $request): JsonResponse
{
$this->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']);
}
}
Loading