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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ 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
- **Typed operator outputs can enforce a pass/fail quality gate** *(admin / operator)* — a `number`, `boolean` or `select` output can now carry an expected result (a min/max range, a required Yes, or a single passing option) alongside the existing "must be recorded" requirement. Recording a value that fails the configured criterion **automatically raises a blocking issue** (the existing in-process QC-fail type) on the work order, which stops the *next* station from starting until the issue is resolved — closing the gap where a required output only checked that *something* was recorded, never that the recorded value was actually good. No criterion configured = unchanged behaviour. `App\Observers\BatchStepOutputValueObserver` + `App\Services\WorkOrder\OutputGateEvaluator`.
- **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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate)
'unit' => $o->unit,
'options' => $o->options ?? [],
'is_required' => (bool) $o->is_required,
'expected_min' => $o->expected_min === null ? null : (float) $o->expected_min,
'expected_max' => $o->expected_max === null ? null : (float) $o->expected_max,
'expected_value' => $o->expected_value,
]),
],
'workstations' => $workstations->map(fn ($w) => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public function store(
'unit' => $request->validated('unit'),
'options' => $request->validated('options'),
'is_required' => $request->boolean('is_required'),
'expected_min' => $request->validated('expected_min'),
'expected_max' => $request->validated('expected_max'),
'expected_value' => $request->validated('expected_value'),
'sort_order' => ($processTemplate->outputs()->where('template_step_id', $stepId)->max('sort_order') ?? 0) + 1,
]);

Expand Down
3 changes: 3 additions & 0 deletions backend/app/Http/Requests/StoreTemplateStepOutputRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public function rules(): array
'options' => ['nullable', 'array'],
'options.*' => ['string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'expected_min' => ['nullable', 'numeric'],
'expected_max' => ['nullable', 'numeric', 'gte:expected_min'],
'expected_value' => ['nullable', 'string', 'max:255'],
];
}

Expand Down
17 changes: 17 additions & 0 deletions backend/app/Models/TemplateStepOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ class TemplateStepOutput extends Model
'options',
'is_required',
'sort_order',
'expected_min',
'expected_max',
'expected_value',
];

protected function casts(): array
Expand All @@ -57,6 +60,8 @@ protected function casts(): array
'options' => 'array',
'is_required' => 'boolean',
'sort_order' => 'integer',
'expected_min' => 'decimal:4',
'expected_max' => 'decimal:4',
];
}

Expand All @@ -74,4 +79,16 @@ public function values(): HasMany
{
return $this->hasMany(BatchStepOutputValue::class, 'output_id');
}

/**
* True when this output has a configured pass criterion. Callers (the
* OutputGateEvaluator) treat "no criterion" as "always passes" — the
* required-field gate (is_required) is unaffected either way.
*/
public function hasExpectedResult(): bool
{
return $this->expected_min !== null
|| $this->expected_max !== null
|| $this->expected_value !== null;
}
}
61 changes: 61 additions & 0 deletions backend/app/Observers/BatchStepOutputValueObserver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace App\Observers;

use App\Models\BatchStepOutputValue;
use App\Models\Issue;
use App\Models\IssueType;
use App\Services\IssueService;
use App\Services\WorkOrder\OutputGateEvaluator;
use Illuminate\Support\Facades\Log;

