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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,24 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- **Settings language picker showed the wrong language** *(admin)* — the Settings → System language dropdown always showed the stored *system default*, so after switching the UI language with the per-session switcher the picker contradicted the language actually on screen (#271). It now reflects the currently effective locale (the session override if set, else the system default).

### Added
- **Consumption is deducted from the workshop location it came off** — stock levels per storage location now reflect what production actually used. Allocation already moved the plant-wide quantity and the picked lot, but nothing said *where* the material physically was, so a plant running several stores could not tell which one had emptied.
- **Each line names its stock location.** A production line gains a **Stock location** (Admin → Lines), picked from the raw-material warehouses. Optional: a plant that doesn't track stock per location leaves it unset and nothing changes.
- **The location is resolved most-specific-first.** The **picked lot's** warehouse wins (it knows exactly where it sits), then the **line's** stock location, then the plant's **default raw-material** warehouse. Once a deduction has been made the location is **frozen on the allocation**, so a later correction always credits back the location that actually gave the material up — even if the lot has since been moved or the line re-pointed.
- **Split across the stores it really came from.** Lot picking is FEFO across the material's lots and knows nothing about stores, so one allocation can draw from two — each pick's share is booked against its own lot's warehouse, **frozen on the pick at its first deduction** so a lot moved afterwards still credits back the store that gave the material up. A picked lot the location cannot cover is refused on its own account: a healthy material total is not the same answer as the lot being there. **Scrap counts as consumed** for this: it left the store too, unlike the leftover that is returned.
- **Booked by difference, never twice.** Consumption is recorded more than once for the same allocation (an operator's entry, a correction, then batch completion finalising the rest), so the balance moves by the **difference** each time. A downward correction credits the location back, and cancelling a batch returns everything it had taken.
- **Auditable per deduction.** Every deduction writes a `stock_movements` row carrying the **warehouse**, the batch/step it came from and the quantity — the plant-wide quantity is deliberately *not* moved again, since allocation already booked it.
- **Stock cannot silently go negative.** The balance row is **locked before it is read**, so two concurrent bookings cannot both pass the same check; posting a warehouse document now honours the **location's** balance as well as the plant-wide one. Consumption exceeding the location's balance is **refused** when the system-wide **"block negative stock"** setting is on (the same switch warehouse documents respect) — and when it's off, production is not stopped but the movement records the **shortfall** explicitly, so an overdraw stays findable.
- **Part of the optional Warehouses module.** With the module off, consumption moves no location balance and refuses nothing — a plant that does not run per-location stock is unaffected. Rollout steps are in [`docs/warehouse-erp-rollout.md`](docs/warehouse-erp-rollout.md).
- New `lines.warehouse_id` and `material_allocations.consumption_warehouse_id` / `location_deducted_qty` columns, `App\Services\Material\ConsumptionLocationService`, and a shared `App\Services\Warehouse\WarehouseStockService` that the stock-document posting path now uses too, so both routes into a location balance share one race-safe implementation.
- **Count production from MQTT machines onto a line/step** *(admin / connectivity)* — a machine/MQTT device can be **assigned to a production line**, and a new **"Count at Station / Step"** topic-mapping action turns each sensor pulse (e.g. a break-beam sensor: one unit leaving a station) into `+1` on the addressed step of the line's **currently running** work order — no per-order configuration. The per-step throughput is tracked in a new `passed_qty` counter; a mapping flagged as the finished-goods counting point also feeds the work order's `produced_qty` (through the shared machine-count path, so `counting_source` and auto start/complete are honoured — no double counting). `update_work_order_qty` can now also target a line directly (the running order) instead of a fixed order number. The device form gains an **Assigned line** picker and the topic-mapping editor a guided **Line + Station/Step** form for the count action (no more hand-written JSON).
- **Traceability when editing an in-use process template** *(admin)* — editing a template's steps (add / rename / delete / reorder) still mutates the current template in place, and running work orders correctly keep their frozen snapshot — but that used to happen silently. Now the template page shows a **warning banner** when the template backs active (non-finished) work orders, destructive step edits ask for confirmation while it's in use, and **every step change is written to the immutable audit log** (before/after shape) so the previous version is never lost. No change to how orders resolve their steps.
- **Add maintenance to the planner** *(admin)* — a new **+ Maintenance** button on the schedule planner opens a modal to place a **defined maintenance** (a maintenance schedule, which pre-fills its title / type / line) or an ad-hoc one onto a line at a chosen date, time and duration. It lands as a **distinct yellow tile** in the line's maintenance strip (maintenance tiles are now yellow instead of purple, so they stand out from work orders). Backed by `POST /admin/schedule/maintenance`.
- **Plant timezone is changeable after installation** *(admin)* — Settings → System → General now carries a timezone picker (region + zone), writing the same `system_settings` row the installer's step does; the wizard already promised this was possible. The chosen zone is re-applied per request and before each queued job, so on Octane a change reaches every worker immediately instead of waiting for a container restart. Saving reloads the page so every displayed time switches over at once.
- **Product types as Bill-of-Materials components** *(admin)* — a BOM line can now be a manufactured **product type** (a sub-assembly), not only a material. In the BOM editor a Material / Product type switch picks the component kind; product-type lines carry the same quantity-per-unit, step, scrap %, consumption timing and notes as materials. A product type can't be a component of itself, and each appears once per template. Lines are captured in the work-order snapshot as sub-assembly references; they're a simple component reference (they don't explode into their own BOM) and are skipped by the material stock/consumption engine. Additive — existing material BOMs are unaffected.

### Fixed
- **Saving system settings crashed on PostgreSQL** *(admin)* — the plant-timezone save wrote the raw identifier (e.g. `Europe/Warsaw`) into the JSON `system_settings.value` column, which PostgreSQL rejects (`invalid input syntax for type json`), 500-ing the whole Settings → System save; SQLite tolerated it, so tests missed it. The value is now JSON-encoded (and decoded on read, tolerating legacy raw values).
- **Header clock ignored the configured timezone** *(all users)* — the live clock top-right was hardcoded to `Europe/Warsaw`, so on any install with a different timezone it was the one timestamp in the UI that disagreed with all the others. It now goes through the same `formatDate`/`formatTime` helpers as the rest of the app.

- **Add maintenance to the planner** *(admin)* — a new **+ Maintenance** button on the schedule planner opens a modal to place a **defined maintenance** (a maintenance schedule, which pre-fills its title / type / line) or an ad-hoc one onto a line at a chosen date, time and duration. It lands as a **distinct yellow tile** in the line's maintenance strip (maintenance tiles are now yellow instead of purple, so they stand out from work orders). Backed by `POST /admin/schedule/maintenance`.

Expand Down
47 changes: 26 additions & 21 deletions backend/app/Http/Controllers/Web/Admin/LineManagementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Http\Requests\Web\Admin\StoreLineRequest;
use App\Http\Requests\Web\Admin\UpdateLineRequest;
use App\Models\Area;
use App\Models\Line;
use App\Models\LineStatus;
Expand Down Expand Up @@ -50,10 +52,26 @@ public function create()
{
return Inertia::render('admin/lines/Create', [
'areas' => $this->areaOptions(),
'warehouses' => $this->warehouseOptions(),
'customFields' => app(CustomFieldService::class)->clientConfig('line'),
]);
}

/**
* Raw-material locations a line can consume from. Only those, because a line
* draws components, never finished goods.
*
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
*/
private function warehouseOptions(): \Illuminate\Support\Collection
{
return \App\Models\Warehouse::forMaterials()
->where('is_active', true)
->orderBy('name')
->get(['id', 'code', 'name'])
->map(fn ($w) => ['id' => $w->id, 'name' => "{$w->name} ({$w->code})"]);
}

/** Areas as {id, name (with site)} options for the line form. */
private function areaOptions(): \Illuminate\Support\Collection
{
Expand All @@ -64,18 +82,11 @@ private function areaOptions(): \Illuminate\Support\Collection
/**
* Store a newly created line
*/
public function store(Request $request)
public function store(StoreLineRequest $request)
{
$cf = app(CustomFieldService::class);
$validated = $request->validate(array_merge([
'code' => 'required|string|max:50|unique:lines',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'area_id' => 'nullable|exists:areas,id',
'is_active' => 'boolean',
], $cf->rules('line')), [], $cf->attributeNames('line'));

$validated['is_active'] = $request->boolean('is_active', true);
$validated = $request->validated();

unset($validated['custom_field_files']);
if ($cf->touched($request)) {
$validated['custom_fields'] = $cf->fromRequest($request, 'line') ?: null;
Expand Down Expand Up @@ -218,27 +229,21 @@ public function show(Line $line)
public function edit(Line $line)
{
return Inertia::render('admin/lines/Edit', [
'line' => $line->only('id', 'code', 'name', 'description', 'area_id', 'is_active', 'custom_fields'),
'line' => $line->only('id', 'code', 'name', 'description', 'area_id', 'warehouse_id', 'is_active', 'custom_fields'),
'areas' => $this->areaOptions(),
'warehouses' => $this->warehouseOptions(),
'customFields' => app(CustomFieldService::class)->clientConfig('line'),
]);
}

/**
* Update the specified line
*/
public function update(Request $request, Line $line)
public function update(UpdateLineRequest $request, Line $line)
{
$cf = app(CustomFieldService::class);
$validated = $request->validate(array_merge([
'code' => 'required|string|max:50|unique:lines,code,'.$line->id,
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'area_id' => 'nullable|exists:areas,id',
'is_active' => 'boolean',
], $cf->rules('line')), [], $cf->attributeNames('line'));

$validated['is_active'] = $request->boolean('is_active');
$validated = $request->validated();

unset($validated['custom_field_files']);
if ($cf->touched($request)) {
$validated['custom_fields'] = $cf->fromRequest($request, 'line', $line->custom_fields) ?: null;
Expand Down
47 changes: 47 additions & 0 deletions backend/app/Http/Requests/Concerns/ValidatesLineStockLocation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Requests\Concerns;

use App\Models\Warehouse;
use Illuminate\Validation\Rule;

/**
* The rule set behind a line's stock location, shared by create and edit.
*
* It has to match what the form actually offers (LineManagementController::
* warehouseOptions): a live, active warehouse that may hold materials. A line
* draws components, never finished goods, and pointing one at an archived or
* finished-goods store would send every consumption deduction somewhere that
* cannot answer for it.
*
* `Rule::exists` queries the table directly and so bypasses the model's global
* TenantScope — the tenant clause below is what keeps one tenant from naming
* another tenant's warehouse by id.
*/
trait ValidatesLineStockLocation
{
/** @return array<int, mixed> */
protected function stockLocationRules(): array
{
return [
'nullable',
'integer',
Rule::exists('warehouses', 'id')
->whereNull('deleted_at')
->where('is_active', true)
->whereIn('kind', [Warehouse::KIND_RAW_MATERIAL, Warehouse::KIND_MIXED])
->where(function ($query) {
// Mirrors TenantScope exactly: scope to the tenant when there is
// one, and to nothing when there is not. Rejecting outright on a
// null tenant would break every single-tenant install — tenancy is
// dormant there, so users and warehouses both carry a null
// tenant_id and the picker offers all of them.
$tenantId = $this->user()?->tenant_id;

if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
Comment thread
JanKolo04 marked this conversation as resolved.
}),
];
}
}
43 changes: 43 additions & 0 deletions backend/app/Http/Requests/Web/Admin/StoreLineRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Http\Requests\Web\Admin;

use App\Http\Requests\Concerns\MergesCustomFieldRules;
use App\Http\Requests\Concerns\ValidatesLineStockLocation;
use Illuminate\Foundation\Http\FormRequest;

class StoreLineRequest extends FormRequest
{
use MergesCustomFieldRules;
use ValidatesLineStockLocation;

public function authorize(): bool
{
// Route middleware already restricts admin routes to the Admin role.
return true;
}

protected function customFieldEntityType(): string
{
return 'line';
}

/** A missing checkbox on create means "not filled in", not "unchecked". */
protected function prepareForValidation(): void
{
$this->merge(['is_active' => $this->boolean('is_active', true)]);
}

public function rules(): array
{
return array_merge([
'code' => ['required', 'string', 'max:50', 'unique:lines,code'],
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'area_id' => ['nullable', 'exists:areas,id'],
// The stock location this line's consumption comes off.
'warehouse_id' => $this->stockLocationRules(),
'is_active' => ['boolean'],
], $this->customFieldRules());
}
}
47 changes: 47 additions & 0 deletions backend/app/Http/Requests/Web/Admin/UpdateLineRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Requests\Web\Admin;

use App\Http\Requests\Concerns\MergesCustomFieldRules;
use App\Http\Requests\Concerns\ValidatesLineStockLocation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateLineRequest extends FormRequest
{
use MergesCustomFieldRules;
use ValidatesLineStockLocation;

public function authorize(): bool
{
// Route middleware already restricts admin routes to the Admin role.
return true;
}

protected function customFieldEntityType(): string
{
return 'line';
}

/** On edit an unticked checkbox really does mean "inactive". */
protected function prepareForValidation(): void
{
$this->merge(['is_active' => $this->boolean('is_active')]);
}

public function rules(): array
{
return array_merge([
'code' => [
'required', 'string', 'max:50',
Rule::unique('lines', 'code')->ignore($this->route('line')?->id),
],
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'area_id' => ['nullable', 'exists:areas,id'],
// The stock location this line's consumption comes off.
'warehouse_id' => $this->stockLocationRules(),
'is_active' => ['boolean'],
], $this->customFieldRules());
}
}
8 changes: 8 additions & 0 deletions backend/app/Models/AllocationLotPick.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class AllocationLotPick extends Model
protected $fillable = [
'material_allocation_id',
'material_lot_id',
// The location this pick was deducted from, frozen at the first deduction.
'consumption_warehouse_id',
'tenant_id',
'picked_qty',
'picking_strategy',
Expand All @@ -44,4 +46,10 @@ public function lot(): BelongsTo
{
return $this->belongsTo(MaterialLot::class, 'material_lot_id');
}

/** The location this pick was consumed from, once anything has been deducted. */
public function consumptionWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'consumption_warehouse_id');
}
}
8 changes: 8 additions & 0 deletions backend/app/Models/Line.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class Line extends Model
protected $fillable = [
'area_id',
'division_id',
// Stock location this line consumes from; null when stock is not tracked per location.
'warehouse_id',
'code',
'name',
'description',
Expand All @@ -44,6 +46,12 @@ public function division(): BelongsTo
return $this->belongsTo(Division::class);
}

/** The stock location this line's consumption comes off. */
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}

/**
* Get the ISA-95 area this line belongs to.
*/
Expand Down
11 changes: 11 additions & 0 deletions backend/app/Models/MaterialAllocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ class MaterialAllocation extends Model
'batch_step_id',
'material_id',
'work_order_id',
// The location this allocation is consumed from, frozen on first deduction.
'consumption_warehouse_id',
'allocated_qty',
'expected_qty',
'returned_qty',
'consumed_qty',
'consumption_recorded',
// How much of it has already been taken off that location's balance.
'location_deducted_qty',
'adjustment_qty',
'scrap_qty',
'status',
Expand All @@ -49,6 +53,7 @@ protected function casts(): array
'returned_qty' => 'decimal:4',
'consumed_qty' => 'decimal:4',
'consumption_recorded' => 'boolean',
'location_deducted_qty' => 'decimal:4',
'adjustment_qty' => 'decimal:4',
'scrap_qty' => 'decimal:4',
'allocated_at' => 'datetime',
Expand Down Expand Up @@ -86,6 +91,12 @@ public function allocatedBy(): BelongsTo
return $this->belongsTo(User::class, 'allocated_by');
}

/** The location this allocation's consumption is booked against. */
public function consumptionWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'consumption_warehouse_id');
}

public function lotPicks(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(AllocationLotPick::class, 'material_allocation_id');
Expand Down
Loading
Loading