Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1a47b52
translation: sync Vietnamese translations with develop and standardiz…
SoiBien-AI Aug 20, 2026
1a67467
translation: address CodeRabbit review on #249 (work order terms + MR…
SoiBien-AI Aug 20, 2026
b96c136
translation: update Vietnamese translations after sync with develop
SoiBien-AI Aug 21, 2026
a3a0e55
fix(lines): match product-types sync route to the URL the UI posts to
jakub-przepiora Aug 21, 2026
549de1a
feat(steps): equipment key:value parameters per process-template step
jakub-przepiora Aug 21, 2026
1a6a70e
feat(steps): typed operator outputs (text/number/bool/select/date/pic…
jakub-przepiora Aug 21, 2026
6b1b385
Merge pull request #251 from Mes-Open/feat/step-equipment-parameters
jakub-przepiora Aug 21, 2026
f527566
chore(ci): enable CodeRabbit auto-review for develop/main
jakub-przepiora Aug 21, 2026
eba61a7
Merge pull request #253 from Mes-Open/chore/coderabbit-config
jakub-przepiora Aug 21, 2026
47e2eff
Merge pull request #249 from SoiBien-AI/feat/translate-develop
jakub-przepiora Aug 21, 2026
af89d3d
Merge remote-tracking branch 'origin/develop' into fix/line-product-t…
jakub-przepiora Aug 21, 2026
e5e28b4
Merge pull request #250 from Mes-Open/fix/line-product-types-sync-route
jakub-przepiora Aug 21, 2026
091ba90
Merge pull request #254 from Mes-Open/feat/step-typed-outputs
jakub-przepiora Aug 21, 2026
5e44c8b
chore(release): v0.21.0
jakub-przepiora Aug 21, 2026
8ddbc8c
Merge pull request #255 from Mes-Open/chore/release-0.21.0
jakub-przepiora Aug 21, 2026
2a5262f
fix(steps): address CodeRabbit review on typed outputs & parameters
jakub-przepiora Aug 21, 2026
beb1cbf
Merge pull request #257 from Mes-Open/fix/step-outputs-coderabbit
jakub-przepiora Aug 21, 2026
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
38 changes: 38 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# CodeRabbit configuration — https://docs.coderabbit.ai/reference/yaml-template
# Schema: https://coderabbit.ai/integrations/schema.v2.json
#
# Auto-review is enabled for the branches OpenMES actually merges into: `develop`
# (where all feature/fix PRs land) and `main` (release merges). Without listing
# them here CodeRabbit reports "reviews are disabled for this base branch" and
# skips the PR, which is what was happening on PRs targeting develop.
language: en-US
early_access: false

reviews:
# Balanced signal — flags real issues without nitpicking every style choice.
profile: chill
# Don't post a blocking "changes requested" review; leave the merge decision to us.
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
auto_review:
enabled: true
drafts: false
# The base branches CodeRabbit auto-reviews (regex). This is the key setting.
base_branches:
- develop
- main
- "feat/.*"
- "fix/.*"
# Keep generated assets and dependency lockfiles out of the review.
path_filters:
- "!**/*.lock"
- "!**/package-lock.json"
- "!**/composer.lock"
- "!backend/public/build/**"
- "!**/vendor/**"
- "!**/node_modules/**"

chat:
auto_reply: true
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

## [0.21.0] - 2026-08-21

### Added
- **Typed operator outputs on process-template steps** *(admin / operator / API)* — a step can define what the operator must **record** at execution: a `key`, a label and a **value type** — `text`, `number` (with unit), `boolean`, `select` (with options), `date`, or **`picture`** (e.g. a QC photo, `output_qcpic`). Admins add these in the step editor (beside the checklist). At the workstation the operator fills each one — typing a value, picking an option, or **capturing/uploading a photo** — and the MES records it with who/when. Any output marked **required blocks step completion** until it's recorded (same gate family as checklists and mandatory documents). Photos are decoded + re-encoded through the image sanitiser and stored on the private disk, served only through an authenticated endpoint. Recorded values (and picture URLs) are exposed to external systems over `GET /api/v1/work-orders/{id}/step-outputs`. Additive — existing steps and workflows are unaffected.
- **Equipment parameters on process-template steps** *(admin / API)* — each step can now carry a free-form `key:value` recipe (temperature, humidity, pressure, sample size…) that an external client reads to drive equipment. Set them in the step editor (a key/value row editor beside the ISA-95 fields). A linked Process Segment supplies defaults; the step's own values override them key by key (`effectiveParameters()`). The values are frozen onto a work order's `process_snapshot` (so `GET /api/v1/work-orders/{id}` exposes the exact recipe each order was built with) **and** readable live from the current template (`GET /api/v1/process-templates/{id}`). Nullable and additive — existing steps and workflows are unaffected.

### Fixed
- **Assigning product types to a production line 404'd** *(admin)* — the line-detail page posts the assignment to `/admin/lines/{line}/product-types/sync`, but the route was registered at `/admin/lines/{line}/product-types` (no `/sync`), so every save hit a non-existent URL and silently failed. The route path now matches the frontend (and its own `lines.product-types.sync` name); a feature test pins the literal URL so it can't drift again.

## [0.20.0] - 2026-08-16

### Added
Expand Down
71 changes: 71 additions & 0 deletions backend/app/Http/Controllers/Api/V1/StepOutputController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\BatchStepOutputValue;
use App\Models\WorkOrder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;

/**
* Read API for operator-recorded typed step outputs (#B), so an external system
* (ERP, reporting) can pull the values + pictures a shop floor recorded against a
* work order. Read-only; gated by the work-order view policy.
*/
class StepOutputController extends Controller
{
/** All recorded output values for a work order, grouped by batch step. */
public function forWorkOrder(WorkOrder $workOrder): JsonResponse
{
$this->authorize('view', $workOrder);

$values = BatchStepOutputValue::query()
->whereHas('batchStep.batch', fn ($q) => $q->where('work_order_id', $workOrder->id))
->with(['output:id,key,label,value_type,unit', 'recordedBy:id,name', 'batchStep:id,step_number,name'])
->orderBy('batch_step_id')
->get()
->map(fn (BatchStepOutputValue $v) => [
'id' => $v->id,
'step_number' => $v->batchStep?->step_number,
'batch_step_id' => $v->batch_step_id,
'key' => $v->output?->key,
'label' => $v->output?->label,
'value_type' => $v->output?->value_type,
'unit' => $v->output?->unit,
'value' => $v->typedValue(),
'file_url' => $v->file_path
? route('api.v1.batch-step-outputs.file', $v)
: null,
'recorded_by' => $v->recordedBy?->name,
'recorded_at' => $v->recorded_at?->toISOString(),
]);

return response()->json([
'data' => $values,
'meta' => ['work_order_id' => $workOrder->id],
]);
}

/** Serve a recorded output picture (safe inline mime + nosniff). */
public function file(BatchStepOutputValue $batchStepOutputValue)
{
$batchStepOutputValue->loadMissing('batchStep.batch.workOrder');
$workOrder = $batchStepOutputValue->batchStep?->batch?->workOrder;
abort_unless($workOrder !== null, 404);
$this->authorize('view', $workOrder);

abort_unless($batchStepOutputValue->file_path && Storage::exists($batchStepOutputValue->file_path), 404);

$inlineSafe = ['image/png', 'image/jpeg', 'image/webp'];
$mime = $batchStepOutputValue->mime_type ?? 'application/octet-stream';
$disposition = in_array($mime, $inlineSafe, true) ? 'inline' : 'attachment';

return response()->file(Storage::path($batchStepOutputValue->file_path), [
'Content-Type' => $mime,
'Content-Disposition' => $disposition.'; filename="'.addslashes($batchStepOutputValue->original_name ?? 'output').'"',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, max-age=3600',
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate)
'photos.uploadedBy',
'stepMedia',
'checklistItems',
'outputs',
]);
$workstations = Workstation::active()->with('line')->orderBy('name')->get();
$processSegments = \App\Models\ProcessSegment::query()
Expand All @@ -113,6 +114,7 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate)
'run_time_per_unit_minutes' => $s->run_time_per_unit_minutes,
'workstation_id' => $s->workstation_id,
'workstation_type_id' => $s->workstation_type_id,
'parameters' => $s->parameters ?? [],
'process_segment_id' => $s->process_segment_id,
'is_optional' => (bool) $s->is_optional,
'variant_group' => $s->variant_group,
Expand Down Expand Up @@ -153,6 +155,16 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate)
'label' => $c->label,
'is_required' => (bool) $c->is_required,
]),
'outputs' => $processTemplate->outputs->map(fn ($o) => [
'id' => $o->id,
'template_step_id' => $o->template_step_id,
'key' => $o->key,
'label' => $o->label,
'value_type' => $o->value_type,
'unit' => $o->unit,
'options' => $o->options ?? [],
'is_required' => (bool) $o->is_required,
]),
],
'workstations' => $workstations->map(fn ($w) => [
'id' => $w->id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTemplateStepOutputRequest;
use App\Models\ProcessTemplate;
use App\Models\ProductType;
use App\Models\TemplateStepOutput;

/**
* Typed operator-output definitions on process template steps (admin authoring).
* Reusable definition; operators record a value per batch step at the
* workstation. Routes are scoped to their template/product-type (mismatch = 404).
*/
class TemplateStepOutputController extends Controller
{
public function store(
StoreTemplateStepOutputRequest $request,
ProductType $productType,
ProcessTemplate $processTemplate,
) {
$this->ensureBelongs($productType, $processTemplate);

$stepId = $request->validated('template_step_id');
abort_unless($processTemplate->steps()->whereKey($stepId)->exists(), 404);

$processTemplate->outputs()->create([
'template_step_id' => $stepId,
'key' => $request->validated('key'),
'label' => $request->validated('label'),
'value_type' => $request->validated('value_type'),
'unit' => $request->validated('unit'),
'options' => $request->validated('options'),
'is_required' => $request->boolean('is_required'),
'sort_order' => ($processTemplate->outputs()->where('template_step_id', $stepId)->max('sort_order') ?? 0) + 1,
]);

return back()->with('success', __('Output added.'));
}

public function destroy(
ProductType $productType,
ProcessTemplate $processTemplate,
TemplateStepOutput $output,
) {
$this->ensureBelongs($productType, $processTemplate);
abort_unless($output->process_template_id === $processTemplate->id, 404);

$output->delete();

return back()->with('success', __('Output removed.'));
}

private function ensureBelongs(ProductType $productType, ProcessTemplate $processTemplate): void
{
abort_unless($processTemplate->product_type_id === $productType->id, 404);
}
}
127 changes: 127 additions & 0 deletions backend/app/Http/Controllers/Web/Operator/BatchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
use App\Models\BatchStep;
use App\Models\BatchStepChecklistCompletion;
use App\Models\BatchStepDocument;
use App\Models\BatchStepOutputValue;
use App\Models\TemplateStepChecklistItem;
use App\Models\TemplateStepOutput;
use App\Models\WorkOrder;
use App\Services\Lot\BatchReleaseService;
use App\Services\Lot\LotService;
use App\Services\Material\MaterialAllocationService;
use App\Services\Media\ImageSanitizer;
use App\Services\Production\PackagingChecklistService;
use App\Services\Production\ProcessConfirmationService;
use App\Services\Production\QualityCheckService;
Expand Down Expand Up @@ -244,6 +247,130 @@ public function toggleChecklistItem(Request $request, BatchStep $batchStep, Temp
return back()->with('success', 'Checklist item checked.');
}

/**
* Record (or overwrite) the operator's value for a typed step output (#B).
* Scalars write the typed column; a `picture` output sanitises + stores the
* uploaded image on the private disk. Re-recording soft-deletes the prior
* value (audit preserved) and inserts a fresh one.
*/
public function recordOutput(Request $request, BatchStep $batchStep, TemplateStepOutput $output, ImageSanitizer $sanitizer)
{
if (! $this->stepBelongsToSelectedLine($request, $batchStep)) {
return back()->with('error', 'This step does not belong to the selected line.');
}

// Anti-IDOR: the output must belong to this step's template + step number.
$templateId = $batchStep->batch?->workOrder?->process_snapshot['template_id'] ?? null;
$output->loadMissing('templateStep:id,step_number');
if ($output->process_template_id !== $templateId
|| $output->templateStep?->step_number !== $batchStep->step_number) {
return back()->with('error', 'This output does not belong to this step.');
}

$attrs = [
'batch_step_id' => $batchStep->id,
'output_id' => $output->id,
'recorded_by_id' => $request->user()->id,
'recorded_at' => now(),
];

try {
if ($output->value_type === TemplateStepOutput::TYPE_PICTURE) {
$validated = $request->validate(['value' => ['required', 'image', 'max:10240']]);
$clean = $sanitizer->sanitize($validated['value']->getRealPath());
$path = 'batch-step-outputs/'.\Illuminate\Support\Str::random(40).'.'.$clean['extension'];
// Pin to the private disk explicitly: the default disk follows
// FILESYSTEM_DISK, and a `public` default would web-expose the file.
\Illuminate\Support\Facades\Storage::disk('local')->put($path, $clean['bytes']);
$attrs += [
'file_path' => $path,
'original_name' => $validated['value']->getClientOriginalName(),
'mime_type' => $clean['mime'],
'file_size' => strlen($clean['bytes']),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
$attrs += $this->scalarOutputAttrs($request, $output);
}
} catch (\InvalidArgumentException $e) {
return back()->with('error', __('The uploaded file is not a valid image.'));
} catch (\Illuminate\Validation\ValidationException $e) {
throw $e;
}

// Overwrite: soft-delete any live value, then insert the new one (keeps the
// partial-unique index happy and preserves the prior value as audit). Both
// steps run in one transaction with the live rows locked, so a failed insert
// can't leave the output with no value and two concurrent posts can't both
// pass the delete and then collide on the partial-unique index.
\Illuminate\Support\Facades\DB::transaction(function () use ($batchStep, $output, $attrs) {
BatchStepOutputValue::where('batch_step_id', $batchStep->id)
->where('output_id', $output->id)
->lockForUpdate()
->get()->each->delete();
BatchStepOutputValue::create($attrs);
});

return back()->with('success', __('Output recorded'));
}

/**
* Validate + map a scalar output value to its typed column.
*
* @return array<string, mixed>
*/
private function scalarOutputAttrs(Request $request, TemplateStepOutput $output): array
{
return match ($output->value_type) {
TemplateStepOutput::TYPE_NUMBER => [
'value_number' => $request->validate(['value' => ['required', 'numeric']])['value'],
],
TemplateStepOutput::TYPE_BOOLEAN => [
// Require the field: without it a missing value would silently record
// `false` and satisfy the required-output gate. The UI posts '1'/'0'.
'value_boolean' => (bool) $request->validate([
'value' => ['required', 'boolean'],
])['value'],
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
TemplateStepOutput::TYPE_DATE => [
'value_date' => $request->validate(['value' => ['required', 'date']])['value'],
],
TemplateStepOutput::TYPE_SELECT => [
'value_text' => $request->validate([
'value' => ['required', \Illuminate\Validation\Rule::in($output->options ?? [])],
])['value'],
],
default => [
'value_text' => $request->validate(['value' => ['required', 'string', 'max:5000']])['value'],
],
};
}

/**
* Serve an operator-recorded output picture (private disk, line-scoped, safe
* inline mime + nosniff). Mirrors showDocumentFile().
*/
public function showOutputFile(Request $request, BatchStepOutputValue $batchStepOutputValue)
{
$batchStepOutputValue->loadMissing('batchStep');
$step = $batchStepOutputValue->batchStep;

if (! $step || ! $this->stepBelongsToSelectedLine($request, $step)) {
abort(403);
}
abort_unless($batchStepOutputValue->file_path && \Illuminate\Support\Facades\Storage::disk('local')->exists($batchStepOutputValue->file_path), 404);

$inlineSafe = ['image/png', 'image/jpeg', 'image/webp'];
$mime = $batchStepOutputValue->mime_type ?? 'application/octet-stream';
$disposition = in_array($mime, $inlineSafe, true) ? 'inline' : 'attachment';

return response()->file(\Illuminate\Support\Facades\Storage::disk('local')->path($batchStepOutputValue->file_path), [
'Content-Type' => $mime,
'Content-Disposition' => $disposition.'; filename="'.addslashes($batchStepOutputValue->original_name ?? 'output').'"',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, max-age=3600',
]);
}

/**
* Skip an optional or variant step. Reason is optional and stored for audit.
*/
Expand Down
Loading
Loading