/**
* A recorded output that fails its configured expected result
* (TemplateStepOutput expected_min/expected_max/expected_value) auto-raises a
* blocking Issue on the work order — reusing WorkOrder::isBlocked() /
* BatchStep::canStart() (BatchStep.php:311-322) rather than inventing new
* blocking logic. An open blocking Issue on a work order already stops every
* step's canStart() on that order, so this is the whole enforcement mechanism.
* Best-effort like the sibling BatchStepEventObserver: a throwing evaluator or
* issue-service call must never break the operator's save.
*/
class BatchStepOutputValueObserver
{
public function __construct(
private readonly OutputGateEvaluator $evaluator,
private readonly IssueService $issues,
) {}

public function created(BatchStepOutputValue $value): void
{
try {
$value->loadMissing(['output', 'batchStep.batch']);

if ($this->evaluator->passes($value)) {
return;
}

$issueType = IssueType::where('code', 'IN_PROCESS_QC_FAIL')->first();

if (! $issueType) {
Log::warning('Quality gate failed but IN_PROCESS_QC_FAIL issue type is missing — no issue raised', [
'batch_step_output_value_id' => $value->id,
]);

return;
}

$this->issues->createIssue([
'work_order_id' => $value->batchStep->batch->work_order_id,
'batch_step_id' => $value->batch_step_id,
'issue_type_id' => $issueType->id,
'source' => Issue::SOURCE_IN_PROCESS,
'title' => __('Quality gate failed: :label', ['label' => $value->output->label]),
'description' => __('Recorded value did not meet the expected result configured for this step.'),
'reported_by_id' => $value->recorded_by_id,
]);
} catch (\Throwable $e) {
Log::warning('BatchStepOutputValue quality-gate hook failed', ['error' => $e->getMessage()]);
}
}
}
6 changes: 6 additions & 0 deletions backend/app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ public function boot(): void
// changes no work_orders column — watch them for the schedule hook too.
\App\Models\WorkOrderPlacement::observe(\App\Observers\WorkOrderPlacementEventObserver::class);

// Quality gates (#quality-gate): a recorded output that fails its
// configured expected result auto-raises a blocking Issue, which the
// existing WorkOrder::isBlocked()/BatchStep::canStart() checks already
// use to stop the next station — see BatchStepOutputValueObserver.
\App\Models\BatchStepOutputValue::observe(\App\Observers\BatchStepOutputValueObserver::class);

// Generic CRUD hook: one wildcard Eloquent listener re-dispatches
// ResourceChanged for every curated resource (SoftDeleteRegistry::MODELS)
// so a module can hook any create/update/delete without per-model wiring.
Expand Down
63 changes: 63 additions & 0 deletions backend/app/Services/WorkOrder/OutputGateEvaluator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace App\Services\WorkOrder;

use App\Models\BatchStepOutputValue;
use App\Models\TemplateStepOutput;

