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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\RecordConsumptionRequest;
use App\Http\Requests\Api\V1\ReturnAllocationRequest;
use App\Models\MaterialAllocation;
use App\Services\Material\MaterialAllocationService;
use Illuminate\Http\JsonResponse;

/**
* Work-order material reconciliation (#99): declare actual (partial) consumption
* and return unused quantity to stock. Both funnel through
* MaterialAllocationService so stock, reservation and lot accounting stay in sync.
*/
class MaterialAllocationController extends Controller
{
public function __construct(
protected MaterialAllocationService $allocations,
) {}

public function consume(RecordConsumptionRequest $request, MaterialAllocation $allocation): JsonResponse
{
try {
$updated = $this->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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ReclassifyClassRequest;
use App\Http\Requests\Api\V1\ReclassifyLotStatusRequest;
use App\Models\Material;
use App\Models\MaterialLot;
use App\Services\Material\MaterialReclassificationService;
use Illuminate\Http\JsonResponse;

/**
* Material reclassification (#99): regrade a quantity between material classes,
* or change a lot's status. Route-gated to Supervisor|Admin.
*/
class MaterialReclassificationController extends Controller
{
public function __construct(
protected MaterialReclassificationService $reclassifications,
) {}

public function class(ReclassifyClassRequest $request): JsonResponse
{
try {
$source = Material::findOrFail($request->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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Comment on lines +284 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check nullability of material_allocations.batch_id and soft-delete usage on Batch.
set -euo pipefail

fd -t f . backend/database/migrations --exec rg -n -C3 'material_allocations' {} \; | head -80

echo '--- MaterialAllocation model ---'
fd -t f 'MaterialAllocation.php' backend/app/Models --exec cat -n {} \;

echo '--- Batch soft deletes ---'
fd -t f 'Batch.php' backend/app/Models --exec rg -n 'SoftDelete|batch_id|workOrder' {} \;

echo '--- Request authorize methods ---'
fd -t f 'RecordConsumptionRequest.php|ReturnAllocationRequest.php' backend/app/Http/Requests --exec cat -n {} \;

Repository: Mes-Open/OpenMes

Length of output: 3726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Relevant migration definitions ---'
rg -n -C5 'batch_id|CREATE TABLE.*material_allocations|Schema::create\(' backend/database/migrations -g '*.php' | head -240

echo '--- Model files ---'
fd -t f -i 'MaterialAllocation.php|Batch.php' backend/app/Models -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

echo '--- Request files ---'
fd -t f -i 'RecordConsumptionRequest.php|ReturnAllocationRequest.php' backend/app/Http/Requests -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

echo '--- Controller authorization flow ---'
sed -n '260,320p' backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php
rg -n -C4 'assertAllocationBelongs|recordConsumption|returnAllocation' backend/app/Http/Controllers backend/app/Http/Requests -g '*.php'

Repository: Mes-Open/OpenMes

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Files that define or alter material_allocations.batch_id ---'
rg -l -F 'batch_id' backend/database/migrations -g '*.php' | sort

echo '--- Exact batch_id definitions ---'
rg -n -C8 -F "foreignId('batch_id')" backend/database/migrations -g '*.php'

echo '--- Soft-delete implementation ---'
fd -t f -i 'SoftDeletesWithAudit.php' backend/app -x cat -n {}

echo '--- Request imports and route bindings ---'
sed -n '1,45p' backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php
rg -n -C3 'recordConsumption|returnAllocation' backend/routes backend/app -g '*.php' | head -160

echo '--- Belongs-to assertion ---'
sed -n '338,352p' backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php

Repository: Mes-Open/OpenMes

Length of output: 39499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

migration = Path("backend/database/migrations/2026_05_09_100002_create_material_allocations_table.php").read_text()
batch = Path("backend/app/Models/Batch.php").read_text()
soft_deletes = Path("backend/app/Models/Concerns/SoftDeletesWithAudit.php").read_text()
requests = [
    Path("backend/app/Http/Requests/Api/V1/RecordConsumptionRequest.php").read_text(),
    Path("backend/app/Http/Requests/Api/V1/ReturnAllocationRequest.php").read_text(),
]
controller = Path("backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php").read_text()

assert "foreignId('batch_id')->constrained()->cascadeOnDelete()" in migration
assert "use SoftDeletesWithAudit;" in batch
assert "use SoftDeletes;" in soft_deletes
assert all("$allocation->batch->workOrder" in source for source in requests)
assert "$allocation->batch?->work_order_id" in controller

print("material_allocations.batch_id is declared non-nullable")
print("Batch uses SoftDeletesWithAudit, which uses SoftDeletes")
print("Both request authorize methods dereference batch without a null check")
print("The web controller checks the batch relationship with null-safe access")
PY

Repository: Mes-Open/OpenMes

Length of output: 406


Guard missing batches in both authorization methods.

material_allocations.batch_id is non-nullable, but Batch uses SoftDeletesWithAudit. A soft-deleted batch is hidden from $allocation->batch, so both RecordConsumptionRequest::authorize() and ReturnAllocationRequest::authorize() dereference workOrder on null and return 500 before the controller check. Add an explicit batch-null guard and deny authorization when the batch is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php`
around lines 284 - 286, Update RecordConsumptionRequest::authorize() and
ReturnAllocationRequest::authorize() to explicitly deny authorization when the
allocation’s batch relationship is null, before dereferencing batch->workOrder.
Preserve the existing authorization checks for available batches and ensure
soft-deleted or otherwise unavailable batches return false rather than causing
an exception.


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'),
);
Comment on lines +320 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope reclassify to the work order, or drop the route parameter.

The action receives $workOrder and never uses it (PHPMD flags the unused parameter). source_material_id comes straight from the request body and is validated only by exists:materials,id. A Supervisor on the page for work order A can therefore reclassify any material in the system through work order A's URL. The nested route implies a scope that the action does not enforce.

Add a check that the source material is allocated to this work order. That matches the panel's UI, where the source is always the allocation's material.

🔒 Proposed scoping check
     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 and not decoration.
+            if (! $workOrder->allocations()->where('material_id', $source->id)->exists()) {
+                abort(404);
+            }
+
             $target = Material::findOrFail($request->validated('target_material_id'));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function reclassify(ReclassifyClassRequest $request, WorkOrder $workOrder, MaterialReclassificationService $reclassifications)
{
try {
$source = Material::findOrFail($request->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;
$reclassifications->reclassifyClass(
$source,
$target,
(float) $request->validated('qty'),
$request->user(),
$lot,
$request->validated('reason'),
);
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 and 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'),
);
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 320-320: Avoid unused parameters such as '$workOrder'. (undefined)

(UnusedFormalParameter)

🪛 PHPStan (2.2.7)

[error] 323-323: Call to an undefined static method App\Models\Material::findOrFail().

(staticMethod.notFound)


[error] 324-324: Call to an undefined static method App\Models\Material::findOrFail().

(staticMethod.notFound)


[error] 325-325: Call to an undefined static method App\Models\MaterialLot::findOrFail().

(staticMethod.notFound)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php`
around lines 320 - 334, Update the reclassify method to enforce the nested
work-order scope before calling
MaterialReclassificationService::reclassifyClass: verify that the requested
source material is allocated to the supplied WorkOrder and reject the request
when it is not. Keep the existing source, target, lot lookup, and
reclassification flow unchanged for valid allocations, and use the existing
allocation relationship or model symbol rather than trusting source_material_id
alone.

Source: Linters/SAST tools


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', [
Expand Down
28 changes: 28 additions & 0 deletions backend/app/Http/Requests/Api/V1/ReclassifyClassRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

/**
* Reclassify a quantity of material from one class (material) to another (#99).
* The route is gated to Supervisor|Admin; this request owns shape validation.
*/
class ReclassifyClassRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'source_material_id' => ['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'],
];
}
}
40 changes: 40 additions & 0 deletions backend/app/Http/Requests/Api/V1/ReclassifyLotStatusRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Http\Requests\Api\V1;

use App\Models\MaterialLot;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

/**
* Change a material lot's status (#99) — released / quarantine / rejected. The
* route is gated to Supervisor|Admin. A reason is required when quarantining or
* rejecting, so the disposition is explainable in the audit trail.
*/
class ReclassifyLotStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'to_status' => ['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)),
],
];
}
}
Loading
Loading