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 ( +
+ {__('Record what was actually consumed, return leftovers to stock, or reclassify material.')} +
+| {__('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 && ( + <> + · + + > + )} + > + )} + | +