/**
* Answers "did this recorded value meet its station's pass criterion?" — the
* piece that was missing before #quality-gate: a required output only ever
* checked "was something recorded", never "was the recorded thing good". No
* criterion configured on the output = always passes (today's behaviour,
* unchanged). text/date/picture outputs have no gate support in v1.
*/
class OutputGateEvaluator
{
public function passes(BatchStepOutputValue $value): bool
{
$output = $value->output;

if (! $output || ! $output->hasExpectedResult()) {
return true;
}

return match ($output->value_type) {
TemplateStepOutput::TYPE_BOOLEAN => $this->passesBoolean($output, $value),
TemplateStepOutput::TYPE_NUMBER => $this->passesNumber($output, $value),
TemplateStepOutput::TYPE_SELECT => $this->passesSelect($output, $value),
default => true,
};
}

private function passesBoolean(TemplateStepOutput $output, BatchStepOutputValue $value): bool
{
$expected = filter_var($output->expected_value, FILTER_VALIDATE_BOOLEAN);

return $value->value_boolean === $expected;
}

private function passesNumber(TemplateStepOutput $output, BatchStepOutputValue $value): bool
{
if ($value->value_number === null) {
return true;
}

$recorded = (float) $value->value_number;

if ($output->expected_min !== null && $recorded < (float) $output->expected_min) {
return false;
}

if ($output->expected_max !== null && $recorded > (float) $output->expected_max) {
return false;
}

return true;
}

private function passesSelect(TemplateStepOutput $output, BatchStepOutputValue $value): bool
{
return $value->value_text === $output->expected_value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
* Optional pass criterion for a typed operator output (#quality-gate). A number
* output can set expected_min/expected_max (either or both — one-sided bounds
* are fine); a boolean or select output sets expected_value (boolean: '1'/'0';
* select: the single passing option string). Null on all three = no gate,
* current "must be recorded" behaviour is unchanged. Evaluated by
* App\Services\WorkOrder\OutputGateEvaluator on every recorded value.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('template_step_outputs', function (Blueprint $table) {
$table->decimal('expected_min', 12, 4)->nullable()->after('options');
$table->decimal('expected_max', 12, 4)->nullable()->after('expected_min');
$table->string('expected_value', 255)->nullable()->after('expected_max');
});
}

public function down(): void
{
Schema::table('template_step_outputs', function (Blueprint $table) {
$table->dropColumn(['expected_min', 'expected_max', 'expected_value']);
});
}
};
51 changes: 49 additions & 2 deletions backend/resources/js/Pages/admin/process-templates/Show.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,10 @@ function StepInstructionsEditor({ step, productType, processTemplate }) {

const outputsBase = `/admin/product-types/${productType.id}/process-templates/${processTemplate.id}/outputs`;
const outputs = (processTemplate.outputs ?? []).filter((o) => o.template_step_id === step.id);
const outputForm = useForm({ key: '', label: '', value_type: 'text', unit: '', options: '', is_required: false, template_step_id: step.id });
const outputForm = useForm({
key: '', label: '', value_type: 'text', unit: '', options: '', is_required: false,
expected_min: '', expected_max: '', expected_value: '', template_step_id: step.id,
});

const addOutput = (e) => {
e.preventDefault();
Expand All @@ -577,7 +580,7 @@ function StepInstructionsEditor({ step, productType, processTemplate }) {
}));
outputForm.post(outputsBase, {
preserveScroll: true,
onSuccess: () => outputForm.reset('key', 'label', 'unit', 'options', 'is_required'),
onSuccess: () => outputForm.reset('key', 'label', 'unit', 'options', 'is_required', 'expected_min', 'expected_max', 'expected_value'),
onFinish: () => outputForm.transform((d) => d),
});
};
Expand Down Expand Up @@ -697,6 +700,16 @@ function StepInstructionsEditor({ step, productType, processTemplate }) {
<span className="font-mono text-[10px] text-om-muted">{o.key}</span>
<span className="text-[10px] uppercase px-1.5 py-0.5 rounded bg-om-chip text-om-muted">{o.value_type}</span>
{o.is_required && <span className="text-[10px] uppercase text-om-downtime">{__('required')}</span>}
{(o.expected_min !== null || o.expected_max !== null) && (
<span className="text-[10px] uppercase px-1.5 py-0.5 rounded bg-om-chip text-om-muted">
{__('pass')}: {o.expected_min ?? '–'}–{o.expected_max ?? '–'}
</span>
)}
{o.expected_value && (
<span className="text-[10px] uppercase px-1.5 py-0.5 rounded bg-om-chip text-om-muted">
{__('pass')}: {o.expected_value}
</span>
)}
<button type="button" onClick={() => router.delete(`${outputsBase}/${o.id}`, { preserveScroll: true })} className="text-xs text-om-blocked hover:underline ml-auto">{__('Remove')}</button>
</li>
))}
Expand Down Expand Up @@ -734,6 +747,40 @@ function StepInstructionsEditor({ step, productType, processTemplate }) {
<TextField value={outputForm.data.options} onChange={(v) => outputForm.setData('options', v)} placeholder={__('options, comma-separated')} aria-label={__('options, comma-separated')} />
</div>
)}
{outputForm.data.value_type === 'number' && (
<span className="flex items-center gap-1">
<div className="w-[80px]">
<TextField type="number" value={outputForm.data.expected_min} onChange={(v) => outputForm.setData('expected_min', v)} placeholder={__('pass min')} aria-label={__('pass min')} />
</div>
<span className="text-xs text-om-muted">–</span>
<div className="w-[80px]">
<TextField type="number" value={outputForm.data.expected_max} onChange={(v) => outputForm.setData('expected_max', v)} placeholder={__('pass max')} aria-label={__('pass max')} />
</div>
</span>
)}
{outputForm.data.value_type === 'boolean' && (
<Checkbox
size="sm"
checked={outputForm.data.expected_value === '1'}
onChange={(next) => outputForm.setData('expected_value', next ? '1' : '')}
label={__('Must be Yes to pass')}
/>
)}
{outputForm.data.value_type === 'select' && outputForm.data.options.trim() !== '' && (
<Dropdown
size="sm"
className="min-w-[140px]"
value={outputForm.data.expected_value}
onChange={(v) => outputForm.setData('expected_value', v ?? '')}
options={[
{ value: '', label: __('No pass criterion') },
...outputForm.data.options.split(',').map((s) => s.trim()).filter(Boolean).map((opt) => ({
value: opt, label: __('Pass: :option', { option: opt }),
})),
]}
aria-label={__('Pass criterion')}
/>
)}
<Checkbox
size="sm"
checked={outputForm.data.is_required}
Expand Down
Loading