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
30 changes: 21 additions & 9 deletions backend/app/Http/Controllers/Web/Operator/BatchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ public function recordOutput(Request $request, BatchStep $batchStep, TemplateSte
$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'];
\Illuminate\Support\Facades\Storage::put($path, $clean['bytes']);
// 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(),
Expand All @@ -296,11 +298,17 @@ public function recordOutput(Request $request, BatchStep $batchStep, TemplateSte
}

// Overwrite: soft-delete any live value, then insert the new one (keeps the
// partial-unique index happy and preserves the prior value as audit).
BatchStepOutputValue::where('batch_step_id', $batchStep->id)
->where('output_id', $output->id)
->get()->each->delete();
BatchStepOutputValue::create($attrs);
// 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'));
}
Expand All @@ -317,7 +325,11 @@ private function scalarOutputAttrs(Request $request, TemplateStepOutput $output)
'value_number' => $request->validate(['value' => ['required', 'numeric']])['value'],
],
TemplateStepOutput::TYPE_BOOLEAN => [
'value_boolean' => $request->boolean('value'),
// 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'],
],
TemplateStepOutput::TYPE_DATE => [
'value_date' => $request->validate(['value' => ['required', 'date']])['value'],
Expand Down Expand Up @@ -345,13 +357,13 @@ public function showOutputFile(Request $request, BatchStepOutputValue $batchStep
if (! $step || ! $this->stepBelongsToSelectedLine($request, $step)) {
abort(403);
}
abort_unless($batchStepOutputValue->file_path && \Illuminate\Support\Facades\Storage::exists($batchStepOutputValue->file_path), 404);
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::path($batchStepOutputValue->file_path), [
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

namespace App\Http\Requests\Api\V1;

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

class StoreTemplateStepRequest extends FormRequest
{
use ValidatesEquipmentParameters;

public function authorize(): bool
{
return true;
Expand All @@ -21,7 +24,7 @@ public function rules(): array
'estimated_duration_minutes' => ['nullable', 'integer', 'min:0'],
'setup_time_minutes' => ['nullable', 'integer', 'min:0'],
'run_time_per_unit_minutes' => ['nullable', 'numeric', 'min:0'],
'parameters' => ['nullable', 'array'],
'parameters' => ['nullable', 'array', self::keyValueMapRule()],
'parameters.*' => ['nullable', 'string', 'max:1000'],
'required_operators' => ['nullable', 'integer', 'min:1'],
'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

namespace App\Http\Requests\Api\V1;

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

class UpdateTemplateStepRequest extends FormRequest
{
use ValidatesEquipmentParameters;

public function authorize(): bool
{
return true;
Expand All @@ -20,7 +23,7 @@ public function rules(): array
'estimated_duration_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'],
'setup_time_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'],
'run_time_per_unit_minutes' => ['sometimes', 'nullable', 'numeric', 'min:0'],
'parameters' => ['sometimes', 'nullable', 'array'],
'parameters' => ['sometimes', 'nullable', 'array', self::keyValueMapRule()],
'parameters.*' => ['nullable', 'string', 'max:1000'],
'required_operators' => ['sometimes', 'nullable', 'integer', 'min:1'],
'workstation_id' => ['sometimes', 'nullable', 'integer', 'exists:workstations,id'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Http\Requests\Concerns;

use Closure;

/**
* Shared rule for step "equipment parameters": they must be a flat key:value map
* (`{"temperature_c":"250"}`), never a positional list (`["250"]`). The `array`
* rule alone accepts both, and a list would silently survive into the equipment
* snapshot with meaningless integer keys.
*/
trait ValidatesEquipmentParameters
{
protected static function keyValueMapRule(): Closure
{
return static function (string $attribute, mixed $value, Closure $fail): void {
if (is_array($value) && $value !== [] && array_is_list($value)) {
$fail(__('The :attribute field must be a key:value map, not a list.', [
'attribute' => $attribute,
]));
}
};
}
}
7 changes: 6 additions & 1 deletion backend/app/Http/Requests/StoreTemplateStepOutputRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@ public function rules(): array
public function withValidator($validator): void
{
$validator->after(function ($v) {
// Explicit non-blank predicate: PHP's default array_filter would drop the
// string "0", wrongly rejecting a select whose only option is "0".
if ($this->input('value_type') === TemplateStepOutput::TYPE_SELECT
&& empty(array_filter((array) $this->input('options', [])))) {
&& empty(array_filter(
(array) $this->input('options', []),
static fn ($option) => trim((string) $option) !== '',
))) {
$v->errors()->add('options', __('A select output needs at least one option.'));
}
});
Expand Down
5 changes: 4 additions & 1 deletion backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Requests\Web\Admin;

use App\Http\Requests\Concerns\ValidatesEquipmentParameters;
use App\Models\TemplateStep;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Expand All @@ -12,6 +13,8 @@
*/
abstract class TemplateStepRequest extends FormRequest
{
use ValidatesEquipmentParameters;

public function authorize(): bool
{
return true;
Expand All @@ -27,7 +30,7 @@ public function rules(): array
'setup_time_minutes' => 'nullable|integer|min:0',
'run_time_per_unit_minutes' => 'nullable|numeric|min:0',
// Equipment key:value parameters (temperature, humidity, …) β€” a flat map.
'parameters' => 'nullable|array',
'parameters' => ['nullable', 'array', self::keyValueMapRule()],
'parameters.*' => 'nullable|string|max:1000',
'required_operators' => 'nullable|integer|min:1',
'workstation_id' => 'nullable|exists:workstations,id',
Expand Down
3 changes: 2 additions & 1 deletion backend/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5645,5 +5645,6 @@
"Picture": "Picture",
"options, comma-separated": "options, comma-separated",
"Take / upload photo": "Take / upload photo",
"Enter value…": "Enter value…"
"Enter value…": "Enter value…",
"The :attribute field must be a key:value map, not a list.": "The :attribute field must be a key:value map, not a list."
}
3 changes: 2 additions & 1 deletion backend/lang/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -5645,5 +5645,6 @@
"Picture": "ZdjΔ™cie",
"options, comma-separated": "opcje, po przecinku",
"Take / upload photo": "ZrΓ³b / przeΕ›lij zdjΔ™cie",
"Enter value…": "Wpisz wartość…"
"Enter value…": "Wpisz wartość…",
"The :attribute field must be a key:value map, not a list.": "Pole :attribute musi być mapą klucz:wartość, a nie listą."
}
12 changes: 12 additions & 0 deletions backend/tests/Feature/Api/ProcessTemplateApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ public function test_parameters_must_be_an_object(): void
])->assertStatus(422)->assertJsonValidationErrors('parameters');
}

public function test_parameters_reject_a_positional_list(): void
{
$pt = ProductType::factory()->create();
$template = ProcessTemplate::factory()->create(['product_type_id' => $pt->id]);

// A positional list ["250","40"] passes the `array` rule but is not a
// key:value recipe β€” it must be rejected, not frozen with integer keys.
$this->authAdmin()->postJson("/api/v1/process-templates/{$template->id}/steps", [
'name' => 'Bad', 'parameters' => ['250', '40'],
])->assertStatus(422)->assertJsonValidationErrors('parameters');
}

public function test_step_numbers_auto_increment(): void
{
$template = ProcessTemplate::factory()->create();
Expand Down
29 changes: 29 additions & 0 deletions backend/tests/Feature/StepTypedOutputsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,35 @@ public function test_operator_records_a_number_output(): void
$this->assertSame($this->operator->id, $value->recorded_by_id);
}

public function test_boolean_output_requires_an_explicit_value(): void
{
$output = $this->def(['key' => 'passed', 'value_type' => 'boolean']);
$url = "/operator/batch-step/{$this->batchStep->id}/outputs/{$output->id}";

// Omitting `value` must 422 β€” not silently record false and pass the gate.
$this->asOperator()->post($url, [])->assertSessionHasErrors('value');
$this->assertDatabaseCount('batch_step_output_values', 0);

// The UI posts '0' for a ticked-off false; that records false explicitly.
$this->asOperator()->post($url, ['value' => '0'])->assertRedirect();
$this->assertFalse((bool) BatchStepOutputValue::firstWhere('output_id', $output->id)->value_boolean);
}

public function test_select_output_accepts_zero_as_its_only_option(): void
{
$base = "/admin/product-types/{$this->productType->id}/process-templates/{$this->template->id}";

// "0" is a legitimate option; the non-blank filter must not drop it.
$this->actingAs($this->admin)->post("{$base}/outputs", [
'template_step_id' => $this->templateStep->id,
'key' => 'flag', 'label' => 'Flag', 'value_type' => 'select', 'options' => ['0'],
])->assertRedirect()->assertSessionHasNoErrors();

$this->assertDatabaseHas('template_step_outputs', [
'process_template_id' => $this->template->id, 'key' => 'flag', 'value_type' => 'select',
]);
}

public function test_operator_uploads_a_picture_output_and_it_serves_back(): void
{
$output = $this->def(['key' => 'output_qcpic', 'value_type' => 'picture']);
Expand Down
Loading