diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c6b3e7..36282af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **Partial consumption, returns to stock & material reclassification** *([#99](https://github.com/Mes-Open/OpenMes/issues/99))* — the shop floor rarely uses exactly what was issued, so a work order's pulled materials can now be reconciled against reality, every change booked to the stock-movement ledger. **Declare actual (partial) consumption** per material — recording what was really used leaves the over-issued surplus to be handed back. **Return unused quantity to stock** at any point before completion: the return raises on-hand, releases the reservation and restores the picked lots, and — critically — shrinks the allocation so batch completion never returns the same quantity twice. **Reclassify material** either between **classes** (regrade a quantity from one material to another, booking a correlated pair of movements) or by **lot status** (release / quarantine / reject — a rejected lot's remaining quantity is scrapped out of stock). Available on the admin work-order page (a **Materials reconciliation** panel) and over the API (`/api/v1/material-allocations/{id}/consume` & `/return`, `/api/v1/material-reclassifications/class`, `/api/v1/material-lots/{id}/reclassify-status`); reclassification is gated to Supervisor/Admin. A new append-only `material_reclassifications` audit table correlates the movement legs. All quantities/columns are additive — existing consumption at batch completion is unchanged. - **Controlled production stops and work-order change requests** *([#182](https://github.com/Mes-Open/OpenMes/issues/182))* — pause a running order, change what it builds under review, and resume on the new configuration **without ever overwriting what the shop floor already did**. - **A stop is a record, not just a status.** `POST /api/v1/work-orders/{id}/stop` captures a **typed reason** (`OPERATIONAL`, `MATERIAL_SHORTAGE`, `MACHINE_FAILURE`, `QUALITY_HOLD`, `ENGINEERING_CHANGE`, `OTHER`), who stopped production and when, and a **photograph of the state at that moment**: produced quantity, active/completed batches, in-progress steps, allocated and consumed material, and the configuration version in force. Only **one stop is open at a time**, so downtime totals can't double-count. Supplying a downtime reason also opens a **linked `production_downtimes` record** — a stop and a downtime stay separate concepts (why production stopped vs. how long a resource was idle), linked rather than merged, and closed together on resume. Duration is materialised on resume for reporting and reported as a **running total** while the stop is still open. - **A new `CHANGE_HOLD` status.** A stop raised with `requires_change` puts the order on **CHANGE_HOLD** instead of PAUSED — the board distinguishes "back after the break" from "nobody may build this until a change is approved" — and **resume is refused** until an approved change request has actually been **applied**. Plain pause/resume is untouched: an order paused the old way still resumes on an empty request body, with no change request and no stop record. diff --git a/backend/app/Http/Controllers/Api/V1/MaterialAllocationController.php b/backend/app/Http/Controllers/Api/V1/MaterialAllocationController.php new file mode 100644 index 00000000..11c8c00e --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/MaterialAllocationController.php @@ -0,0 +1,66 @@ +allocations->recordConsumption( + $allocation, + (float) $request->validated('consumed_qty'), + (float) ($request->validated('scrap_qty') ?? 0), + $request->validated('notes'), + ); + + return response()->json([ + 'message' => 'Consumption recorded', + 'data' => $updated, + ]); + } catch (\DomainException|\InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + 'errors' => ['consumed_qty' => [$e->getMessage()]], + ], 422); + } + } + + public function return(ReturnAllocationRequest $request, MaterialAllocation $allocation): JsonResponse + { + try { + $updated = $this->allocations->returnQuantity( + $allocation, + (float) $request->validated('qty'), + $request->user(), + $request->validated('reason'), + ); + + return response()->json([ + 'message' => 'Material returned to stock', + 'data' => $updated, + ]); + } catch (\DomainException|\InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + 'errors' => ['qty' => [$e->getMessage()]], + ], 422); + } + } +} diff --git a/backend/app/Http/Controllers/Api/V1/MaterialReclassificationController.php b/backend/app/Http/Controllers/Api/V1/MaterialReclassificationController.php new file mode 100644 index 00000000..c376fd29 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/MaterialReclassificationController.php @@ -0,0 +1,74 @@ +validated('source_material_id')); + $target = Material::findOrFail($request->validated('target_material_id')); + $lot = $request->validated('source_lot_id') + ? MaterialLot::findOrFail($request->validated('source_lot_id')) + : null; + + $record = $this->reclassifications->reclassifyClass( + $source, + $target, + (float) $request->validated('qty'), + $request->user(), + $lot, + $request->validated('reason'), + ); + + return response()->json([ + 'message' => 'Material reclassified', + 'data' => $record, + ]); + } catch (\DomainException|\InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + 'errors' => ['qty' => [$e->getMessage()]], + ], 422); + } + } + + public function status(ReclassifyLotStatusRequest $request, MaterialLot $materialLot): JsonResponse + { + try { + $record = $this->reclassifications->reclassifyStatus( + $materialLot, + $request->validated('to_status'), + $request->user(), + $request->validated('reason'), + ); + + return response()->json([ + 'message' => 'Lot status changed', + 'data' => $record, + ]); + } catch (\DomainException|\InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + 'errors' => ['to_status' => [$e->getMessage()]], + ], 422); + } + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php b/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php index faca783a..50be3752 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php @@ -3,15 +3,23 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\V1\ReclassifyClassRequest; +use App\Http\Requests\Api\V1\RecordConsumptionRequest; +use App\Http\Requests\Api\V1\ReturnAllocationRequest; use App\Http\Requests\Web\Admin\StoreWorkOrderRequest; use App\Http\Requests\Web\Admin\UpdateWorkOrderRequest; use App\Http\Requests\WorkOrder\ResumeWorkOrderRequest; use App\Models\Customer; use App\Models\Line; +use App\Models\Material; +use App\Models\MaterialAllocation; +use App\Models\MaterialLot; use App\Models\ProcessTemplate; use App\Models\ProductType; use App\Models\WorkOrder; use App\Services\CustomFieldService; +use App\Services\Material\MaterialAllocationService; +use App\Services\Material\MaterialReclassificationService; use App\Services\WorkOrder\WorkOrderService; use App\Services\WorkOrder\WorkOrderStopService; use Illuminate\Http\Request; @@ -155,6 +163,27 @@ public function show(WorkOrder $workOrder, CustomFieldService $customFields) 'is_blocking' => (bool) ($i->issueType?->is_blocking ?? false), ])->values(); + // Materials reconciliation (#99): the allocations pulled for this order, so + // the page can offer record-consumption / return / reclassify per material. + $workOrder->load(['allocations.material', 'allocations.lotPicks.lot']); + $allocations = $workOrder->allocations->map(fn ($a) => [ + 'id' => $a->id, + 'material_id' => $a->material_id, + 'material_code' => $a->material?->code, + 'material_name' => $a->material?->name, + 'unit_of_measure' => $a->material?->unit_of_measure, + 'status' => $a->status, + 'allocated_qty' => (float) $a->allocated_qty, + 'consumed_qty' => (float) $a->consumed_qty, + 'scrap_qty' => (float) $a->scrap_qty, + 'returned_qty' => (float) $a->returned_qty, + 'lots' => $a->lotPicks->map(fn ($p) => [ + 'lot_id' => $p->material_lot_id, + 'lot_number' => $p->lot?->lot_number, + 'picked_qty' => (float) $p->picked_qty, + ])->values(), + ])->values(); + // Change control (#182): the stop history with durations, and every change // request raised against this order. $stops = $workOrder->stops()->with(['stoppedBy:id,name', 'resumedBy:id,name'])->get() @@ -188,6 +217,8 @@ public function show(WorkOrder $workOrder, CustomFieldService $customFields) 'created_at' => $cr->created_at?->toISOString(), ])->values(); + $canReclassify = (bool) request()->user()?->hasAnyRole(['Supervisor', 'Admin']); + $openStop = $workOrder->openStop(); // An order held for a change may only resume once one has been applied — the // page needs to know which, so Resume can carry it. @@ -235,11 +266,93 @@ public function show(WorkOrder $workOrder, CustomFieldService $customFields) 'product_type_name' => $workOrder->productType?->name, 'batches' => $batches, 'issues' => $issues, + 'allocations' => $allocations, ], + 'canReclassify' => $canReclassify, + 'materials' => $canReclassify + ? Material::where('is_active', true)->orderBy('code')->get(['id', 'code', 'name']) + : [], 'customFields' => $customFields->clientConfig('work_order'), ]); } + /** + * Materials reconciliation (#99): declare actual consumption, return unused + * material to stock, and reclassify a quantity to another class. Each + * allocation must belong to this work order. + */ + public function recordConsumption(RecordConsumptionRequest $request, WorkOrder $workOrder, MaterialAllocation $allocation, MaterialAllocationService $allocations) + { + $this->assertAllocationBelongs($workOrder, $allocation); + + try { + $allocations->recordConsumption( + $allocation, + (float) $request->validated('consumed_qty'), + (float) ($request->validated('scrap_qty') ?? 0), + $request->validated('notes'), + ); + + return back()->with('success', __('Consumption recorded')); + } catch (\DomainException|\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + } + + public function returnAllocation(ReturnAllocationRequest $request, WorkOrder $workOrder, MaterialAllocation $allocation, MaterialAllocationService $allocations) + { + $this->assertAllocationBelongs($workOrder, $allocation); + + try { + $allocations->returnQuantity( + $allocation, + (float) $request->validated('qty'), + $request->user(), + $request->validated('reason'), + ); + + return back()->with('success', __('Material returned to stock')); + } catch (\DomainException|\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + } + + public function reclassify(ReclassifyClassRequest $request, WorkOrder $workOrder, MaterialReclassificationService $reclassifications) + { + try { + $source = Material::findOrFail($request->validated('source_material_id')); + + // The panel always reclassifies one of this order's pulled materials — + // enforce that so the nested route is a real scope, not decoration. + if (! $workOrder->allocations()->where('material_id', $source->id)->exists()) { + abort(404); + } + + $target = Material::findOrFail($request->validated('target_material_id')); + $lot = $request->validated('source_lot_id') ? MaterialLot::findOrFail($request->validated('source_lot_id')) : null; + + $reclassifications->reclassifyClass( + $source, + $target, + (float) $request->validated('qty'), + $request->user(), + $lot, + $request->validated('reason'), + ); + + return back()->with('success', __('Material reclassified')); + } catch (\DomainException|\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + } + + private function assertAllocationBelongs(WorkOrder $workOrder, MaterialAllocation $allocation): void + { + if ($allocation->batch?->work_order_id !== $workOrder->id) { + abort(404); + } + } + public function edit(WorkOrder $workOrder, CustomFieldService $customFields) { return Inertia::render('admin/work-orders/Edit', [ diff --git a/backend/app/Http/Requests/Api/V1/ReclassifyClassRequest.php b/backend/app/Http/Requests/Api/V1/ReclassifyClassRequest.php new file mode 100644 index 00000000..b250f8c0 --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/ReclassifyClassRequest.php @@ -0,0 +1,28 @@ + ['required', 'integer', 'exists:materials,id'], + 'target_material_id' => ['required', 'integer', 'different:source_material_id', 'exists:materials,id'], + 'qty' => ['required', 'numeric', 'gt:0'], + 'source_lot_id' => ['nullable', 'integer', 'exists:material_lots,id'], + 'reason' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/ReclassifyLotStatusRequest.php b/backend/app/Http/Requests/Api/V1/ReclassifyLotStatusRequest.php new file mode 100644 index 00000000..c45d02e2 --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/ReclassifyLotStatusRequest.php @@ -0,0 +1,40 @@ + ['required', Rule::in([ + MaterialLot::STATUS_RELEASED, + MaterialLot::STATUS_QUARANTINE, + MaterialLot::STATUS_REJECTED, + ])], + 'reason' => [ + 'nullable', + 'string', + 'max:255', + Rule::requiredIf(fn () => in_array($this->input('to_status'), [ + MaterialLot::STATUS_QUARANTINE, + MaterialLot::STATUS_REJECTED, + ], true)), + ], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/RecordConsumptionRequest.php b/backend/app/Http/Requests/Api/V1/RecordConsumptionRequest.php new file mode 100644 index 00000000..3f72b59d --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/RecordConsumptionRequest.php @@ -0,0 +1,33 @@ +route('allocation'); + + // A soft-deleted batch hides the relationship, so guard it before deref. + return $allocation instanceof MaterialAllocation + && $allocation->batch?->workOrder !== null + && (bool) $this->user()?->can('view', $allocation->batch->workOrder); + } + + public function rules(): array + { + return [ + 'consumed_qty' => ['required', 'numeric', 'min:0'], + 'scrap_qty' => ['nullable', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/ReturnAllocationRequest.php b/backend/app/Http/Requests/Api/V1/ReturnAllocationRequest.php new file mode 100644 index 00000000..2bdbd11c --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/ReturnAllocationRequest.php @@ -0,0 +1,31 @@ +route('allocation'); + + // A soft-deleted batch hides the relationship, so guard it before deref. + return $allocation instanceof MaterialAllocation + && $allocation->batch?->workOrder !== null + && (bool) $this->user()?->can('view', $allocation->batch->workOrder); + } + + public function rules(): array + { + return [ + 'qty' => ['required', 'numeric', 'min:0.0001'], + 'reason' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Models/MaterialAllocation.php b/backend/app/Models/MaterialAllocation.php index 7833dd53..05bce990 100644 --- a/backend/app/Models/MaterialAllocation.php +++ b/backend/app/Models/MaterialAllocation.php @@ -29,6 +29,7 @@ class MaterialAllocation extends Model 'expected_qty', 'returned_qty', 'consumed_qty', + 'consumption_recorded', 'adjustment_qty', 'scrap_qty', 'status', @@ -47,6 +48,7 @@ protected function casts(): array 'expected_qty' => 'decimal:4', 'returned_qty' => 'decimal:4', 'consumed_qty' => 'decimal:4', + 'consumption_recorded' => 'boolean', 'adjustment_qty' => 'decimal:4', 'scrap_qty' => 'decimal:4', 'allocated_at' => 'datetime', diff --git a/backend/app/Models/MaterialReclassification.php b/backend/app/Models/MaterialReclassification.php new file mode 100644 index 00000000..90ee0779 --- /dev/null +++ b/backend/app/Models/MaterialReclassification.php @@ -0,0 +1,66 @@ + 'decimal:4', + 'performed_at' => 'datetime', + ]; + } + + public function sourceMaterial(): BelongsTo + { + return $this->belongsTo(Material::class, 'source_material_id'); + } + + public function targetMaterial(): BelongsTo + { + return $this->belongsTo(Material::class, 'target_material_id'); + } + + public function sourceLot(): BelongsTo + { + return $this->belongsTo(MaterialLot::class, 'source_lot_id'); + } + + public function performedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'performed_by'); + } +} diff --git a/backend/app/Models/StockMovement.php b/backend/app/Models/StockMovement.php index d76c6d98..b83a8eea 100644 --- a/backend/app/Models/StockMovement.php +++ b/backend/app/Models/StockMovement.php @@ -26,6 +26,10 @@ class StockMovement extends Model public const TYPE_TRANSFER = 'transfer'; + // Regrade of material between classes — paired legs on source (−) and target (+), + // both correlated by SOURCE_RECLASSIFICATION + the material_reclassifications id (#99). + public const TYPE_RECLASSIFY = 'reclassify'; + public const SOURCE_BATCH = 'batch'; public const SOURCE_BATCH_STEP = 'batch_step'; @@ -45,6 +49,9 @@ class StockMovement extends Model // Balance re-derived from an ERP stock snapshot (#212). public const SOURCE_ERP_SYNC = 'erp_sync'; + // Material reclassification (#99) — source_id is the material_reclassifications row. + public const SOURCE_RECLASSIFICATION = 'reclassification'; + protected $fillable = [ 'material_id', 'warehouse_id', diff --git a/backend/app/Models/WorkOrder.php b/backend/app/Models/WorkOrder.php index 0bee9e0f..fcc81cd5 100644 --- a/backend/app/Models/WorkOrder.php +++ b/backend/app/Models/WorkOrder.php @@ -483,6 +483,12 @@ public function batches(): HasMany return $this->hasMany(Batch::class)->orderBy('batch_number'); } + /** Material allocations pulled for this work order (#99 reconciliation). */ + public function allocations(): HasMany + { + return $this->hasMany(MaterialAllocation::class); + } + /** Pallets packed for this work order. */ public function pallets(): HasMany { diff --git a/backend/app/Services/Material/LotPickingService.php b/backend/app/Services/Material/LotPickingService.php index d5c2fc77..ff43a67a 100644 --- a/backend/app/Services/Material/LotPickingService.php +++ b/backend/app/Services/Material/LotPickingService.php @@ -219,6 +219,52 @@ public function returnPicksForAllocation(MaterialAllocation $allocation): void }); } + /** + * Return only part of an allocation's picked quantity to its lots (#99) — + * used when the operator hands back surplus mid-batch. Walks the picks + * newest-first (LIFO), restoring each lot's available quantity and reopening + * a depleted lot, then shrinks or removes the pick row so + * Σ picked_qty stays equal to the (now reduced) allocated_qty. + */ + public function returnPartialForAllocation(MaterialAllocation $allocation, float $qty): void + { + if ($qty <= 0 || ! $this->isLotTrackingEnabled()) { + return; + } + + DB::transaction(function () use ($allocation, $qty) { + $picks = $allocation->lotPicks()->with('lot')->lockForUpdate()->get()->reverse(); + $remaining = $qty; + + foreach ($picks as $pick) { + if ($remaining <= self::EPSILON) { + break; + } + $take = min($remaining, (float) $pick->picked_qty); + if ($take <= 0) { + continue; + } + + if ($pick->lot) { + $pick->lot->increment('quantity_available', $take); + if ($pick->lot->status === MaterialLot::STATUS_CONSUMED && (float) $pick->lot->fresh()->quantity_available > 0) { + $pick->lot->update(['status' => MaterialLot::STATUS_RELEASED]); + } + \App\Sync\CollectionBroadcaster::flush($pick->lot); // increment bypasses model events + } + + $newPicked = (float) $pick->picked_qty - $take; + if ($newPicked <= self::EPSILON) { + $pick->delete(); + } else { + $pick->update(['picked_qty' => $newPicked]); + } + + $remaining -= $take; + } + }); + } + public function isLotTrackingEnabled(): bool { try { diff --git a/backend/app/Services/Material/MaterialAllocationService.php b/backend/app/Services/Material/MaterialAllocationService.php index 1219a0f2..aae1541c 100644 --- a/backend/app/Services/Material/MaterialAllocationService.php +++ b/backend/app/Services/Material/MaterialAllocationService.php @@ -223,8 +223,10 @@ public function consumeForBatch(Batch $batch): void // flip to CONSUMED), so this never double-writes. $this->writeGenealogy($allocation); - // Default: operator did not record per-step consumption → assume planned. - $actualConsumed = (float) $allocation->consumed_qty > 0 + // Use the operator-declared quantity when consumption was recorded — + // including an explicit zero (nothing used, return everything). Only + // fall back to the planned quantity when nothing was ever declared. + $actualConsumed = $allocation->consumption_recorded ? (float) $allocation->consumed_qty : (float) $allocation->allocated_qty; @@ -318,6 +320,7 @@ public function recordConsumption( $allocation->update([ 'consumed_qty' => $actualConsumed, + 'consumption_recorded' => true, 'scrap_qty' => $scrap, // Snapshot the price so historical cost reports stay stable. 'unit_price_snapshot' => $actualConsumed > 0 ? $allocation->material?->unit_price : null, @@ -382,6 +385,75 @@ public function adjustAllocation( }); } + /** + * Return a leftover quantity from an in-flight allocation to stock (#99) — + * e.g. the operator over-issued and hands the surplus back before the batch + * completes. Books TYPE_RETURN, releases the reservation and restores the + * picked lots for the returned quantity. + * + * Crucially it DECREMENTS allocated_qty by the returned amount so the + * completion reconciler (consumeForBatch: leftover = allocated − consumed − + * scrap) never returns the same quantity a second time. + * + * @throws \DomainException|\InvalidArgumentException + */ + public function returnQuantity( + MaterialAllocation $allocation, + float $qty, + User $user, + ?string $reason = null, + ): MaterialAllocation { + if ($qty <= 0) { + throw new \InvalidArgumentException('Return quantity must be positive.'); + } + + return DB::transaction(function () use ($allocation, $qty, $user, $reason) { + // Lock and re-read inside the transaction so two concurrent returns can't + // both validate the same returnable quantity and over-return. + $allocation = MaterialAllocation::query()->lockForUpdate()->findOrFail($allocation->getKey()); + + if ($allocation->status !== MaterialAllocation::STATUS_ALLOCATED) { + throw new \DomainException('Can only return material from an `allocated` allocation.'); + } + + $returnable = (float) $allocation->allocated_qty + - (float) $allocation->consumed_qty + - (float) $allocation->scrap_qty; + if ($qty > $returnable + 1e-9) { + throw new \InvalidArgumentException('Return quantity exceeds the unconsumed allocated quantity.'); + } + + $material = $allocation->material; + + if (! $material) { + throw new \DomainException('Allocation has no associated material.'); + } + + $this->stockMovements->record( + $material, + StockMovement::TYPE_RETURN, + $qty, + user: $user, + sourceType: StockMovement::SOURCE_BATCH, + sourceId: $allocation->batch_id, + reason: $reason ?? 'Batch #'.$allocation->batch_id.' — unused material returned to stock', + ); + + $this->releaseReservation($material, $qty); + + // Lot tracking: hand the returned quantity back to the picked lots. + $this->lotPicking->returnPartialForAllocation($allocation, $qty); + + $allocation->update([ + // Shrink the allocation so consumeForBatch's leftover calc excludes what we just returned. + 'allocated_qty' => (float) $allocation->allocated_qty - $qty, + 'returned_qty' => (float) $allocation->returned_qty + $qty, + ]); + + return $allocation->fresh(); + }); + } + // ── internals ───────────────────────────────────────────────────────────── /** diff --git a/backend/app/Services/Material/MaterialReclassificationService.php b/backend/app/Services/Material/MaterialReclassificationService.php new file mode 100644 index 00000000..c5a266c8 --- /dev/null +++ b/backend/app/Services/Material/MaterialReclassificationService.php @@ -0,0 +1,169 @@ +id === $target->id) { + throw new \DomainException('Source and target material must differ.'); + } + if ($sourceLot) { + if ($sourceLot->material_id !== $source->id) { + throw new \DomainException('The lot does not belong to the source material.'); + } + if ($qty > (float) $sourceLot->quantity_available + 1e-9) { + throw new \InvalidArgumentException('Reclassification quantity exceeds the lot available quantity.'); + } + } + + return DB::transaction(function () use ($source, $target, $qty, $by, $sourceLot, $reason) { + $record = MaterialReclassification::create([ + 'type' => MaterialReclassification::TYPE_CLASS, + 'source_material_id' => $source->id, + 'target_material_id' => $target->id, + 'source_lot_id' => $sourceLot?->id, + 'quantity' => $qty, + 'reason' => $reason, + 'performed_by' => $by->id, + 'performed_at' => now(), + ]); + + $note = $reason ?? 'Reclassified '.$source->code.' → '.$target->code; + + $this->stockMovements->record( + $source, + StockMovement::TYPE_RECLASSIFY, + -$qty, + user: $by, + sourceType: StockMovement::SOURCE_RECLASSIFICATION, + sourceId: $record->id, + reason: $note, + ); + $this->stockMovements->record( + $target, + StockMovement::TYPE_RECLASSIFY, + $qty, + user: $by, + sourceType: StockMovement::SOURCE_RECLASSIFICATION, + sourceId: $record->id, + reason: $note, + ); + + if ($sourceLot) { + $sourceLot->consume($qty); // decrements available; flips to CONSUMED when empty + } + + return $record->fresh(); + }); + } + + /** + * Change a lot's status. released↔quarantine reuse the manual hold/release + * path (no stock delta). A move to `rejected` scraps the lot's remaining + * available quantity out of stock and zeroes it. + * + * @throws \DomainException|\InvalidArgumentException + */ + public function reclassifyStatus( + MaterialLot $lot, + string $toStatus, + User $by, + ?string $reason = null, + ): MaterialReclassification { + if (! in_array($toStatus, [MaterialLot::STATUS_RELEASED, MaterialLot::STATUS_QUARANTINE, MaterialLot::STATUS_REJECTED], true)) { + throw new \InvalidArgumentException("Unsupported target status: {$toStatus}."); + } + + return DB::transaction(function () use ($lot, $toStatus, $by, $reason) { + // Lock and re-read so two concurrent rejects can't both scrap the same + // available quantity and double-decrement stock. + $lot = MaterialLot::query()->lockForUpdate()->findOrFail($lot->getKey()); + $fromStatus = $lot->status; + + // Create the audit row first so the reject scrap movement can point at it. + $record = MaterialReclassification::create([ + 'type' => MaterialReclassification::TYPE_STATUS, + 'source_material_id' => $lot->material_id, + 'source_lot_id' => $lot->id, + 'from_status' => $fromStatus, + 'to_status' => $toStatus, + 'reason' => $reason, + 'performed_by' => $by->id, + 'performed_at' => now(), + ]); + + switch ($toStatus) { + case MaterialLot::STATUS_QUARANTINE: + $this->holds->hold($lot, $reason ?? '', $by); + break; + + case MaterialLot::STATUS_RELEASED: + $this->holds->release($lot, $by); + break; + + case MaterialLot::STATUS_REJECTED: + if (in_array($lot->status, [MaterialLot::STATUS_CONSUMED, MaterialLot::STATUS_REJECTED], true)) { + throw new \DomainException("Cannot reject a {$lot->status} lot."); + } + $remaining = (float) $lot->quantity_available; + if ($remaining > 0 && $lot->material) { + $this->stockMovements->record( + $lot->material, + StockMovement::TYPE_SCRAP, + -$remaining, + user: $by, + sourceType: StockMovement::SOURCE_RECLASSIFICATION, + sourceId: $record->id, + reason: $reason ?? 'Lot '.$lot->lot_number.' rejected — scrapped from stock', + ); + } + $lot->update([ + 'status' => MaterialLot::STATUS_REJECTED, + 'quantity_available' => 0, + ]); + break; + } + + return $record->fresh(); + }); + } +} diff --git a/backend/database/migrations/2026_08_12_100000_create_material_reclassifications_table.php b/backend/database/migrations/2026_08_12_100000_create_material_reclassifications_table.php new file mode 100644 index 00000000..bed8e8b7 --- /dev/null +++ b/backend/database/migrations/2026_08_12_100000_create_material_reclassifications_table.php @@ -0,0 +1,56 @@ +id(); + + // 'class' = quantity moved between materials; 'status' = lot status change. + $table->string('type', 20); + + // Preserve the audit row if the source material is ever hard-deleted. + $table->foreignId('source_material_id')->constrained('materials')->restrictOnDelete(); + $table->foreignId('target_material_id')->nullable()->constrained('materials')->nullOnDelete(); + $table->foreignId('source_lot_id')->nullable()->constrained('material_lots')->nullOnDelete(); + + // Null for a status-only change. + $table->decimal('quantity', 14, 4)->nullable(); + + // Lot status transition (null for a class move). + $table->string('from_status', 20)->nullable(); + $table->string('to_status', 20)->nullable(); + + $table->text('reason')->nullable(); + + $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('performed_at'); + + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->index(['source_material_id', 'performed_at']); + $table->index(['source_lot_id', 'performed_at']); + $table->index(['type', 'performed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('material_reclassifications'); + } +}; diff --git a/backend/database/migrations/2026_08_12_100001_add_consumption_recorded_to_material_allocations.php b/backend/database/migrations/2026_08_12_100001_add_consumption_recorded_to_material_allocations.php new file mode 100644 index 00000000..2c82410c --- /dev/null +++ b/backend/database/migrations/2026_08_12_100001_add_consumption_recorded_to_material_allocations.php @@ -0,0 +1,29 @@ +boolean('consumption_recorded')->default(false)->after('consumed_qty'); + }); + } + + public function down(): void + { + Schema::table('material_allocations', function (Blueprint $table) { + $table->dropColumn('consumption_recorded'); + }); + } +}; diff --git a/backend/lang/en.json b/backend/lang/en.json index 6ca3bdf7..b1a47f86 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -5189,5 +5189,26 @@ "Assign workstation": "Assign workstation", "required type": "required type", "No active workstation of the required type is available.": "No active workstation of the required type is available.", - "The workstation could not be assigned.": "The workstation could not be assigned." + "The workstation could not be assigned.": "The workstation could not be assigned.", + "Materials reconciliation": "Materials reconciliation", + "Record what was actually consumed, return leftovers to stock, or reclassify material.": "Record what was actually consumed, return leftovers to stock, or reclassify material.", + "Allocated": "Allocated", + "Returned": "Returned", + "Return": "Return", + "Reclassify": "Reclassify", + "Record consumption": "Record consumption", + "Allocated: :qty": "Allocated: :qty", + "Consumed quantity": "Consumed quantity", + "Return to stock": "Return to stock", + "Returnable: :qty": "Returnable: :qty", + "Quantity to return": "Quantity to return", + "Reclassify material": "Reclassify material", + "Target class (material)": "Target class (material)", + "Select a material": "Select a material", + "allocated": "allocated", + "consumed": "consumed", + "returned": "returned", + "Material returned to stock": "Material returned to stock", + "Material reclassified": "Material reclassified", + "Work order :code created.": "Work order :code created." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 09973010..121d165c 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -5189,5 +5189,26 @@ "Assign workstation": "Przypisz stanowisko", "required type": "wymagany typ", "No active workstation of the required type is available.": "Brak dostępnego aktywnego stanowiska wymaganego typu.", - "The workstation could not be assigned.": "Nie udało się przypisać stanowiska." + "The workstation could not be assigned.": "Nie udało się przypisać stanowiska.", + "Materials reconciliation": "Rozliczenie materiałów", + "Record what was actually consumed, return leftovers to stock, or reclassify material.": "Zapisz faktyczne zużycie, zwróć nadmiar do magazynu lub przeklasyfikuj materiał.", + "Allocated": "Przydzielono", + "Returned": "Zwrócono", + "Return": "Zwróć", + "Reclassify": "Przeklasyfikuj", + "Record consumption": "Zapisz zużycie", + "Allocated: :qty": "Przydzielono: :qty", + "Consumed quantity": "Zużyta ilość", + "Return to stock": "Zwróć do magazynu", + "Returnable: :qty": "Do zwrotu: :qty", + "Quantity to return": "Ilość do zwrotu", + "Reclassify material": "Przeklasyfikuj materiał", + "Target class (material)": "Klasa docelowa (materiał)", + "Select a material": "Wybierz materiał", + "allocated": "przydzielony", + "consumed": "zużyty", + "returned": "zwrócony", + "Material returned to stock": "Materiał zwrócony do magazynu", + "Material reclassified": "Materiał przeklasyfikowany", + "Work order :code created.": "Zlecenie :code utworzone." } diff --git a/backend/resources/js/Pages/admin/work-orders/Show.jsx b/backend/resources/js/Pages/admin/work-orders/Show.jsx index 091845b2..37b37210 100644 --- a/backend/resources/js/Pages/admin/work-orders/Show.jsx +++ b/backend/resources/js/Pages/admin/work-orders/Show.jsx @@ -182,10 +182,250 @@ function DoneModal({ workOrder, onClose }) { ); } +const ALLOC_STATUS_STYLES = { + allocated: 'bg-om-chip text-om-accent', + consumed: 'bg-om-running-bg text-om-running', + returned: 'bg-om-chip text-om-faint', +}; + +// Materials reconciliation panel (#99): per-allocation record-consumption, return +// leftover and reclassify actions against the work order's pulled materials. +function MaterialsReconciliation({ workOrder, allocations, canReclassify, materials }) { + const [modal, setModal] = useState(null); // { kind: 'consume'|'return'|'reclassify', alloc } + + return ( +
+

+ {__('Materials reconciliation')}{' '} + ({allocations.length}) +

+

+ {__('Record what was actually consumed, return leftovers to stock, or reclassify material.')} +

+
+ + + + + + + + + + + + + + {allocations.map((a) => { + const open = a.status === 'allocated'; + return ( + + + + + + + + + + ); + })} + +
{__('Material')}{__('Allocated')}{__('Consumed')}{__('Returned')}{__('Scrap')}{__('Status')}{__('Actions')}
+ {a.material_code} + · {a.material_name} + {fmtQty(a.allocated_qty)}{fmtQty(a.consumed_qty)}{fmtQty(a.returned_qty)}{fmtQty(a.scrap_qty)} + + {__(a.status)} + + + {open && ( + <> + + · + + {canReclassify && ( + <> + · + + + )} + + )} +
+
+ + {modal?.kind === 'consume' && ( + setModal(null)} /> + )} + {modal?.kind === 'return' && ( + setModal(null)} /> + )} + {modal?.kind === 'reclassify' && ( + setModal(null)} /> + )} +
+ ); +} + +function ModalFrame({ title, children }) { + return ( +
+
+

{title}

+ {children} +
+
+ ); +} + +const fieldCls = 'w-full border border-om-line rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-om-accent'; +const labelCls = 'block text-sm font-medium text-om-muted mb-1'; + +function ModalActions({ onClose, submitLabel, disabled }) { + return ( +
+ + +
+ ); +} + +function ConsumeModal({ workOrder, alloc, onClose }) { + const [consumed, setConsumed] = useState(String(alloc.consumed_qty || '')); + const [scrap, setScrap] = useState(String(alloc.scrap_qty || '')); + const [processing, setProcessing] = useState(false); + + function submit(e) { + e.preventDefault(); + if (processing) return; + setProcessing(true); + router.post(`/admin/work-orders/${workOrder.id}/allocations/${alloc.id}/consume`, + { consumed_qty: consumed, scrap_qty: scrap || 0 }, + { preserveScroll: true, onSuccess: onClose, onFinish: () => setProcessing(false) }); + } + + return ( + +
+

+ {__('Allocated: :qty', { qty: fmtQty(alloc.allocated_qty) })} {alloc.unit_of_measure} +

+
+ + setConsumed(e.target.value)} className={fieldCls} required /> +
+
+ + setScrap(e.target.value)} className={fieldCls} /> +
+ + +
+ ); +} + +function ReturnModal({ workOrder, alloc, onClose }) { + const returnable = Math.max(0, alloc.allocated_qty - alloc.consumed_qty - alloc.scrap_qty); + const [qty, setQty] = useState(''); + const [reason, setReason] = useState(''); + const [processing, setProcessing] = useState(false); + + function submit(e) { + e.preventDefault(); + if (processing) return; + setProcessing(true); + router.post(`/admin/work-orders/${workOrder.id}/allocations/${alloc.id}/return`, + { qty, reason }, + { preserveScroll: true, onSuccess: onClose, onFinish: () => setProcessing(false) }); + } + + return ( + +
+

+ {__('Returnable: :qty', { qty: fmtQty(returnable) })} {alloc.unit_of_measure} +

+
+ + setQty(e.target.value)} className={fieldCls} required /> +
+
+ + setReason(e.target.value)} className={fieldCls} /> +
+ + +
+ ); +} + +function ReclassifyModal({ workOrder, alloc, materials, onClose }) { + const [target, setTarget] = useState(''); + const [qty, setQty] = useState(''); + const [reason, setReason] = useState(''); + const [processing, setProcessing] = useState(false); + + function submit(e) { + e.preventDefault(); + if (processing) return; + setProcessing(true); + router.post(`/admin/work-orders/${workOrder.id}/reclassify`, + { source_material_id: alloc.material_id, target_material_id: target, qty, reason }, + { preserveScroll: true, onSuccess: onClose, onFinish: () => setProcessing(false) }); + } + + const targets = materials.filter((m) => m.id !== alloc.material_id); + + return ( + +
+

+ {__('From')} {alloc.material_code} +

+
+ + +
+
+ + setQty(e.target.value)} className={fieldCls} required /> +
+
+ + setReason(e.target.value)} className={fieldCls} /> +
+ + +
+ ); +} + export default function AdminWorkOrderShow() { const { workOrder, customFields = [], stops = [], changeRequests = [], changeControl = {}, + canReclassify = false, materials = [], } = usePage().props; const [showDoneModal, setShowDoneModal] = useState(false); const [showStopModal, setShowStopModal] = useState(false); @@ -470,6 +710,16 @@ export default function AdminWorkOrderShow() { )} + {/* Materials reconciliation (#99) */} + {workOrder.allocations && workOrder.allocations.length > 0 && ( + + )} + {/* Change requests (#182) */} {changeRequests.length > 0 && (
diff --git a/backend/routes/api.php b/backend/routes/api.php index cf60fccc..0bed7c13 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -703,6 +703,16 @@ Route::middleware('role:Supervisor|Admin') ->post('/batch-steps/{batchStep}/assign', [\App\Http\Controllers\Api\V1\BatchStepController::class, 'assign']); + // Material reconciliation (#99): declare partial consumption and return unused + // material to stock against a work-order allocation (production users). + Route::post('/material-allocations/{allocation}/consume', [\App\Http\Controllers\Api\V1\MaterialAllocationController::class, 'consume']); + Route::post('/material-allocations/{allocation}/return', [\App\Http\Controllers\Api\V1\MaterialAllocationController::class, 'return']); + // Reclassification (#99): regrade between material classes / change a lot status. + Route::middleware('role:Supervisor|Admin')->group(function () { + Route::post('/material-reclassifications/class', [\App\Http\Controllers\Api\V1\MaterialReclassificationController::class, 'class']); + Route::post('/material-lots/{materialLot}/reclassify-status', [\App\Http\Controllers\Api\V1\MaterialReclassificationController::class, 'status']); + }); + // Process Confirmations (per batch) Route::get('/batches/{batch}/confirmations', [ProcessConfirmationController::class, 'index']); Route::post('/batches/{batch}/confirmations', [ProcessConfirmationController::class, 'store']); diff --git a/backend/routes/web.php b/backend/routes/web.php index 4be43a7d..a7fd7ff2 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -424,6 +424,15 @@ Route::post('/work-orders/{workOrder}/reopen', [AdminWorkOrderController::class, 'reopen'])->name('work-orders.reopen'); Route::post('/work-orders/{workOrder}/complete', [AdminWorkOrderController::class, 'complete'])->name('work-orders.complete'); + // Materials reconciliation (#99) — declare consumption, return leftovers, + // reclassify a quantity to another class. All three move stock, so all three + // are gated to Supervisor|Admin (the operator path is the API endpoints). + Route::middleware('role:Supervisor|Admin')->group(function () { + Route::post('/work-orders/{workOrder}/allocations/{allocation}/consume', [AdminWorkOrderController::class, 'recordConsumption'])->name('work-orders.allocations.consume'); + Route::post('/work-orders/{workOrder}/allocations/{allocation}/return', [AdminWorkOrderController::class, 'returnAllocation'])->name('work-orders.allocations.return'); + Route::post('/work-orders/{workOrder}/reclassify', [AdminWorkOrderController::class, 'reclassify'])->name('work-orders.reclassify'); + }); + // Change control (#182) — structured stop, change request and its review. Route::post('/work-orders/{workOrder}/stop', [WorkOrderChangeControlController::class, 'stop']) ->name('work-orders.stop'); diff --git a/backend/tests/Feature/Api/V1/ReclassifyClassTest.php b/backend/tests/Feature/Api/V1/ReclassifyClassTest.php new file mode 100644 index 00000000..7581964e --- /dev/null +++ b/backend/tests/Feature/Api/V1/ReclassifyClassTest.php @@ -0,0 +1,137 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $gradeA = MaterialType::create(['code' => 'GRADE-A', 'name' => 'Grade A']); + $gradeB = MaterialType::create(['code' => 'GRADE-B', 'name' => 'Grade B']); + $this->source = Material::create([ + 'code' => 'STEEL-A', 'name' => 'Steel A', 'material_type_id' => $gradeA->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 500, + ]); + $this->target = Material::create([ + 'code' => 'STEEL-B', 'name' => 'Steel B', 'material_type_id' => $gradeB->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 0, + ]); + } + + private function user(string $role): User + { + return tap(User::factory()->create(), fn ($u) => $u->assignRole($role)); + } + + private function submit(User $u, array $body) + { + return $this->withHeader('Authorization', 'Bearer '.$u->createToken('t')->plainTextToken) + ->postJson('/api/v1/material-reclassifications/class', $body); + } + + public function test_supervisor_reclassifies_between_classes(): void + { + $this->submit($this->user('Supervisor'), [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->target->id, + 'qty' => 40, + ])->assertOk(); + + $this->assertEqualsWithDelta(460.0, (float) $this->source->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(40.0, (float) $this->target->fresh()->stock_quantity, 0.0001); + + $record = MaterialReclassification::firstWhere('type', MaterialReclassification::TYPE_CLASS); + $this->assertNotNull($record); + + $legs = StockMovement::where('source_type', StockMovement::SOURCE_RECLASSIFICATION) + ->where('source_id', $record->id) + ->where('movement_type', StockMovement::TYPE_RECLASSIFY) + ->get(); + $this->assertCount(2, $legs); + $this->assertEqualsWithDelta(-40.0, (float) $legs->firstWhere('material_id', $this->source->id)->quantity, 0.0001); + $this->assertEqualsWithDelta(40.0, (float) $legs->firstWhere('material_id', $this->target->id)->quantity, 0.0001); + } + + public function test_reclassify_with_source_lot_decrements_the_lot(): void + { + $lot = MaterialLot::factory()->create([ + 'material_id' => $this->source->id, 'status' => MaterialLot::STATUS_RELEASED, + 'quantity_received' => 100, 'quantity_available' => 100, + ]); + + $this->submit($this->user('Supervisor'), [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->target->id, + 'qty' => 40, + 'source_lot_id' => $lot->id, + ])->assertOk(); + + $this->assertEqualsWithDelta(60.0, (float) $lot->fresh()->quantity_available, 0.0001); + } + + public function test_same_source_and_target_is_rejected(): void + { + $this->submit($this->user('Supervisor'), [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->source->id, + 'qty' => 10, + ])->assertStatus(422)->assertJsonValidationErrors('target_material_id'); + } + + public function test_lot_not_belonging_to_source_is_rejected(): void + { + $otherLot = MaterialLot::factory()->create([ + 'material_id' => $this->target->id, 'status' => MaterialLot::STATUS_RELEASED, + 'quantity_received' => 50, 'quantity_available' => 50, + ]); + + $this->submit($this->user('Supervisor'), [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->target->id, + 'qty' => 10, + 'source_lot_id' => $otherLot->id, + ])->assertStatus(422); + + $this->assertEqualsWithDelta(500.0, (float) $this->source->fresh()->stock_quantity, 0.0001); + } + + public function test_operator_cannot_reclassify(): void + { + $this->submit($this->user('Operator'), [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->target->id, + 'qty' => 10, + ])->assertForbidden(); + } + + public function test_guest_cannot_reclassify(): void + { + $this->postJson('/api/v1/material-reclassifications/class', [ + 'source_material_id' => $this->source->id, + 'target_material_id' => $this->target->id, + 'qty' => 10, + ])->assertUnauthorized(); + } +} diff --git a/backend/tests/Feature/Api/V1/ReclassifyLotStatusTest.php b/backend/tests/Feature/Api/V1/ReclassifyLotStatusTest.php new file mode 100644 index 00000000..cf072b18 --- /dev/null +++ b/backend/tests/Feature/Api/V1/ReclassifyLotStatusTest.php @@ -0,0 +1,133 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $type = MaterialType::create(['code' => 'RAW', 'name' => 'Raw']); + $this->material = Material::create([ + 'code' => 'M1', 'name' => 'Material 1', 'material_type_id' => $type->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 500, + ]); + } + + private function lot(string $status = MaterialLot::STATUS_RELEASED, float $available = 100): MaterialLot + { + return MaterialLot::factory()->create([ + 'material_id' => $this->material->id, 'status' => $status, + 'quantity_received' => 100, 'quantity_available' => $available, + ]); + } + + private function submit(MaterialLot $lot, array $body, string $role = 'Supervisor') + { + $u = tap(User::factory()->create(), fn ($x) => $x->assignRole($role)); + + return $this->withHeader('Authorization', 'Bearer '.$u->createToken('t')->plainTextToken) + ->postJson("/api/v1/material-lots/{$lot->id}/reclassify-status", $body); + } + + public function test_released_to_quarantine_holds_without_stock_delta(): void + { + $lot = $this->lot(); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_QUARANTINE, 'reason' => 'Suspect batch']) + ->assertOk(); + + $lot->refresh(); + $this->assertSame(MaterialLot::STATUS_QUARANTINE, $lot->status); + $this->assertNotNull($lot->held_at); + $this->assertEqualsWithDelta(500.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertSame(0, StockMovement::forMaterial($this->material->id)->count()); + + $record = MaterialReclassification::firstWhere('type', MaterialReclassification::TYPE_STATUS); + $this->assertSame(MaterialLot::STATUS_RELEASED, $record->from_status); + $this->assertSame(MaterialLot::STATUS_QUARANTINE, $record->to_status); + } + + public function test_quarantine_to_released(): void + { + $lot = $this->lot(MaterialLot::STATUS_QUARANTINE); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_RELEASED])->assertOk(); + + $this->assertSame(MaterialLot::STATUS_RELEASED, $lot->fresh()->status); + $this->assertSame(0, StockMovement::forMaterial($this->material->id)->count()); + } + + public function test_reject_scraps_remaining_quantity_from_stock(): void + { + $lot = $this->lot(MaterialLot::STATUS_RELEASED, 100); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_REJECTED, 'reason' => 'Failed inspection']) + ->assertOk(); + + $lot->refresh(); + $this->assertSame(MaterialLot::STATUS_REJECTED, $lot->status); + $this->assertEqualsWithDelta(0.0, (float) $lot->quantity_available, 0.0001); + // The 100 remaining left stock as scrap. + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + + $scrap = StockMovement::forMaterial($this->material->id) + ->where('movement_type', StockMovement::TYPE_SCRAP)->first(); + $this->assertEqualsWithDelta(-100.0, (float) $scrap->quantity, 0.0001); + $this->assertSame(StockMovement::SOURCE_RECLASSIFICATION, $scrap->source_type); + } + + public function test_rejecting_a_consumed_lot_is_rejected(): void + { + $lot = $this->lot(MaterialLot::STATUS_CONSUMED, 0); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_REJECTED, 'reason' => 'x']) + ->assertStatus(422); + } + + public function test_missing_reason_for_quarantine_is_rejected(): void + { + $lot = $this->lot(); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_QUARANTINE]) + ->assertStatus(422) + ->assertJsonValidationErrors('reason'); + } + + public function test_operator_cannot_change_lot_status(): void + { + $lot = $this->lot(); + + $this->submit($lot, ['to_status' => MaterialLot::STATUS_QUARANTINE, 'reason' => 'x'], 'Operator') + ->assertForbidden(); + } + + public function test_guest_cannot_change_lot_status(): void + { + $lot = $this->lot(); + + $this->postJson("/api/v1/material-lots/{$lot->id}/reclassify-status", [ + 'to_status' => MaterialLot::STATUS_QUARANTINE, 'reason' => 'x', + ])->assertUnauthorized(); + } +} diff --git a/backend/tests/Feature/Api/V1/RecordConsumptionTest.php b/backend/tests/Feature/Api/V1/RecordConsumptionTest.php new file mode 100644 index 00000000..5ec79aa2 --- /dev/null +++ b/backend/tests/Feature/Api/V1/RecordConsumptionTest.php @@ -0,0 +1,140 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $type = MaterialType::create(['code' => 'RAW', 'name' => 'Raw']); + $this->material = Material::create([ + 'code' => 'M1', 'name' => 'Material 1', 'material_type_id' => $type->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 500, + ]); + + $wo = WorkOrder::factory()->create([ + 'product_type_id' => ProductType::factory()->create()->id, + 'process_snapshot' => ['bom' => [[ + 'material_id' => $this->material->id, 'material_code' => 'M1', 'material_name' => 'Material 1', + 'unit_of_measure' => 'kg', 'quantity_per_unit' => 1.0, 'scrap_percentage' => 0, + ]]], + ]); + $this->batch = Batch::factory()->create([ + 'work_order_id' => $wo->id, 'target_qty' => 100, 'produced_qty' => 0, 'status' => Batch::STATUS_PENDING, + ]); + + app(MaterialAllocationService::class)->allocateForBatch($this->batch, $this->admin()); + $this->allocation = MaterialAllocation::firstWhere('batch_id', $this->batch->id); + } + + private function admin(): User + { + return once(fn () => tap(User::factory()->create(), fn ($u) => $u->assignRole('Admin'))); + } + + private function token(User $u): string + { + return $u->createToken('test')->plainTextToken; + } + + public function test_records_partial_consumption_and_returns_leftover_at_completion(): void + { + // Allocation pulled 100 from 500. + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + + $this->withHeader('Authorization', 'Bearer '.$this->token($this->admin())) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => 60]) + ->assertOk(); + + $this->allocation->refresh(); + $this->assertEqualsWithDelta(60.0, (float) $this->allocation->consumed_qty, 0.0001); + $this->assertSame(MaterialAllocation::STATUS_ALLOCATED, $this->allocation->status); + // Declaring consumption books no stock delta on its own. + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + + // Completion reconciler returns the 40 leftover. + app(MaterialAllocationService::class)->consumeForBatch($this->batch); + $this->assertEqualsWithDelta(440.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_recorded_zero_consumption_returns_the_full_allocation(): void + { + // Explicitly recording zero usage must mean "nothing consumed" — the whole + // allocation returns to stock — NOT the unrecorded fallback (consume all). + $this->withHeader('Authorization', 'Bearer '.$this->token($this->admin())) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => 0]) + ->assertOk(); + + $this->assertTrue((bool) $this->allocation->fresh()->consumption_recorded); + + app(MaterialAllocationService::class)->consumeForBatch($this->batch); + $this->assertEqualsWithDelta(500.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_unrecorded_consumption_falls_back_to_planned_at_completion(): void + { + // No declaration at all → consumeForBatch assumes the planned qty was used. + app(MaterialAllocationService::class)->consumeForBatch($this->batch); + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_negative_consumed_qty_is_rejected(): void + { + $this->withHeader('Authorization', 'Bearer '.$this->token($this->admin())) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => -5]) + ->assertStatus(422) + ->assertJsonValidationErrors('consumed_qty'); + } + + public function test_recording_on_a_non_allocated_row_is_rejected(): void + { + $this->allocation->update(['status' => MaterialAllocation::STATUS_CONSUMED]); + + $this->withHeader('Authorization', 'Bearer '.$this->token($this->admin())) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => 10]) + ->assertStatus(422); + } + + public function test_guest_cannot_record_consumption(): void + { + $this->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => 10]) + ->assertUnauthorized(); + } + + public function test_operator_outside_the_line_cannot_record_consumption(): void + { + // An operator not assigned to this order's line fails the WorkOrder view policy. + $operator = tap(User::factory()->create(), fn ($u) => $u->assignRole('Operator')); + + $this->withHeader('Authorization', 'Bearer '.$this->token($operator)) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/consume", ['consumed_qty' => 10]) + ->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Api/V1/ReturnAllocationTest.php b/backend/tests/Feature/Api/V1/ReturnAllocationTest.php new file mode 100644 index 00000000..0f89a55c --- /dev/null +++ b/backend/tests/Feature/Api/V1/ReturnAllocationTest.php @@ -0,0 +1,154 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $type = MaterialType::create(['code' => 'RAW', 'name' => 'Raw']); + $this->material = Material::create([ + 'code' => 'M1', 'name' => 'Material 1', 'material_type_id' => $type->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 500, + ]); + + $wo = WorkOrder::factory()->create([ + 'product_type_id' => ProductType::factory()->create()->id, + 'process_snapshot' => ['bom' => [[ + 'material_id' => $this->material->id, 'material_code' => 'M1', 'material_name' => 'Material 1', + 'unit_of_measure' => 'kg', 'quantity_per_unit' => 1.0, 'scrap_percentage' => 0, + ]]], + ]); + $this->batch = Batch::factory()->create([ + 'work_order_id' => $wo->id, 'target_qty' => 100, 'produced_qty' => 0, 'status' => Batch::STATUS_PENDING, + ]); + + app(MaterialAllocationService::class)->allocateForBatch($this->batch, $this->admin()); + $this->allocation = MaterialAllocation::firstWhere('batch_id', $this->batch->id); + // Allocation pulled 100 from 500 and reserved 100. + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(100.0, (float) $this->material->fresh()->reserved_quantity, 0.0001); + } + + private function admin(): User + { + return once(fn () => tap(User::factory()->create(), fn ($u) => $u->assignRole('Admin'))); + } + + private function submit(string $path, array $body = []) + { + return $this->withHeader('Authorization', 'Bearer '.$this->admin()->createToken('t')->plainTextToken) + ->postJson($path, $body); + } + + public function test_partial_return_adjusts_stock_reserved_allocated_and_ledger(): void + { + $this->submit("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 30]) + ->assertOk(); + + $this->assertEqualsWithDelta(430.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(70.0, (float) $this->material->fresh()->reserved_quantity, 0.0001); + + $this->allocation->refresh(); + $this->assertEqualsWithDelta(70.0, (float) $this->allocation->allocated_qty, 0.0001); + $this->assertEqualsWithDelta(30.0, (float) $this->allocation->returned_qty, 0.0001); + $this->assertSame(MaterialAllocation::STATUS_ALLOCATED, $this->allocation->status); + + $returns = StockMovement::forMaterial($this->material->id) + ->where('movement_type', StockMovement::TYPE_RETURN)->get(); + $this->assertCount(1, $returns); + $this->assertEqualsWithDelta(30.0, (float) $returns->first()->quantity, 0.0001); + } + + public function test_return_then_completion_does_not_double_return(): void + { + // Consume 50, then explicitly return the 50 leftover before completion. + $svc = app(MaterialAllocationService::class); + $svc->recordConsumption($this->allocation, 50); + $this->submit("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 50])->assertOk(); + + // After the return: stock 400→450, allocated 100→50, reserved 100→50. + $this->assertEqualsWithDelta(450.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + + // Completion must NOT return the 50 again (allocated is now 50 == consumed). + $svc->consumeForBatch($this->batch); + + $this->assertEqualsWithDelta(450.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(0.0, (float) $this->material->fresh()->reserved_quantity, 0.0001); + + // Exactly one return of 50 across both events — no double count, reserved never negative. + $totalReturned = (float) StockMovement::forMaterial($this->material->id) + ->where('movement_type', StockMovement::TYPE_RETURN)->sum('quantity'); + $this->assertEqualsWithDelta(50.0, $totalReturned, 0.0001); + } + + public function test_full_return_leaves_no_leftover_at_completion(): void + { + $this->submit("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 100])->assertOk(); + $this->assertEqualsWithDelta(0.0, (float) $this->allocation->fresh()->allocated_qty, 0.0001); + + app(MaterialAllocationService::class)->consumeForBatch($this->batch); + // Exactly one return movement (the explicit 100); completion adds none. + $this->assertCount(1, StockMovement::forMaterial($this->material->id) + ->where('movement_type', StockMovement::TYPE_RETURN)->get()); + $this->assertEqualsWithDelta(500.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_returning_more_than_unconsumed_is_rejected(): void + { + $this->submit("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 150]) + ->assertStatus(422) + ->assertJsonValidationErrors('qty'); + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_zero_quantity_is_rejected(): void + { + $this->submit("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 0]) + ->assertStatus(422) + ->assertJsonValidationErrors('qty'); + } + + public function test_guest_cannot_return(): void + { + $this->postJson("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 10]) + ->assertUnauthorized(); + } + + public function test_operator_outside_the_line_cannot_return(): void + { + $operator = tap(User::factory()->create(), fn ($u) => $u->assignRole('Operator')); + + $this->withHeader('Authorization', 'Bearer '.$operator->createToken('t')->plainTextToken) + ->postJson("/api/v1/material-allocations/{$this->allocation->id}/return", ['qty' => 10]) + ->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Material/PartialLotReturnTest.php b/backend/tests/Feature/Material/PartialLotReturnTest.php new file mode 100644 index 00000000..f79b4527 --- /dev/null +++ b/backend/tests/Feature/Material/PartialLotReturnTest.php @@ -0,0 +1,75 @@ +updateOrInsert( + ['key' => 'lot_tracking_enabled'], + ['value' => json_encode(true)], + ); + + $type = MaterialType::create(['code' => 'RAW', 'name' => 'Raw']); + $material = Material::create([ + 'code' => 'M1', 'name' => 'Material 1', 'material_type_id' => $type->id, + 'unit_of_measure' => 'kg', 'tracking_type' => 'batch', 'stock_quantity' => 0, + ]); + + // Two lots fully drawn down by the allocation (available 0, CONSUMED). + $lotA = MaterialLot::factory()->create([ + 'material_id' => $material->id, 'lot_number' => 'A', + 'quantity_received' => 40, 'quantity_available' => 0, 'status' => MaterialLot::STATUS_CONSUMED, + ]); + $lotB = MaterialLot::factory()->create([ + 'material_id' => $material->id, 'lot_number' => 'B', + 'quantity_received' => 60, 'quantity_available' => 0, 'status' => MaterialLot::STATUS_CONSUMED, + ]); + + $wo = WorkOrder::factory()->create(['product_type_id' => ProductType::factory()->create()->id]); + $batch = Batch::factory()->create(['work_order_id' => $wo->id, 'target_qty' => 100, 'status' => Batch::STATUS_IN_PROGRESS]); + $allocation = MaterialAllocation::create([ + 'batch_id' => $batch->id, 'work_order_id' => $wo->id, 'material_id' => $material->id, + 'allocated_qty' => 100, 'status' => MaterialAllocation::STATUS_ALLOCATED, + 'allocated_at' => now(), + ]); + // Pick A first (id lower), then B (id higher) → reverse walk hits B first. + $pickA = AllocationLotPick::create(['material_allocation_id' => $allocation->id, 'material_lot_id' => $lotA->id, 'picked_qty' => 40, 'picking_strategy' => 'fifo']); + $pickB = AllocationLotPick::create(['material_allocation_id' => $allocation->id, 'material_lot_id' => $lotB->id, 'picked_qty' => 60, 'picking_strategy' => 'fifo']); + + app(LotPickingService::class)->returnPartialForAllocation($allocation, 70); + + // 70 returned newest-first: B gets its full 60 back (pick deleted, reopened), + // then A gets the remaining 10 (pick reduced to 30, reopened). + $lotB->refresh(); + $this->assertEqualsWithDelta(60.0, (float) $lotB->quantity_available, 0.0001); + $this->assertSame(MaterialLot::STATUS_RELEASED, $lotB->status); + $this->assertNull(AllocationLotPick::find($pickB->id)); + + $lotA->refresh(); + $this->assertEqualsWithDelta(10.0, (float) $lotA->quantity_available, 0.0001); + $this->assertSame(MaterialLot::STATUS_RELEASED, $lotA->status); + $this->assertEqualsWithDelta(30.0, (float) AllocationLotPick::find($pickA->id)->picked_qty, 0.0001); + } +} diff --git a/backend/tests/Feature/Web/Admin/WorkOrderMaterialReconciliationTest.php b/backend/tests/Feature/Web/Admin/WorkOrderMaterialReconciliationTest.php new file mode 100644 index 00000000..a0be8595 --- /dev/null +++ b/backend/tests/Feature/Web/Admin/WorkOrderMaterialReconciliationTest.php @@ -0,0 +1,154 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $type = MaterialType::create(['code' => 'RAW', 'name' => 'Raw']); + $this->material = Material::create([ + 'code' => 'M1', 'name' => 'Material 1', 'material_type_id' => $type->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 500, + ]); + + $this->workOrder = WorkOrder::factory()->create([ + 'product_type_id' => ProductType::factory()->create()->id, + 'process_snapshot' => ['bom' => [[ + 'material_id' => $this->material->id, 'material_code' => 'M1', 'material_name' => 'Material 1', + 'unit_of_measure' => 'kg', 'quantity_per_unit' => 1.0, 'scrap_percentage' => 0, + ]]], + ]); + $batch = Batch::factory()->create([ + 'work_order_id' => $this->workOrder->id, 'target_qty' => 100, 'produced_qty' => 0, 'status' => Batch::STATUS_PENDING, + ]); + + app(MaterialAllocationService::class)->allocateForBatch($batch, $this->admin()); + $this->allocation = MaterialAllocation::firstWhere('batch_id', $batch->id); + } + + private function admin(): User + { + return once(fn () => tap(User::factory()->create(), fn ($u) => $u->assignRole('Admin'))); + } + + private function base(): string + { + return "/admin/work-orders/{$this->workOrder->id}"; + } + + public function test_admin_records_consumption(): void + { + $this->actingAs($this->admin()) + ->post($this->base()."/allocations/{$this->allocation->id}/consume", ['consumed_qty' => 70]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertEqualsWithDelta(70.0, (float) $this->allocation->fresh()->consumed_qty, 0.0001); + } + + public function test_admin_returns_unused_material(): void + { + $this->actingAs($this->admin()) + ->post($this->base()."/allocations/{$this->allocation->id}/return", ['qty' => 25]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertEqualsWithDelta(425.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(75.0, (float) $this->allocation->fresh()->allocated_qty, 0.0001); + } + + public function test_admin_reclassifies_to_another_class(): void + { + $target = Material::create([ + 'code' => 'M2', 'name' => 'Material 2', + 'material_type_id' => MaterialType::create(['code' => 'ALT', 'name' => 'Alt'])->id, + 'unit_of_measure' => 'kg', 'stock_quantity' => 0, + ]); + + $this->actingAs($this->admin()) + ->post($this->base().'/reclassify', [ + 'source_material_id' => $this->material->id, + 'target_material_id' => $target->id, + 'qty' => 30, + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + // Source already at 400 after allocation; reclassify moves 30 more out. + $this->assertEqualsWithDelta(370.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + $this->assertEqualsWithDelta(30.0, (float) $target->fresh()->stock_quantity, 0.0001); + } + + public function test_guest_cannot_reconcile(): void + { + $this->post($this->base()."/allocations/{$this->allocation->id}/return", ['qty' => 25]) + ->assertRedirect('/login'); + } + + public function test_operator_cannot_reconcile(): void + { + $operator = tap(User::factory()->create(), fn ($u) => $u->assignRole('Operator')); + + $this->actingAs($operator) + ->post($this->base()."/allocations/{$this->allocation->id}/return", ['qty' => 25]) + ->assertForbidden(); + } + + public function test_reclassify_rejects_identical_source_and_target(): void + { + $this->actingAs($this->admin()) + ->post($this->base().'/reclassify', [ + 'source_material_id' => $this->material->id, + 'target_material_id' => $this->material->id, + 'qty' => 5, + ]) + ->assertSessionHasErrors('target_material_id'); + } + + public function test_return_above_the_returnable_quantity_flashes_an_error(): void + { + $this->actingAs($this->admin()) + ->post($this->base()."/allocations/{$this->allocation->id}/return", ['qty' => 5000]) + ->assertRedirect() + ->assertSessionHas('error'); + + // Stock untouched: 500 less the 100 allocated. + $this->assertEqualsWithDelta(400.0, (float) $this->material->fresh()->stock_quantity, 0.0001); + } + + public function test_allocation_from_another_work_order_is_404(): void + { + $otherWo = WorkOrder::factory()->create(['process_snapshot' => ['bom' => []]]); + + $this->actingAs($this->admin()) + ->post("/admin/work-orders/{$otherWo->id}/allocations/{$this->allocation->id}/return", ['qty' => 10]) + ->assertNotFound(); + } +}