diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..c0936342 --- /dev/null +++ b/.coderabbit.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f7384d..4b4ab8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/Http/Controllers/Api/V1/StepOutputController.php b/backend/app/Http/Controllers/Api/V1/StepOutputController.php new file mode 100644 index 00000000..a8ae9b05 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/StepOutputController.php @@ -0,0 +1,71 @@ +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', + ]); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php b/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php index 7dc1fec1..175357eb 100644 --- a/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php @@ -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() @@ -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, @@ -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, diff --git a/backend/app/Http/Controllers/Web/Admin/TemplateStepOutputController.php b/backend/app/Http/Controllers/Web/Admin/TemplateStepOutputController.php new file mode 100644 index 00000000..dcbbb9f0 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/TemplateStepOutputController.php @@ -0,0 +1,59 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Web/Operator/BatchController.php b/backend/app/Http/Controllers/Web/Operator/BatchController.php index 895f9417..f1cdccb7 100644 --- a/backend/app/Http/Controllers/Web/Operator/BatchController.php +++ b/backend/app/Http/Controllers/Web/Operator/BatchController.php @@ -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; @@ -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']), + ]; + } 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 + */ + 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'], + ], + 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. */ diff --git a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php index 84158be2..10fb58da 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php @@ -234,6 +234,7 @@ public function show(Request $request, WorkOrder $workOrder) 'batches.steps.confirmedBy', 'batches.steps.documents.validatedBy', 'batches.steps.checklistCompletions.checkedBy', + 'batches.steps.outputValues.recordedBy', 'batches.workstation', 'batches.processConfirmations.confirmedBy', 'batches.qualityChecks.samples', @@ -306,6 +307,7 @@ public function show(Request $request, WorkOrder $workOrder) // reach in-flight orders. $stepMedia = []; // step_number => [ {id, url, media_type, title, ...} ] $stepChecklists = []; // step_number => [ {id, label, is_required} ] + $stepOutputs = []; // step_number => [ {id, key, label, value_type, unit, options, is_required} ] if ($templateId) { foreach (TemplateStepMedia::where('process_template_id', $templateId) ->whereNotNull('template_step_id') @@ -339,6 +341,25 @@ public function show(Request $request, WorkOrder $workOrder) 'is_required' => $it->is_required, ]; } + + foreach (\App\Models\TemplateStepOutput::where('process_template_id', $templateId) + ->whereNotNull('template_step_id') + ->with('templateStep:id,step_number') + ->orderBy('sort_order')->orderBy('id')->get() as $o) { + $num = $o->templateStep?->step_number; + if ($num === null) { + continue; + } + $stepOutputs[$num][] = [ + 'id' => $o->id, + 'key' => $o->key, + 'label' => $o->label, + 'value_type' => $o->value_type, + 'unit' => $o->unit, + 'options' => $o->options ?? [], + 'is_required' => $o->is_required, + ]; + } } $issueCustomFields = app(\App\Services\CustomFieldService::class)->clientConfig('issue'); @@ -348,6 +369,6 @@ public function show(Request $request, WorkOrder $workOrder) // documents` on the client (Operator has it; see the seeder). $engineeringDocuments = $workOrder->frozenEngineeringDocuments(); - return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'issueCustomFields', 'engineeringDocuments')); + return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'stepOutputs', 'issueCustomFields', 'engineeringDocuments')); } } diff --git a/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php b/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php index 8e1a39b5..e9dce6b8 100644 --- a/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php @@ -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; @@ -21,6 +24,8 @@ 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', self::keyValueMapRule()], + 'parameters.*' => ['nullable', 'string', 'max:1000'], 'required_operators' => ['nullable', 'integer', 'min:1'], 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], 'workstation_type_id' => ['nullable', 'integer', Rule::exists('workstation_types', 'id')->where('is_active', true)->whereNull('deleted_at')], diff --git a/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php b/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php index 2a848160..8d1d6867 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php @@ -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; @@ -20,6 +23,8 @@ 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', self::keyValueMapRule()], + 'parameters.*' => ['nullable', 'string', 'max:1000'], 'required_operators' => ['sometimes', 'nullable', 'integer', 'min:1'], 'workstation_id' => ['sometimes', 'nullable', 'integer', 'exists:workstations,id'], 'workstation_type_id' => ['sometimes', 'nullable', 'integer', Rule::exists('workstation_types', 'id')->where('is_active', true)->whereNull('deleted_at')], diff --git a/backend/app/Http/Requests/Concerns/ValidatesEquipmentParameters.php b/backend/app/Http/Requests/Concerns/ValidatesEquipmentParameters.php new file mode 100644 index 00000000..384b9fc8 --- /dev/null +++ b/backend/app/Http/Requests/Concerns/ValidatesEquipmentParameters.php @@ -0,0 +1,25 @@ + $attribute, + ])); + } + }; + } +} diff --git a/backend/app/Http/Requests/StoreTemplateStepOutputRequest.php b/backend/app/Http/Requests/StoreTemplateStepOutputRequest.php new file mode 100644 index 00000000..599cd47b --- /dev/null +++ b/backend/app/Http/Requests/StoreTemplateStepOutputRequest.php @@ -0,0 +1,47 @@ + ['required', 'integer', 'exists:template_steps,id'], + 'key' => ['required', 'string', 'max:100', 'regex:/^[A-Za-z0-9_.-]+$/'], + 'label' => ['required', 'string', 'max:255'], + 'value_type' => ['required', Rule::in(TemplateStepOutput::VALUE_TYPES)], + 'unit' => ['nullable', 'string', 'max:30'], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'is_required' => ['sometimes', 'boolean'], + ]; + } + + 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', []), + static fn ($option) => trim((string) $option) !== '', + ))) { + $v->errors()->add('options', __('A select output needs at least one option.')); + } + }); + } +} diff --git a/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php b/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php index b52dcec0..2303584a 100644 --- a/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php +++ b/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php @@ -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; @@ -12,6 +13,8 @@ */ abstract class TemplateStepRequest extends FormRequest { + use ValidatesEquipmentParameters; + public function authorize(): bool { return true; @@ -26,6 +29,9 @@ 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', + // Equipment key:value parameters (temperature, humidity, …) — a flat map. + 'parameters' => ['nullable', 'array', self::keyValueMapRule()], + 'parameters.*' => 'nullable|string|max:1000', 'required_operators' => 'nullable|integer|min:1', 'workstation_id' => 'nullable|exists:workstations,id', 'workstation_type_id' => ['nullable', Rule::exists('workstation_types', 'id')->where('is_active', true)->whereNull('deleted_at')], diff --git a/backend/app/Models/BatchStep.php b/backend/app/Models/BatchStep.php index 1add1fec..eede194c 100644 --- a/backend/app/Models/BatchStep.php +++ b/backend/app/Models/BatchStep.php @@ -157,11 +157,12 @@ public function documents(): HasMany return $this->hasMany(BatchStepDocument::class); } - /** Soft-deleting a step cascades to its attached documents. */ + /** Soft-deleting a step cascades to its attached documents and recorded output values. */ public function softDeleteCascades(): array { return [ [BatchStepDocument::class, 'batch_step_id'], + [BatchStepOutputValue::class, 'batch_step_id'], ]; } @@ -171,6 +172,12 @@ public function checklistCompletions(): HasMany return $this->hasMany(BatchStepChecklistCompletion::class); } + /** Typed output values recorded against this step. */ + public function outputValues(): HasMany + { + return $this->hasMany(BatchStepOutputValue::class); + } + /** * Labels of the step's required checklist items (defined on the template * step, resolved by template id + step number) that have not been ticked on @@ -198,6 +205,31 @@ public function pendingRequiredChecklistLabels(): \Illuminate\Support\Collection return $required->reject(fn ($label, $id) => in_array($id, $done, true))->values(); } + /** + * Labels of required typed outputs on this step that the operator has not yet + * recorded. Resolved live from the work-order snapshot's template, mirroring + * pendingRequiredChecklistLabels(). Empty when nothing is pending. + */ + public function pendingRequiredOutputs(): \Illuminate\Support\Collection + { + $templateId = $this->batch?->workOrder?->process_snapshot['template_id'] ?? null; + if (! $templateId) { + return collect(); + } + + $required = TemplateStepOutput::where('process_template_id', $templateId) + ->where('is_required', true) + ->whereHas('templateStep', fn ($q) => $q->where('step_number', $this->step_number)) + ->pluck('label', 'id'); + if ($required->isEmpty()) { + return collect(); + } + + $recorded = $this->outputValues()->pluck('output_id')->all(); + + return $required->reject(fn ($label, $id) => in_array($id, $recorded, true))->values(); + } + /** * Mandatory, validatable documents on this step that have not been validated * yet - the documents that block completion. Empty when nothing blocks. diff --git a/backend/app/Models/BatchStepOutputValue.php b/backend/app/Models/BatchStepOutputValue.php new file mode 100644 index 00000000..5c13e4de --- /dev/null +++ b/backend/app/Models/BatchStepOutputValue.php @@ -0,0 +1,85 @@ + 'decimal:6', + 'value_boolean' => 'boolean', + 'value_date' => 'date', + 'file_size' => 'integer', + 'recorded_at' => 'datetime', + ]; + } + + public function batchStep(): BelongsTo + { + return $this->belongsTo(BatchStep::class); + } + + public function output(): BelongsTo + { + return $this->belongsTo(TemplateStepOutput::class, 'output_id'); + } + + public function recordedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_id'); + } + + /** Authenticated URL to the recorded picture (null for non-picture values). */ + public function getFileUrlAttribute(): ?string + { + return $this->file_path ? route('operator.batch-step-output.file', $this) : null; + } + + /** + * The plain recorded value for API/UI, resolved from the output's type. + * Pictures return null here (fetched via the file endpoint instead). + */ + public function typedValue(): mixed + { + return match ($this->output?->value_type) { + TemplateStepOutput::TYPE_NUMBER => $this->value_number === null ? null : (float) $this->value_number, + TemplateStepOutput::TYPE_BOOLEAN => $this->value_boolean, + TemplateStepOutput::TYPE_DATE => $this->value_date?->toDateString(), + TemplateStepOutput::TYPE_PICTURE => null, + default => $this->value_text, + }; + } +} diff --git a/backend/app/Models/ProcessTemplate.php b/backend/app/Models/ProcessTemplate.php index ec0b4faf..93d5eeaa 100644 --- a/backend/app/Models/ProcessTemplate.php +++ b/backend/app/Models/ProcessTemplate.php @@ -81,6 +81,12 @@ public function checklistItems(): HasMany return $this->hasMany(TemplateStepChecklistItem::class)->orderBy('sort_order')->orderBy('id'); } + /** Typed operator-output definitions across this template's steps. */ + public function outputs(): HasMany + { + return $this->hasMany(TemplateStepOutput::class)->orderBy('sort_order')->orderBy('id'); + } + /** * Generate a JSON snapshot of this template for work order storage. * This ensures work orders are immune to template changes. @@ -104,6 +110,7 @@ public function toSnapshot(): array 'workstation_id' => $step->workstation_id, 'workstation_name' => $step->workstation?->name, 'workstation_type_id' => $step->effectiveWorkstationType(), + 'parameters' => $step->effectiveParameters(), 'is_optional' => (bool) $step->is_optional, 'variant_group' => $step->variant_group, 'is_default_variant' => (bool) $step->is_default_variant, diff --git a/backend/app/Models/TemplateStep.php b/backend/app/Models/TemplateStep.php index d4361123..e3b5f191 100644 --- a/backend/app/Models/TemplateStep.php +++ b/backend/app/Models/TemplateStep.php @@ -29,6 +29,7 @@ class TemplateStep extends Model 'workstation_type_id', 'setup_time_minutes', 'run_time_per_unit_minutes', + 'parameters', 'is_optional', 'variant_group', 'is_default_variant', @@ -41,6 +42,7 @@ protected function casts(): array 'estimated_duration_minutes' => 'integer', 'setup_time_minutes' => 'integer', 'run_time_per_unit_minutes' => 'decimal:2', + 'parameters' => 'array', 'required_operators' => 'integer', 'min_duration_minutes' => 'integer', 'requires_confirmation' => 'boolean', @@ -102,12 +104,19 @@ public function checklistItems(): HasMany return $this->hasMany(TemplateStepChecklistItem::class)->orderBy('sort_order')->orderBy('id'); } - /** Soft-deleting a step cascades to its rich-instruction media and checklist items. */ + /** Typed operator-output definitions on this step. */ + public function outputs(): HasMany + { + return $this->hasMany(TemplateStepOutput::class)->orderBy('sort_order')->orderBy('id'); + } + + /** Soft-deleting a step cascades to its rich-instruction media, checklist items and outputs. */ public function softDeleteCascades(): array { return [ [TemplateStepMedia::class, 'template_step_id'], [TemplateStepChecklistItem::class, 'template_step_id'], + [TemplateStepOutput::class, 'template_step_id'], ]; } @@ -151,4 +160,20 @@ public function effectiveWorkstationType(): ?int { return $this->workstation_type_id ?? $this->processSegment?->workstation_type_id; } + + /** + * Resolve the effective equipment parameters — the linked Process Segment + * supplies defaults, the step's own values override them key by key. Both + * absent yields an empty map. Used by the work-order snapshot so a client can + * read the recipe an external system needs to drive equipment. + * + * @return array + */ + public function effectiveParameters(): array + { + return array_merge( + $this->processSegment?->parameters ?? [], + $this->parameters ?? [], + ); + } } diff --git a/backend/app/Models/TemplateStepOutput.php b/backend/app/Models/TemplateStepOutput.php new file mode 100644 index 00000000..f4504e05 --- /dev/null +++ b/backend/app/Models/TemplateStepOutput.php @@ -0,0 +1,77 @@ + 'array', + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function processTemplate(): BelongsTo + { + return $this->belongsTo(ProcessTemplate::class); + } + + public function templateStep(): BelongsTo + { + return $this->belongsTo(TemplateStep::class); + } + + public function values(): HasMany + { + return $this->hasMany(BatchStepOutputValue::class, 'output_id'); + } +} diff --git a/backend/app/Services/ProcessTemplate/SnapshotService.php b/backend/app/Services/ProcessTemplate/SnapshotService.php index 99c968b0..bb8f577b 100644 --- a/backend/app/Services/ProcessTemplate/SnapshotService.php +++ b/backend/app/Services/ProcessTemplate/SnapshotService.php @@ -39,6 +39,7 @@ public function createSnapshot(ProcessTemplate $template): array 'required_operators' => $step->effectiveRequiredOperators(), 'workstation_id' => $step->workstation_id, 'workstation_type_id' => $step->effectiveWorkstationType(), + 'parameters' => $step->effectiveParameters(), ]; })->toArray(), 'bom' => $template->bomItems->map(function ($item) { diff --git a/backend/app/Services/WorkOrder/BatchService.php b/backend/app/Services/WorkOrder/BatchService.php index a23f1448..4b4222e1 100644 --- a/backend/app/Services/WorkOrder/BatchService.php +++ b/backend/app/Services/WorkOrder/BatchService.php @@ -116,6 +116,16 @@ public function completeStep(BatchStep $step, User $user, array $data = []): Bat )); } + // Output control: required typed outputs on this step must be recorded + // by the operator before it can be completed. + $pendingOutputs = $step->pendingRequiredOutputs(); + if ($pendingOutputs->isNotEmpty()) { + throw new \Exception(__( + 'This step is blocked: the required output(s) ":items" must be recorded before it can be completed.', + ['items' => $pendingOutputs->implode(', ')], + )); + } + // Read-confirmation control: a step flagged as carrying critical // instructions must be acknowledged (read-confirmed) by the operator // before it can be completed. diff --git a/backend/config/version.php b/backend/config/version.php index 15d34dd8..3f2c0df5 100644 --- a/backend/config/version.php +++ b/backend/config/version.php @@ -1,6 +1,6 @@ 'v0.20.0', + 'current' => 'v0.21.0', 'archive_url' => env('UPDATE_ARCHIVE_URL', 'https://github.com/Mes-Open/OpenMes/archive/refs/tags/{version}.zip'), ]; diff --git a/backend/database/migrations/2026_08_18_100000_add_parameters_to_template_steps.php b/backend/database/migrations/2026_08_18_100000_add_parameters_to_template_steps.php new file mode 100644 index 00000000..37c0f91e --- /dev/null +++ b/backend/database/migrations/2026_08_18_100000_add_parameters_to_template_steps.php @@ -0,0 +1,28 @@ +json('parameters')->nullable()->after('run_time_per_unit_minutes'); + }); + } + + public function down(): void + { + Schema::table('template_steps', function (Blueprint $table) { + $table->dropColumn('parameters'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_18_110000_create_template_step_outputs_table.php b/backend/database/migrations/2026_08_18_110000_create_template_step_outputs_table.php new file mode 100644 index 00000000..f78dd6d2 --- /dev/null +++ b/backend/database/migrations/2026_08_18_110000_create_template_step_outputs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('process_template_id')->constrained()->cascadeOnDelete(); + $table->foreignId('template_step_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('key', 100); // e.g. output_qcpic + $table->string('label', 255); + $table->string('value_type', 20); // text|number|boolean|select|date|picture + $table->string('unit', 30)->nullable(); // for number + $table->json('options')->nullable(); // for select + $table->boolean('is_required')->default(false); + $table->integer('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + $table->foreignId('deleted_by_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->index(['process_template_id', 'template_step_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('template_step_outputs'); + } +}; diff --git a/backend/database/migrations/2026_08_18_110001_create_batch_step_output_values_table.php b/backend/database/migrations/2026_08_18_110001_create_batch_step_output_values_table.php new file mode 100644 index 00000000..c968f1b7 --- /dev/null +++ b/backend/database/migrations/2026_08_18_110001_create_batch_step_output_values_table.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('batch_step_id')->constrained()->cascadeOnDelete(); + $table->foreignId('output_id')->constrained('template_step_outputs')->cascadeOnDelete(); + + // Typed scalar value (exactly one is set per the output's value_type). + $table->text('value_text')->nullable(); + $table->decimal('value_number', 20, 6)->nullable(); + $table->boolean('value_boolean')->nullable(); + $table->date('value_date')->nullable(); + + // Picture value. + $table->string('file_path', 1024)->nullable(); + $table->string('original_name', 255)->nullable(); + $table->string('mime_type', 100)->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + + $table->foreignId('recorded_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('recorded_at'); + $table->timestamps(); + $table->softDeletes(); + $table->foreignId('deleted_by_id')->nullable()->constrained('users')->nullOnDelete(); + }); + + // One live value per (step, output); a soft-deleted row doesn't block a + // re-record. Partial unique works on both Postgres and SQLite. + DB::statement('CREATE UNIQUE INDEX batch_step_output_values_unique ON batch_step_output_values (batch_step_id, output_id) WHERE deleted_at IS NULL'); + } + + public function down(): void + { + Schema::dropIfExists('batch_step_output_values'); + } +}; diff --git a/backend/lang/en.json b/backend/lang/en.json index 882b6004..5c37968f 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -5628,5 +5628,23 @@ "Material returned to stock": "Material returned to stock", "Material reclassified": "Material reclassified", "Work order :code created.": "Work order :code created.", - "Customers & order data": "Customers & order data" + "Customers & order data": "Customers & order data", + "Equipment parameters": "Equipment parameters", + "Key:value settings the equipment needs (e.g. temperature, humidity). Read via API.": "Key:value settings the equipment needs (e.g. temperature, humidity). Read via API.", + "key (e.g. temperature_c)": "key (e.g. temperature_c)", + "+ Add parameter": "+ Add parameter", + "This step is blocked: the required output(s) \":items\" must be recorded before it can be completed.": "This step is blocked: the required output(s) \":items\" must be recorded before it can be completed.", + "Output added.": "Output added.", + "Output removed.": "Output removed.", + "Output recorded": "Output recorded", + "A select output needs at least one option.": "A select output needs at least one option.", + "The uploaded file is not a valid image.": "The uploaded file is not a valid image.", + "Operator outputs": "Operator outputs", + "key (e.g. output_qcpic)": "key (e.g. output_qcpic)", + "Yes/No": "Yes/No", + "Picture": "Picture", + "options, comma-separated": "options, comma-separated", + "Take / upload photo": "Take / upload photo", + "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." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 2627caf1..75d37eb0 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -5628,5 +5628,23 @@ "Material returned to stock": "Materiał zwrócony do magazynu", "Material reclassified": "Materiał przeklasyfikowany", "Work order :code created.": "Zlecenie :code utworzone.", - "Customers & order data": "Klienci i dane zamówień" + "Customers & order data": "Klienci i dane zamówień", + "Equipment parameters": "Parametry sprzętu", + "Key:value settings the equipment needs (e.g. temperature, humidity). Read via API.": "Ustawienia klucz:wartość potrzebne maszynie (np. temperatura, wilgotność). Odczyt przez API.", + "key (e.g. temperature_c)": "klucz (np. temperature_c)", + "+ Add parameter": "+ Dodaj parametr", + "This step is blocked: the required output(s) \":items\" must be recorded before it can be completed.": "Ten krok jest zablokowany: wymagane wyjścia \":items\" muszą zostać zapisane przed jego zakończeniem.", + "Output added.": "Wyjście dodane.", + "Output removed.": "Wyjście usunięte.", + "Output recorded": "Wyjście zapisane", + "A select output needs at least one option.": "Wyjście typu wybór wymaga co najmniej jednej opcji.", + "The uploaded file is not a valid image.": "Przesłany plik nie jest prawidłowym obrazem.", + "Operator outputs": "Wyjścia operatora", + "key (e.g. output_qcpic)": "klucz (np. output_qcpic)", + "Yes/No": "Tak/Nie", + "Picture": "Zdjęcie", + "options, comma-separated": "opcje, po przecinku", + "Take / upload photo": "Zrób / prześlij zdjęcie", + "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ą." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 35d9b586..a39d0772 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -36,8 +36,8 @@ "— Select role —": "— Chọn vai trò —", "— Select site —": "— Chọn cơ sở —", "— Select type —": "— Chọn loại —", - "— select work order —": "— chọn lệnh làm việc —", - "— Select work order —": "— Chọn lệnh làm việc —", + "— select work order —": "— chọn lệnh sản xuất —", + "— Select work order —": "— Chọn lệnh sản xuất —", "— Select workstation —": "— Chọn trạm làm việc —", "— Source —": "— Nguồn —", "— Unassigned —": "— Chưa được chỉ định —", @@ -87,7 +87,7 @@ "+ Install Module": "+ Cài đặt mô-đun", "+ New Custom Field": "+ Trường tùy chỉnh mới", "+ New Pallet": "+ Pallet mới", - "+ New Work Order": "+ Lệnh làm việc mới", + "+ New Work Order": "+ Lệnh sản xuất mới", "≥ 85% (World-class)": "≥ 85% (Đạt chuẩn thế giới)", "✓ Passwords match": "✓ Mật khẩu trùng khớp", "0 results": "0 kết quả", @@ -113,7 +113,7 @@ "A process template defines the production steps (recipe) for your product. Add each step in the order they happen during production.": "Quy trình mẫu định nghĩa các bước sản xuất (công thức) cho sản phẩm của bạn. Thêm từng bước theo thứ tự diễn ra trong quá trình sản xuất.", "A production line is a physical area where manufacturing happens. Start by creating your first one.": "Dây chuyền sản xuất là khu vực vật lý nơi diễn ra hoạt động sản xuất. Bắt đầu bằng cách tạo dây chuyền đầu tiên của bạn.", "A required quality control is outstanding for this batch and must be completed first.": "Kiểm soát chất lượng bắt buộc chưa được thực hiện đối với lô này và phải được hoàn thành trước.", - "A work order represents a production batch to manufacture. Create your first one.": "Lệnh làm việc đại diện cho một lô sản xuất cần chế tạo. Hãy tạo lệnh làm việc đầu tiên của bạn.", + "A work order represents a production batch to manufacture. Create your first one.": "Lệnh sản xuất đại diện cho một lô sản xuất cần chế tạo. Hãy tạo lệnh sản xuất đầu tiên của bạn.", "A%": "A%", "Absence deleted successfully.": "Đã xóa thành công sự vắng mặt.", "Absence recorded successfully.": "Sự vắng mặt được ghi lại thành công.", @@ -154,7 +154,7 @@ "Active (ready for production)": "Đang hoạt động (sẵn sàng cho sản xuất)", "Active (start listening on daemon start)": "Đang hoạt động (bắt đầu nghe khi bắt đầu daemon)", "Active (start polling on daemon start)": "Đang hoạt động (bắt đầu bỏ phiếu khi bắt đầu daemon)", - "Active (template is ready for use in work orders)": "Hoạt động (mẫu đã sẵn sàng để sử dụng trong các lệnh làm việc)", + "Active (template is ready for use in work orders)": "Hoạt động (mẫu đã sẵn sàng để sử dụng trong các lệnh sản xuất)", "Active (workstation is ready for use)": "Đang hoạt động (trạm làm việc đã sẵn sàng để sử dụng)", "Active downtime": "Thời gian ngừng hoạt động", "Active Lines": "Dây chuyền hoạt động", @@ -291,7 +291,7 @@ "All Types": "Tất cả các loại", "All users": "Tất cả người dùng", "All Users": "Tất cả người dùng", - "All work orders": "Tất cả các lệnh làm việc", + "All work orders": "Tất cả các lệnh sản xuất", "All Workstations": "Tất cả các trạm làm việc", "ALL-ACCESS": "TRUY CẬP TẤT CẢ", "Allow operators to record more units than the planned quantity.": "Cho phép người vận hành ghi nhiều đơn vị hơn số lượng dự kiến.", @@ -321,7 +321,7 @@ "Anonymous": "Ẩn danh", "another workstation": "một trạm làm việc khác", "Any": "bất kỳ", - "Any extra field — stored as JSON on the work order": "Bất kỳ trường bổ sung nào — được lưu trữ dưới dạng JSON trên lệnh làm việc", + "Any extra field — stored as JSON on the work order": "Bất kỳ trường bổ sung nào — được lưu trữ dưới dạng JSON trên lệnh sản xuất", "Any line": "Bất kỳ dây chuyền nào", "Any product": "Bất kỳ sản phẩm nào", "Any workstation": "Bất kỳ trạm làm việc nào", @@ -356,7 +356,7 @@ "Assign": "Chỉ định", "Assign all rows to Production Line (optional)": "Chỉ định tất cả các dòng cho Dây chuyền sản xuất (tùy chọn)", "Assign barcodes to production work orders": "Gán mã vạch cho lệnh sản xuất", - "Assign barcodes to work orders": "Gán mã vạch cho lệnh làm việc", + "Assign barcodes to work orders": "Gán mã vạch cho lệnh sản xuất", "Assign each column to a material field.": "Gán mỗi cột cho một trường vật liệu.", "Assign each CSV column to a system field or a custom key.": "Chỉ định mỗi cột CSV cho một trường hệ thống hoặc khóa tùy chỉnh.", "Assign New Operator": "Chỉ định người vận hành mới", @@ -385,10 +385,10 @@ "Auto-refreshing every 5s": "Tự động làm mới sau mỗi 5 giây", "Auto-scroll": "Tự động cuộn", "Auxiliary Material": "Vật liệu phụ trợ", - "AVAIL": "CÓ SẴN", + "AVAIL": "KHẢ DỤNG", "Avail / Recv": "Lịch phát sóng / Nhận", "Availability": "Khả dụng", - "Availability × Perf × Qual": "Sẵn có × Hoàn hảo × Chất lượng", + "Availability × Perf × Qual": "Khả dụng × Hiệu suất × Chất lượng", "available": "có sẵn", "avail": "hiện có", "Available": "Có sẵn", @@ -396,7 +396,7 @@ "Available hooks and events": "Các móc và sự kiện có sẵn", "Available hooks and events (HOOKS.md) ↗": "Móc nối (hooks) và sự kiện có sẵn (HOOKS.md) ↗", "Available keys:": "Các phím có sẵn:", - "Available Qty": "Tồn kho sẵn có", + "Available Qty": "Số lượng khả dụng", "Available system fields reference": "Tham chiếu các trường hệ thống có sẵn", "Available to operators": "Có sẵn cho các nhà khai thác", "Average": "trung bình", @@ -426,7 +426,7 @@ "Back to Templates": "Quay lại Mẫu", "Back to Users": "Quay lại Người dùng", "Back to Workstations": "Quay lại trạm làm việc", - "Backlog": "Hàng đợi", + "Backlog": "Hàng chờ", "Backup & Recovery": "Sao lưu & Phục hồi", "Backup deleted successfully.": "Đã xóa bản sao lưu thành công.", "Backup file does not contain database data (missing db_backup.json).": "File sao lưu không chứa dữ liệu database (thiếu db_backup.json).", @@ -473,7 +473,7 @@ "Blocked Orders": "Lệnh bị chặn", "Blocked since": "Bị chặn kể từ", "Blocked WO": "WO bị chặn", - "Blocked Work Orders": "Lệnh làm việc bị chặn", + "Blocked Work Orders": "Lệnh sản xuất bị chặn", "blocking": "chặn", "Blocking": "Chặn", "BLOCKING": "CHẶN", @@ -632,7 +632,7 @@ "Complete Setup": "Hoàn tất thiết lập", "Complete step": "Bước hoàn thành", "Complete this inspection? It cannot be edited afterwards.": "Hoàn thành đợt kiểm tra này? Bạn sẽ không thể chỉnh sửa sau đó.", - "Complete Work Order": "Hoàn thành lệnh làm việc", + "Complete Work Order": "Hoàn thành lệnh sản xuất", "completed": "hoàn thành", "Completed": "Đã hoàn thành", "COMPLETED": "ĐÃ HOÀN THÀNH", @@ -640,7 +640,7 @@ "Completed at": "Hoàn thành lúc", "Completed By": "Hoàn thành bởi", "Completed with errors": "Đã hoàn thành với lỗi", - "Completed Work Orders": "Lệnh làm việc đã hoàn thành", + "Completed Work Orders": "Lệnh sản xuất đã hoàn thành", "Completed, cancelled and rejected orders — full execution record.": "Các lệnh đã hoàn thành, bị hủy và bị từ chối - hồ sơ thực hiện đầy đủ.", "Completed:": "Đã hoàn thành:", "Completing…": "Đang hoàn thành…", @@ -649,8 +649,8 @@ "Completion %": "% hoàn thành", "Completion notes (optional):": "Ghi chú hoàn thành (tùy chọn):", "Completion Rate": "Tỷ lệ hoàn thành", - "Component": "Linh kiện / Vật tư", - "Component requirements exploded from planned work orders, netted against on-hand stock, with a shortage list.": "Nhu cầu linh kiện/vật tư được phân rã từ các lệnh sản xuất dự kiến, đối trừ với lượng tồn kho hiện có để lập danh sách thiếu hụt.", + "Component": "Linh kiện / Vật liệu", + "Component requirements exploded from planned work orders, netted against on-hand stock, with a shortage list.": "Nhu cầu linh kiện/vật liệu được phân rã từ các lệnh sản xuất dự kiến, đối trừ với lượng tồn kho hiện có để lập danh sách thiếu hụt.", "Components & production lines": "Linh kiện & dây chuyền sản xuất", "Components & serials used": "Linh kiện & serial được sử dụng", "Components appear here once lots are consumed against this product's batches.": "Các thành phần xuất hiện ở đây sau khi các lô nguyên liệu được tiêu thụ cho các lô sản phẩm này.", @@ -753,7 +753,7 @@ "Could not update": "Không thể cập nhật", "Could not update password": "Không thể cập nhật mật khẩu", "Count": "Đếm", - "counted as availability loss": "được tính là mất khả năng sẵn có", + "counted as availability loss": "được tính là tổn thất khả dụng", "Counter Reset": "Đặt lại bộ đếm", "Country (2-letter)": "Quốc gia (2 chữ cái)", "Country (ISO-2)": "Quốc gia (ISO-2)", @@ -780,7 +780,7 @@ "Create Factory": "Tạo nhà máy", "Create first schedule": "Tạo lịch trình đầu tiên", "Create First Shift": "Tạo ca đầu tiên", - "Create First Work Order": "Tạo lệnh làm việc đầu tiên", + "Create First Work Order": "Tạo lệnh sản xuất đầu tiên", "Create Full Backup": "Tạo bản sao lưu đầy đủ", "Create Issue Type": "Tạo loại vấn đề", "Create line": "Tạo dây chuyền", @@ -810,8 +810,8 @@ "Create User": "Tạo người dùng", "Create View Template": "Tạo mẫu xem", "Create Wage Group": "Tạo Nhóm Lương", - "Create work order": "Tạo lệnh làm việc", - "Create Work Order": "Tạo lệnh làm việc", + "Create work order": "Tạo lệnh sản xuất", + "Create Work Order": "Tạo lệnh sản xuất", "Create Worker": "Tạo công nhân", "Create worker profile": "Tạo hồ sơ công nhân", "Create Workstation": "Tạo Trạm làm việc", @@ -1066,7 +1066,7 @@ "Detail": "Chi tiết", "Details": "Chi tiết", "DETAILS": "CHI TIẾT", - "Determines how work orders are grouped for planning.": "Xác định cách nhóm các lệnh công việc để lập kế hoạch.", + "Determines how work orders are grouped for planning.": "Xác định cách nhóm các lệnh sản xuất để lập kế hoạch.", "Deviation": "Độ lệch", "Device": "Thiết bị", "Dimension": "Kích thước", @@ -1157,15 +1157,15 @@ "e.g. Lunch, Shift handover": "vd: Ăn trưa, Giao ca", "e.g. material": "Vd: vat_lieu", "e.g. Material": "Vd: Vật liệu", - "e.g. pcs, kg, l": "ví dụ: chiếc, kg, l", - "e.g. pcs, kg, l, m. Optional.": "ví dụ: chiếc, kg, l, m. Không bắt buộc.", + "e.g. pcs, kg, l": "ví dụ: sp, kg, l", + "e.g. pcs, kg, l, m. Optional.": "ví dụ: sp, kg, l, m. Không bắt buộc.", "e.g. PrestaShop Integration": "ví dụ. Tích hợp PrestaShop", "e.g. Quarterly Inspection — Lathe #3": "ví dụ: Kiểm tra hàng quý — Máy tiện số 3", "e.g. SITE-WAW-01": "ví dụ: COSO-WAW-01", "e.g. Waiting for parts": "VD: Đang chờ linh kiện", "e.g. Warsaw Plant": "ví dụ: Nhà máy Warsaw", "e.g. Weekly Lathe Lubrication": "ví dụ: Bôi trơn máy tiện hàng tuần", - "e.g., pcs, kg, m (optional)": "ví dụ: chiếc, kg, m (tùy chọn)", + "e.g., pcs, kg, m (optional)": "ví dụ: sp, kg, m (tùy chọn)", "e.g., Standard Assembly Process, Quality Inspection v2": "VD: Quy trình lắp ráp tiêu chuẩn, Kiểm tra chất lượng v2", "e.g., Widget Type A, Standard Component": "ví dụ: Loại tiện ích A, Thành phần tiêu chuẩn", "e.g., WIDGET-A, PROD-001": "ví dụ: WIDGET-A, PROD-001", @@ -1239,7 +1239,7 @@ "Edit View Template": "Chỉnh sửa Mẫu", "Edit Wage Group": "Chỉnh sửa nhóm lương", "Edit Webhook": "Sửa Webhook", - "Edit Work Order": "Chỉnh sửa lệnh làm việc", + "Edit Work Order": "Chỉnh sửa lệnh sản xuất", "Edit worker": "Chỉnh sửa công nhân", "Edit Worker": "Chỉnh sửa công nhân", "Edit Workstation": "Chỉnh sửa trạm làm việc", @@ -1502,7 +1502,7 @@ "Higher = sooner": "Cao hơn = sớm hơn", "History": "Lịch sử", "History & reasons": "Lịch sử & lý do", - "History of bulk work-order imports.": "Lịch sử nhập lệnh làm việc số lượng lớn.", + "History of bulk work-order imports.": "Lịch sử nhập lệnh sản xuất số lượng lớn.", "Hold": "Giữ", "Hold an EAN code up to the scanner…": "Đưa mã EAN vào máy quét…", "Hold Ctrl/Cmd to select multiple. Operators must have ALL of these skills to execute the segment.": "Giữ Ctrl/Cmd để chọn nhiều. Người vận hành phải có TẤT CẢ những kỹ năng này để thực hiện phân khúc.", @@ -1546,7 +1546,7 @@ "idle": "nhàn rỗi", "Idle": "Nhàn rỗi", "If no plan is selected, you can still record results but no criteria will be pre-filled.": "Nếu không chọn kế hoạch, bạn vẫn có thể ghi nhận kết quả nhưng các tiêu chí sẽ không được điền sẵn.", - "If selected, every imported work order will be assigned to this line, overriding any line_code column in the file.": "Nếu chọn, mọi lệnh làm việc được nhập sẽ được chỉ định cho dây chuyền này, ghi đè lên bất kỳ cột line_code nào trong tệp.", + "If selected, every imported work order will be assigned to this line, overriding any line_code column in the file.": "Nếu chọn, mọi lệnh sản xuất được nhập sẽ được chỉ định cho dây chuyền này, ghi đè lên bất kỳ cột line_code nào trong tệp.", "Ignore this column": "Bỏ qua cột này", "Image": "Hình ảnh", "Immutable": "bất biến", @@ -1562,7 +1562,7 @@ "Import Settings": "Cài đặt nhập", "Import Strategy": "Chiến lược nhập", "Import Summary": "Tóm tắt lượt nhập", - "Import work orders from a CSV, XLS or XLSX file with custom column mapping": "Nhập lệnh làm việc từ tệp CSV, XLS hoặc XLSX với cấu hình ánh xạ cột tùy chỉnh", + "Import work orders from a CSV, XLS or XLSX file with custom column mapping": "Nhập lệnh sản xuất từ tệp CSV, XLS hoặc XLSX với cấu hình ánh xạ cột tùy chỉnh", "imported": "đã nhập", "Imported CSV data": "Dữ liệu CSV đã nhập", "in :count :unit": "trong :count :unit", @@ -1741,7 +1741,7 @@ "Last used :time": "Lần sử dụng cuối cùng :time", "Late": "Muộn", "Later": "sau này", - "Latest work orders with status and progress": "Lệnh làm việc mới nhất với trạng thái và tiến độ", + "Latest work orders with status and progress": "Lệnh sản xuất mới nhất với trạng thái và tiến độ", "Layout": "Bố cục", "Lead time (days)": "Thời gian thực hiện (ngày)", "Lead Time (days)": "Thời gian thực hiện (ngày)", @@ -1775,7 +1775,7 @@ "Lines using": "Dây chuyền sử dụng", "Lines with active work orders can't be removed. Deactivate to hide from operator selection.": "Không thể xóa các dây chuyền có lệnh sản xuất đang hoạt động. Tắt để ẩn khỏi lựa chọn của người vận hành.", "Link to pallet (optional)": "Liên kết với pallet (tùy chọn)", - "Link to work order · optional": "Liên kết với lệnh làm việc · tùy chọn", + "Link to work order · optional": "Liên kết với lệnh sản xuất · tùy chọn", "Linked issue ID (optional)": "ID vấn đề được liên kết (tùy chọn)", "Linked Process Segment": "Phân đoạn quy trình được liên kết", "Linked to worker:": "Liên kết với công nhân:", @@ -1908,10 +1908,10 @@ "MARKS THE ORDER AS EFFECTIVELY COMPLETE": "ĐÁNH DẤU LỆNH LÀ HOÀN THÀNH HIỆU QUẢ", "MATCHED": "ĐÃ PHÙ HỢP", "Matching Logic": "Logic phù hợp", - "Material": "Vật liệu/Vật tư", + "Material": "Vật liệu", "MATERIAL": "VẬT LIỆU", "Material cost": "Chi phí vật liệu", - "Material lot": "Lô vật tư", + "Material lot": "Lô vật liệu", "Material Lot": "Lô vật liệu", "Material lot created.": "Lô vật chất được tạo ra.", "Material lot deleted.": "Lô vật liệu đã bị xóa.", @@ -1922,8 +1922,8 @@ "material name": "tên vật liệu", "Material Source": "Nguồn nguyên liệu", "Material type": "Loại vật liệu", - "Material Type": "Loại vật tư", - "Material, labor and additional cost per finished work order.": "Vật liệu, nhân công và chi phí bổ sung cho mỗi lệnh làm việc đã hoàn thành.", + "Material Type": "Loại vật liệu", + "Material, labor and additional cost per finished work order.": "Vật liệu, nhân công và chi phí bổ sung cho mỗi lệnh sản xuất đã hoàn thành.", "material(s) linked": "(các) tài liệu được liên kết", "Materials": "Vật liệu", "Materials (BOM)": "Vật liệu (BOM)", @@ -2076,10 +2076,10 @@ "New Wage Group": "Nhóm lương mới", "New Webhook": "Webhook mới", "New WO": "WO mới", - "New work order": "Lệnh làm việc mới", - "New Work Order": "Lệnh làm việc mới", - "NEW WORK ORDER": "LỆNH LÀM VIỆC MỚI", - "NEW WORK ORDERS START HERE": "LỆNH LÀM VIỆC MỚI BẮT ĐẦU TẠI ĐÂY", + "New work order": "Lệnh sản xuất mới", + "New Work Order": "Lệnh sản xuất mới", + "NEW WORK ORDER": "LỆNH SẢN XUẤT MỚI", + "NEW WORK ORDERS START HERE": "LỆNH SẢN XUẤT MỚI BẮT ĐẦU TẠI ĐÂY", "New worker": "Công nhân mới", "New Worker": "Công nhân mới", "New Workstation": "Trạm làm việc mới", @@ -2098,7 +2098,7 @@ "Next week": "Tuần tới", "Next: Process Template →": "Tiếp theo: Quy trình mẫu →", "Next: Product Type →": "Tiếp theo: Loại sản phẩm →", - "Next: Work Order →": "Tiếp theo: Lệnh làm việc →", + "Next: Work Order →": "Tiếp theo: Lệnh sản xuất →", "no": "không", "No": "Không", "No {{status}} issues": "Không có vấn đề về {{status}}", @@ -2112,7 +2112,7 @@ "No active step": "Không có bước hoạt động", "No active work order": "Không có lệnh sản xuất đang hoạt động", "No active work orders at the moment.": "Hiện tại không có lệnh sản xuất nào đang mở.", - "No active work orders.": "Không có lệnh làm việc đang hoạt động.", + "No active work orders.": "Không có lệnh sản xuất đang hoạt động.", "No active workers in the system.": "Không có công nhân đang hoạt động trong hệ thống.", "No activities planned for this day.": "Không có hoạt động nào được lên kế hoạch cho ngày này.", "No activity in this period.": "Không có hoạt động nào trong thời gian này.", @@ -2130,7 +2130,7 @@ "No automatic refresh — reload the page to see changes": "Không tự động làm mới - tải lại trang để xem các thay đổi", "No available lots": "Không có lô nào có sẵn", "No backups found.": "Không tìm thấy bản sao lưu nào.", - "No batches available for this work order.": "Không có lô nào khả dụng cho lệnh làm việc này.", + "No batches available for this work order.": "Không có lô nào khả dụng cho lệnh sản xuất này.", "No batches created yet": "Chưa có lô nào được tạo", "No batches recorded.": "Không có lô nào được ghi lại.", "No batches yet.": "Chưa có lô nào.", @@ -2309,7 +2309,7 @@ "No shift configured — using default window": "Không có cấu hình ca nào - sử dụng cửa sổ mặc định", "No shifts defined yet.": "Chưa có ca nào được xác định.", "No shifts yet.": "Chưa có ca nào.", - "No shortages — on-hand stock covers the planned work orders.": "Không có thiếu hụt - lượng tồn kho sẵn có đáp ứng đủ cho các lệnh sản xuất dự kiến.", + "No shortages — on-hand stock covers the planned work orders.": "Không có thiếu hụt - lượng tồn kho hiện có đáp ứng đủ cho các lệnh sản xuất dự kiến.", "No shortages.": "Không thiếu.", "No sites": "Không có cơ sở", "No sites yet": "Chưa có cơ sở nào", @@ -2355,9 +2355,9 @@ "No work orders in this range": "Không có lệnh sản xuất nào trong phạm vi này", "No work orders match this customer order.": "Không có lệnh sản xuất nào khớp với lệnh của khách hàng này.", "No work orders scheduled for this week.": "Không có lệnh sản xuất nào được lên lịch cho tuần này.", - "No work orders with assigned EAN codes": "Không có lệnh làm việc nào được gán mã EAN", - "No work orders yet": "Chưa có lệnh làm việc nào", - "No work orders yet.": "Chưa có lệnh làm việc nào.", + "No work orders with assigned EAN codes": "Không có lệnh sản xuất nào được gán mã EAN", + "No work orders yet": "Chưa có lệnh sản xuất nào", + "No work orders yet.": "Chưa có lệnh sản xuất nào.", "No workers": "Không có công nhân", "No workers assigned yet.": "Chưa có công nhân nào được phân công.", "No workers configured.": "Chưa có cấu hình công nhân.", @@ -2470,7 +2470,7 @@ "Operating": "Vận hành", "Operation": "hoạt động", "Operations": "Hoạt động", - "Operator": "Vận hành viên (Operator)", + "Operator": "Người vận hành", "OPERATOR": "NGƯỜI VẬN HÀNH", "Operator clicks Start/Complete on each step at each workstation. Full traceability.": "Người vận hành nhấp vào Bắt đầu/Hoàn thành trên mỗi bước tại mỗi trạm làm việc. Truy xuất nguồn gốc đầy đủ.", "Operator enters total produced quantity at the end. No step tracking.": "Người vận hành nhập tổng số lượng sản xuất vào cuối. Không theo dõi bước.", @@ -2510,7 +2510,7 @@ "Order No": "Mã Lệnh Sản Xuất", "Order no. or LOT": "Số lệnh hoặc LÔ", "Order number": "Số lệnh", - "Order Number": "Số lệnh làm việc", + "Order Number": "Số lệnh sản xuất", "ORDER NUMBER": "MÃ LỆNH", "order|orders": "lệnh|lệnh", "orders": "mệnh lệnh", @@ -2534,7 +2534,7 @@ "OVERDUE": "QUÁ HẠN", "Overdue actions": "Hành động quá hạn", "Overdue WO": "Quá hạn WO", - "Overdue Work Orders": "Lệnh làm việc quá hạn", + "Overdue Work Orders": "Lệnh sản xuất quá hạn", "overload": "quá tải", "overload / alert": "quá tải/cảnh báo", "OVERSIGHT": "GIÁM SÁT", @@ -2611,11 +2611,11 @@ "Payload": "Tải trọng", "Payload format": "Định dạng tải trọng", "Payload rules": "Quy tắc tải trọng", - "pcs": "cái", - "PCS": "chiếc", - "PCS PLANNED": "CHIẾC KẾ HOẠCH", - "pcs, kg, m...": "cái, kg, m...", - "pcs.": "chiếc.", + "pcs": "sp", + "PCS": "sp", + "PCS PLANNED": "SP DỰ KIẾN", + "pcs, kg, m...": "sp, kg, m...", + "pcs.": "sp.", "pending": "Chờ xử lý", "Pending": "Chờ duyệt", "PENDING": "Đang chờ xử lý", @@ -2627,7 +2627,7 @@ "Per Operation": "Mỗi hoạt động", "Per step": "Mỗi bước", "Per Unit": "mỗi đơn vị", - "PERF": "HOÀN HẢO", + "PERF": "HIỆU SUẤT", "Perform": "Thực hiện", "Perform quality control": "Thực hiện kiểm soát chất lượng", "Performance": "Hiệu suất", @@ -2919,11 +2919,11 @@ "RECENT LOTS": "LÔ GẦN ĐÂY", "Recent Serials": "Serial gần đây", "Recent stock movements": "Diễn biến chứng khoán gần đây", - "Recent work orders": "Lệnh làm việc gần đây", - "Recent Work Orders": "Lệnh làm việc gần đây", + "Recent work orders": "Lệnh sản xuất gần đây", + "Recent Work Orders": "Lệnh sản xuất gần đây", "Recently edited": "Đã chỉnh sửa gần đây", "Recipe / Materials": "Công thức / Nguyên liệu", - "RECIPE / MATERIALS": "CÔNG THỨC / VẬT TƯ", + "RECIPE / MATERIALS": "CÔNG THỨC / VẬT LIỆU", "Reconnect all": "Kết nối lại tất cả", "Reconnect delay (seconds)": "Độ trễ kết nối lại (giây)", "Reconnect failed": "Kết nối lại không thành công", @@ -2957,8 +2957,8 @@ "Register address": "Đăng ký địa chỉ", "Register first lot": "Đăng ký lô đầu tiên", "Register lot": "Đăng ký lô", - "Register material lot": "Đăng ký lô vật tư", - "Register Material Lot": "Đăng ký lô vật tư", + "Register material lot": "Đăng ký lô vật liệu", + "Register Material Lot": "Đăng ký lô vật liệu", "Register type *": "Loại đăng ký *", "Register your first lot to start traceable consumption.": "Đăng ký lô đầu tiên của bạn để bắt đầu tiêu thụ có thể theo dõi.", "reject": "Từ chối", @@ -2966,7 +2966,7 @@ "Reject (no further action)": "Từ chối (không xử lý thêm)", "Reject count": "Số lượng từ chối", "Reject this work order?": "Từ chối lệnh sản xuất này?", - "Reject work order": "Từ chối lệnh làm việc", + "Reject work order": "Từ chối lệnh sản xuất", "Reject work order :order?": "Từ chối lệnh sản xuất :order?", "rejected": "đã từ chối", "Rejected": "Bị từ chối", @@ -3204,7 +3204,7 @@ "Search by order number or product...": "Tìm kiếm theo số lệnh hoặc sản phẩm...", "Search by order number…": "Tìm kiếm theo số lệnh…", "Search by prefix or product": "Tìm kiếm theo tiền tố hoặc sản phẩm", - "Search by work order number…": "Tìm theo số lệnh làm việc...", + "Search by work order number…": "Tìm theo số lệnh sản xuất...", "Search code or name…": "Tìm kiếm mã hoặc tên…", "Search components…": "Tìm kiếm thành phần…", "Search custom fields…": "Tìm kiếm các trường tùy chỉnh…", @@ -3239,7 +3239,7 @@ "Select a work order to view details": "Chọn lệnh sản xuất để xem chi tiết", "Select a worker to view details": "Chọn nhân viên để xem chi tiết", "Select a workstation to see steps": "Chọn một trạm làm việc để xem các bước", - "Select an operator..": "Chọn vận hành viên..", + "Select an operator..": "Chọn người vận hành..", "Select an operator...": "Chọn một người vận hành...", "Select an unassigned order to place in this slot": "Chọn một lệnh chưa được chỉ định để đặt vào vị trí này", "Select at least one of Tool, Line, or Workstation.": "Chọn ít nhất một trong số Công cụ, Dây chuyền hoặc Trạm làm việc.", @@ -3252,8 +3252,8 @@ "Select reason": "Chọn lý do", "Select type...": "Chọn loại...", "Select WO": "Chọn WO", - "select work order": "chọn lệnh làm việc", - "Select work order": "Chọn lệnh làm việc", + "select work order": "chọn lệnh sản xuất", + "Select work order": "Chọn lệnh sản xuất", "Select workstation": "Chọn trạm làm việc", "Select Workstation": "Chọn trạm làm việc", "Select...": "Chọn...", @@ -3303,10 +3303,10 @@ "Shifts": "ca", "Shifts per day": "Ca mỗi ngày", "Shipped": "Đã vận chuyển", - "Short": "ngắn", + "Short": "Thiếu", "Short code displayed as column header in Workstation view (e.g. Z1, Z2, Z3).": "Mã ngắn được hiển thị dưới dạng tiêu đề cột trong chế độ xem Trạm làm việc (ví dụ: Z1, Z2, Z3).", "Short summary of what this operation accomplishes…": "Tóm tắt ngắn gọn về những gì hoạt động này đạt được…", - "Shortages": "Thiếu hụt vật tư", + "Shortages": "Thiếu hụt vật liệu", "Shortcuts to common admin pages": "Phím tắt đến các trang quản trị thông dụng", "Shortfall": "Thiếu hụt", "Show errors": "Hiển thị lỗi", @@ -3378,7 +3378,7 @@ "Standard execution context — used as defaults when a template step references this segment.": "Ngữ cảnh thực thi tiêu chuẩn — được sử dụng làm mặc định khi bước mẫu tham chiếu đến phân đoạn này.", "Standard instruction": "Hướng dẫn tiêu chuẩn", "Standard weekly hours": "Giờ tiêu chuẩn hàng tuần", - "Standard work order list with status, batches, priority and actions.": "Danh sách lệnh làm việc tiêu chuẩn với trạng thái, lô, mức độ ưu tiên và hành động.", + "Standard work order list with status, batches, priority and actions.": "Danh sách lệnh sản xuất tiêu chuẩn với trạng thái, lô, mức độ ưu tiên và hành động.", "Standardise operations across products by defining your first segment.": "Chuẩn hóa hoạt động trên các sản phẩm bằng cách xác định phân khúc đầu tiên của bạn.", "Standards": "Tiêu chuẩn", "Start": "Bắt đầu", @@ -3422,7 +3422,7 @@ "Step 1 — Production Line": "Bước 1 — Dây chuyền sản xuất", "Step 2 — Product Type": "Bước 2 — Loại sản phẩm", "Step 3 — Process Template": "Bước 3 — Quy trình mẫu", - "Step 4 — Work Order": "Bước 4 — Lệnh làm việc", + "Step 4 — Work Order": "Bước 4 — Lệnh sản xuất", "Step name": "Tên bước", "Step Name": "Tên bước", "Step perf": "Bước hoàn thiện", @@ -3578,8 +3578,8 @@ "THIS WEEK": "TUẦN NÀY", "This will invalidate your existing recovery codes. Enter your password to continue.": "Điều này sẽ làm mất hiệu lực các mã khôi phục hiện tại của bạn. Nhập mật khẩu của bạn để tiếp tục.", "This will record a blocking issue and pause production.": "Điều này sẽ ghi lại sự cố chặn và tạm dừng sản xuất.", - "This work order does not belong to the selected line.": "Lệnh công việc này không thuộc dây chuyền đã chọn.", - "This work order has no bill of materials. Nothing will be allocated.": "Lệnh làm việc này không có định mức nguyên vật liệu (BOM). Không có gì sẽ được phân bổ.", + "This work order does not belong to the selected line.": "Lệnh sản xuất này không thuộc dây chuyền đã chọn.", + "This work order has no bill of materials. Nothing will be allocated.": "Lệnh sản xuất này không có định mức nguyên vật liệu (BOM). Không có gì sẽ được phân bổ.", "This worker already has an absence that overlaps these dates.": "Nhân viên này đã vắng mặt trùng với những ngày này.", "Threshold (N)": "Ngưỡng (N)", "Throughput": "Thông lượng", @@ -3633,12 +3633,12 @@ "Total consumed": "Tổng lượng tiêu thụ", "Total consumed:": "Tổng lượng tiêu thụ:", "Total cost": "Tổng chi phí", - "Total cost = materials + labor + additional costs, per finished work order. Cost per unit = total / produced quantity.": "Tổng chi phí = vật liệu + nhân công + chi phí bổ sung, trên mỗi lệnh làm việc đã hoàn thành. Chi phí mỗi đơn vị = tổng/số lượng sản xuất.", + "Total cost = materials + labor + additional costs, per finished work order. Cost per unit = total / produced quantity.": "Tổng chi phí = vật liệu + nhân công + chi phí bổ sung, trên mỗi lệnh sản xuất đã hoàn thành. Chi phí mỗi đơn vị = tổng/số lượng sản xuất.", "total done": "tổng cộng đã hoàn thành", "Total Lines": "Tổng số dây chuyền", "Total non-conformances": "Tổng số điểm không phù hợp", "Total Orders": "Tổng số lệnh", - "total pcs": "tổng số chiếc", + "total pcs": "tổng số sp", "Total plan": "Tổng kế hoạch", "Total Produced Qty": "Tổng số lượng sản xuất", "Total Product Types": "Tổng số loại sản phẩm", @@ -3651,8 +3651,8 @@ "Total shortfall": "Tổng lượng thiếu hụt", "Total Templates": "Tổng số mẫu", "Total Users": "Tổng số người dùng", - "total work orders": "tổng số lệnh làm việc", - "Total Work Orders": "Tổng số lệnh làm việc", + "total work orders": "tổng số lệnh sản xuất", + "Total Work Orders": "Tổng số lệnh sản xuất", "Total WOs": "Tổng số WO", "Trace": "Dấu vết", "Trace a finished LOT, material lot, supplier LOT, source container or serial number through its full genealogy.": "Theo dõi LÔ thành phẩm, lô nguyên liệu, LÔ nhà cung cấp, thùng chứa nguồn hoặc số sê-ri thông qua phả hệ đầy đủ của nó.", @@ -3894,27 +3894,27 @@ "work": "làm việc", "Work": "công việc", "Work Instructions": "Hướng dẫn công việc", - "Work order": "Lệnh làm việc", - "Work Order": "Lệnh làm việc", - "WORK ORDER": "LỆNH LÀM VIỆC", - "Work Order :no": "Lệnh làm việc :no", - "work order description": "mô tả lệnh làm việc", - "Work Order Details": "Chi tiết lệnh làm việc", + "Work order": "Lệnh sản xuất", + "Work Order": "Lệnh sản xuất", + "WORK ORDER": "LỆNH SẢN XUẤT", + "Work Order :no": "Lệnh sản xuất :no", + "work order description": "mô tả lệnh sản xuất", + "Work Order Details": "Chi tiết lệnh sản xuất", "WORK ORDER DETAILS": "CHI TIẾT LỆNH SẢN XUẤT", - "Work order fully packed": "Lệnh làm việc đã đóng gói đầy đủ", + "Work order fully packed": "Lệnh sản xuất đã đóng gói đầy đủ", "Work Order History": "Lịch sử lệnh sản xuất", "Work order not found": "Không tìm thấy lệnh sản xuất", "Work order not in a packable state (current: :status)": "Lệnh sản xuất không ở trạng thái có thể đóng gói (hiện tại: :status)", - "Work order number": "Số lệnh làm việc", + "Work order number": "Số lệnh sản xuất", "Work order span updated.": "Đã cập nhật khoảng thời gian lệnh sản xuất.", "Work order status changed": "Trạng thái lệnh sản xuất thay đổi", "Work order status is changed manually. Board statuses are visual labels.": "Trạng thái lệnh sản xuất được thay đổi theo cách thủ công. Trạng thái bảng là nhãn trực quan.", - "Work order updated successfully.": "Lệnh công việc được cập nhật thành công.", - "work orders": "lệnh làm việc", - "Work orders": "Lệnh làm việc", - "Work Orders": "Lệnh làm việc", - "WORK ORDERS": "LỆNH LÀM VIỆC", - "Work orders and bulk imports.": "Lệnh làm việc và nhập hàng loạt.", + "Work order updated successfully.": "Lệnh sản xuất được cập nhật thành công.", + "work orders": "lệnh sản xuất", + "Work orders": "Lệnh sản xuất", + "Work Orders": "Lệnh sản xuất", + "WORK ORDERS": "LỆNH SẢN XUẤT", + "Work orders and bulk imports.": "Lệnh sản xuất và nhập hàng loạt.", "Work Orders by Status": "Lệnh sản xuất theo trạng thái", "Work orders to pack": "Lệnh sản xuất cần đóng gói", "Work orders, issues, and lines summary cards": "Thẻ tóm tắt lệnh sản xuất, vấn đề và dây chuyền", @@ -3981,7 +3981,7 @@ "You'll only see work orders for the line you select. You can switch later from the menu.": "Bạn sẽ chỉ thấy lệnh sản xuất cho dây chuyền bạn chọn. Bạn có thể chuyển đổi sau từ menu.", "Your account requires an authentication code at login.": "Tài khoản của bạn yêu cầu mã xác thực khi đăng nhập.", "Your account will be less secure. Enter your password to confirm.": "Tài khoản của bạn sẽ kém an toàn hơn. Nhập mật khẩu của bạn để xác nhận.", - "Your production line, product type, process template, and first work order have been created.": "Dây chuyền sản xuất, loại sản phẩm, quy trình mẫu và lệnh làm việc đầu tiên của bạn đã được tạo.", + "Your production line, product type, process template, and first work order have been created.": "Dây chuyền sản xuất, loại sản phẩm, quy trình mẫu và lệnh sản xuất đầu tiên của bạn đã được tạo.", "New Rule": "New Rule", "New Schedule": "Lịch trình mới", "New Lot": "Lô vật liệu mới", @@ -3997,7 +3997,7 @@ "New Event": "Sự kiện mới", "New Absence": "New Absence", "Switch Line": "Chuyển dây chuyền", - "Work order :code created.": "Đã tạo lệnh làm việc :code.", + "Work order :code created.": "Đã tạo lệnh sản xuất :code.", "Work Order Queue": "Hàng đợi Lệnh sản xuất", "Table": "Bảng", "Cards": "Thẻ", @@ -4710,7 +4710,7 @@ "Log output": "Đầu ra nhật ký", "Logistics operator (can move pallets)": "Nhân viên kho vận (có thể di chuyển pallet)", "Long-press a bar on the planner to drag it. Use this modal for line changes and exact times.": "Nhấn giữ một thanh trên trình lập kế hoạch để kéo. Sử dụng cửa sổ này để thay đổi dây chuyền và thời gian chính xác.", - "Lot Genealogy": "Nguồn gốc lô vật tư", + "Lot Genealogy": "Nguồn gốc lô vật liệu", "Lot Sequences": "Thứ tự lô", "Lot not yet consumed.": "Lô chưa được tiêu hao.", "Lower numbers run first": "Số nhỏ hơn sẽ chạy trước", @@ -4720,7 +4720,7 @@ "MQTT connectivity": "Kết nối MQTT", "MQTT off": "MQTT tắt", "MQTT wildcards (+, #) are accepted": "Chấp nhận ký tự đại diện MQTT (+, #)", - "MRP · shortages": "MRP · thiếu hụt vật tư", + "MRP · shortages": "MRP · thiếu hụt vật liệu", "MY STATION": "TRẠM CỦA TÔI", "Machine (automatic)": "Máy (tự động)", "Machine failure": "Sự cố máy", @@ -4794,7 +4794,7 @@ "New line": "Dây chuyền mới", "New maintenance event": "Sự kiện bảo trì mới", "New mapping": "Ánh xạ mới", - "New material": "Vật tư mới", + "New material": "Vật liệu mới", "New pallet": "Pallet mới", "New password": "Mật khẩu mới", "New personnel class": "Nhóm nhân sự mới", @@ -4951,7 +4951,7 @@ "PREPEND CURRENT YEAR (E.G. 2026LOT0001)": "THÊM NĂM HIỆN TẠI VÀO ĐẦU (VÍ DỤ: 2026LOT0001)", "PRODUCED": "ĐÃ SẢN XUẤT", "Pack & Ship": "Đóng gói & Giao hàng", - "Pack {{units}} units of {{each}} each = {{pieces}} pieces": "Đóng gói {{units}} đơn vị, mỗi đơn vị {{each}} = {{pieces}} chi tiết", + "Pack {{units}} units of {{each}} each = {{pieces}} pieces": "Đóng gói {{units}} đơn vị, mỗi đơn vị {{each}} = {{pieces}} sp", "Packaging EANs": "Mã EAN đóng gói", "Pad size": "Kích thước đệm", "Pakowanie": "Đóng gói", @@ -5046,15 +5046,15 @@ "Read-confirmation": "Xác nhận đã đọc", "Read-only on mobile. Edit products & sync rules from web admin.": "Chỉ đọc trên thiết bị di động. Chỉnh sửa sản phẩm & quy tắc đồng bộ từ trang quản trị web.", "Ready for production": "Sẵn sàng sản xuất", - "Recalculated material requirements for the remaining quantity": "Đã tính lại nhu cầu vật tư cho số lượng còn lại", + "Recalculated material requirements for the remaining quantity": "Đã tính lại nhu cầu vật liệu cho số lượng còn lại", "Recent lots": "Các lô gần đây", "Reclassify": "Phân loại lại", - "Reclassify material": "Phân loại lại vật tư", + "Reclassify material": "Phân loại lại vật liệu", "Record QC": "Ghi nhận QC", "Record absence": "Ghi nhận vắng mặt", - "Record consumption": "Ghi nhận tiêu hao vật tư", + "Record consumption": "Ghi nhận tiêu hao vật liệu", "Record cost": "Ghi nhận chi phí", - "Record what was actually consumed, return leftovers to stock, or reclassify material.": "Ghi nhận lượng vật tư thực tế đã tiêu hao, trả lại vật tư thừa vào kho, hoặc phân loại lại vật tư.", + "Record what was actually consumed, return leftovers to stock, or reclassify material.": "Ghi nhận lượng vật liệu thực tế đã tiêu hao, trả lại vật liệu thừa vào kho, hoặc phân loại lại vật liệu.", "Record why production stopped, and whether a configuration change is needed.": "Ghi nhận lý do dừng sản xuất và liệu có cần thay đổi cấu hình hay không.", "Recorded against :order with the state it is in right now.": "Đã ghi nhận cho :order với trạng thái hiện tại của nó.", "Recovery codes regenerated.": "Đã tạo lại mã khôi phục.", @@ -5125,7 +5125,7 @@ "Search audit logs…": "Tìm kiếm nhật ký kiểm toán…", "Search backlog": "Tìm kiếm tồn đọng", "Search backlog…": "Tìm kiếm tồn đọng…", - "Search by lot or material": "Tìm theo lô hoặc vật tư", + "Search by lot or material": "Tìm theo lô hoặc vật liệu", "Search by name, email, username": "Tìm theo tên, email, tên người dùng", "Search connections…": "Tìm kiếm kết nối…", "Search inspections…": "Tìm kiếm lượt kiểm tra…", @@ -5218,7 +5218,7 @@ "Tap Add to record one.": "Nhấn Thêm để ghi nhận một mục.", "Tap to assign a crew": "Nhấn để phân công tổ làm việc", "Tap to change": "Nhấn để thay đổi", - "Target class (material)": "Phân loại mục tiêu (vật tư)", + "Target class (material)": "Phân loại mục tiêu (vật liệu)", "Target kind": "Loại mục tiêu", "Target qty": "Số lượng mục tiêu", "Team": "Đội ngũ", @@ -5228,7 +5228,7 @@ "Tenant": "Đơn vị thuê", "Tensile strength": "Độ bền kéo", "That item was already removed.": "Mục đó đã bị xóa trước đó.", - "That template already consumes :material, so it cannot also produce it.": "Mẫu đó đã tiêu hao :material, do đó không thể đồng thời sản xuất vật tư này.", + "That template already consumes :material, so it cannot also produce it.": "Mẫu đó đã tiêu hao :material, do đó không thể đồng thời sản xuất vật liệu này.", "The Andon issue-type catalog is empty.": "Danh mục loại sự cố Andon đang trống.", "The action could not be completed.": "Không thể hoàn thành thao tác.", "The activity will be removed from this day plan.": "Hoạt động sẽ bị xóa khỏi kế hoạch ngày này.", @@ -5369,7 +5369,7 @@ "drag an edge onto another line to continue the order there": "kéo một cạnh sang dây chuyền khác để tiếp tục lệnh ở đó", "e.g. 2 with frequency Weekly = every 2 weeks": "vd: 2 với tần suất Hàng tuần = mỗi 2 tuần", "e.g. A": "vd: A", - "e.g. Energy overage, emergency materials, etc.": "vd: Vượt định mức năng lượng, vật tư khẩn cấp, v.v.", + "e.g. Energy overage, emergency materials, etc.": "vd: Vượt định mức năng lượng, vật liệu khẩn cấp, v.v.", "e.g. Factory A, Staging": "vd: Nhà máy A, Khu vực tập kết", "e.g. Final inspection": "vd: Kiểm tra cuối cùng", "e.g. LOT": "vd: LÔ", @@ -5403,7 +5403,7 @@ "orders scheduled": "lệnh đã lập lịch", "orders today": "lệnh hôm nay", "overlap": "chồng chéo", - "pcs, kg, m": "cái, kg, m", + "pcs, kg, m": "sp, kg, m", "processed": "đã xử lý", "produced :qty at stop": "đã sản xuất :qty khi dừng", "read-only": "chỉ đọc", @@ -5469,10 +5469,327 @@ "Due: ": "Hạn: ", "Enter the number": "Nhập số", "Line: ": "Dây chuyền: ", - "Mark a worker as a logistics operator first.": "Hãy đánh dấu một nhân viên làm vận hành viên logistics trước.", + "Mark a worker as a logistics operator first.": "Hãy đánh dấu một nhân viên làm người vận hành logistics trước.", "No recently completed": "Chưa có đơn hoàn thành gần đây", "Priority: ": "Ưu tiên: ", "Produced / Planned": "Đã sản xuất/dự kiến", "Qty: ": "SL: ", - "Record a production stoppage": "Ghi nhận thời gian ngừng sản xuất" + "Record a production stoppage": "Ghi nhận thời gian ngừng sản xuất", + "Already undone": "Đã hoàn tác", + "Could not reschedule": "Không thể dời lịch", + "Could not undo": "Không thể hoàn tác", + "Detach this segment": "Tách phân đoạn này", + "Med": "Trung bình", + "No changes yet.": "Chưa có thay đổi nào.", + "No orders match.": "Không có lệnh nào khớp.", + "Rescheduled": "Đã dời lịch", + "Return to backlog": "Trở về hàng chờ", + "This order has an exact start and end time. Moving it to a shift cell will clear them.": "Lệnh này có thời gian bắt đầu và kết thúc chính xác. Di chuyển nó vào ô ca làm việc sẽ xóa chúng.", + "{{n}} unscheduled in backlog": "{{n}} lệnh chưa lên lịch trong hàng chờ", + "Long-press a bar to move it · drag its edges to resize · snaps to {{n}} min": "Nhấn giữ thanh để di chuyển · kéo mép để thay đổi kích thước · bám theo bước {{n}} phút", + "Long-press a block to move it across shifts, days or lines · drag its edges to stretch · drag an edge onto another line to continue the order there · tap it for actions": "Nhấn giữ khối để di chuyển qua các ca, ngày hoặc dây chuyền · kéo mép để kéo giãn · kéo một mép sang dây chuyền khác để tiếp tục lệnh ở đó · chạm để xem thao tác", + "Filter…": "Bộ lọc…", + "Filters: :n": "Bộ lọc: :n", + ":n of :m selected": ":n trên :m được chọn", + "Select all rows on this page": "Chọn tất cả các hàng trên trang này", + "Select row": "Chọn hàng", + "Cancel :count selected work order(s)?": "Hủy :count lệnh sản xuất đã chọn?", + "Examples: 12, >10, <=5, 3-8": "Ví dụ: 12, >10, <=5, 3-8", + "Any date": "Bất kỳ ngày nào", + "Date range": "Khoảng ngày", + "Pick an end date": "Chọn ngày kết thúc", + "today": "hôm nay", + "Cancelled orders stop production and can be reopened later.": "Lệnh bị hủy sẽ dừng sản xuất và có thể mở lại sau.", + "Cancel orders": "Hủy lệnh", + "Cancel order": "Hủy lệnh", + "Delete order": "Xóa lệnh", + "Only allowed if it has no batches. Logged output stays in reports.": "Chỉ được phép nếu không có lô nào. Sản lượng đã ghi vẫn nằm trong báo cáo.", + "Accept :count selected work order(s)?": "Chấp nhận :count lệnh sản xuất đã chọn?", + "Reject :count selected work order(s)?": "Từ chối :count lệnh sản xuất đã chọn?", + "Pause :count selected work order(s)?": "Tạm dừng :count lệnh sản xuất đã chọn?", + "Resume :count selected work order(s)?": "Tiếp tục :count lệnh sản xuất đã chọn?", + "Reopen :count selected work order(s)?": "Mở lại :count lệnh sản xuất đã chọn?", + "Orders this action doesn't apply to are skipped.": "Các lệnh không áp dụng thao tác này sẽ được bỏ qua.", + "Accept orders": "Chấp nhận lệnh", + "Reject orders": "Từ chối lệnh", + "Pause orders": "Tạm dừng lệnh", + "Resume orders": "Tiếp tục lệnh", + "Reopen orders": "Mở lại lệnh", + "selected": "đã chọn", + "+ Create Batch": "+ Tạo lô", + "1 workstation on this line.": "1 trạm làm việc trên dây chuyền này.", + ":count workstations on this line.": ":count trạm làm việc trên dây chuyền này.", + "A :type package already exists for this entity and revision.": "Gói :type đã tồn tại cho thực thể và phiên bản này.", + "Activity added.": "Đã thêm hoạt động.", + "Activity created": "Đã tạo hoạt động", + "Activity deleted": "Đã xóa hoạt động", + "Activity removed.": "Đã xóa hoạt động.", + "Activity updated": "Đã cập nhật hoạt động", + "Admin creation failed: ADMIN_USERNAME, ADMIN_EMAIL, or ADMIN_PASSWORD is not configured.": "Tạo quản trị viên thất bại: ADMIN_USERNAME, ADMIN_EMAIL hoặc ADMIN_PASSWORD chưa được cấu hình.", + "All passed": "Tất cả đạt", + "An error occurred while uploading the backup file.": "Có lỗi xảy ra khi tải lên tệp sao lưu.", + "At least one lot must be picked.": "Phải chọn ít nhất một lô.", + "Brief summary of the issue": "Tóm tắt ngắn về vấn đề", + "Cannot be undone.": "Không thể hoàn tác.", + "Cannot delete a published version that has recorded inspections.": "Không thể xóa phiên bản đã xuất bản có lượt kiểm tra đã ghi nhận.", + "Cannot delete integration with linked materials. Deactivate it instead.": "Không thể xóa tích hợp có vật liệu liên kết. Thay vào đó hãy vô hiệu hóa nó.", + "Complete order": "Hoàn thành lệnh", + "Created version :v as a draft from the published plan.": "Đã tạo phiên bản :v dưới dạng bản nháp từ kế hoạch đã xuất bản.", + "Creating…": "Đang tạo…", + "Custom type created": "Đã tạo loại tùy chỉnh", + "Custom type deleted": "Đã xóa loại tùy chỉnh", + "Custom type updated": "Đã cập nhật loại tùy chỉnh", + "Delete :name?": "Xóa :name?", + "Delete photo": "Xóa ảnh", + "Delete this step photo?": "Xóa ảnh của bước này?", + "Draft updated.": "Đã cập nhật bản nháp.", + "Each lot pick must reference a lot and a positive quantity.": "Mỗi lần chọn lô phải tham chiếu một lô và số lượng dương.", + "Edit Account": "Chỉnh sửa tài khoản", + "Failed to delete.": "Xóa thất bại.", + "Fill in a worker code to link/create a shop-floor worker profile for this account. Leave this collapsed for office/admin accounts.": "Nhập mã công nhân để liên kết/tạo hồ sơ công nhân xưởng sản xuất cho tài khoản này. Để trống mục này cho tài khoản văn phòng/quản trị.", + "Import data": "Nhập dữ liệu", + "Inspection plan created as a draft. Publish it to use it for inspections.": "Kế hoạch kiểm tra đã được tạo dưới dạng bản nháp. Xuất bản nó để sử dụng cho các lượt kiểm tra.", + "Inspection plan published": "Kế hoạch kiểm tra đã xuất bản", + "Inspection plan version :v published.": "Phiên bản :v của kế hoạch kiểm tra đã được xuất bản.", + "Last:": "Lần cuối:", + "Leave blank to keep the current secret.": "Để trống để giữ bí mật hiện tại.", + "Min cannot exceed max.": "Giá trị tối thiểu không được vượt quá giá trị tối đa.", + "Minute plan is shared — edit it from the primary line": "Kế hoạch chi tiết được dùng chung — hãy chỉnh sửa từ dây chuyền chính", + "No batches created yet.": "Chưa có lô nào được tạo.", + "No caption": "Không có chú thích", + "No results found": "Không tìm thấy kết quả", + "PDF": "PDF", + "Pick a material type when scope is \"material type\".": "Chọn loại vật liệu khi phạm vi là \"loại vật liệu\".", + "Pick a material when scope is \"material\".": "Chọn vật liệu khi phạm vi là \"vật liệu\".", + "Please fix the following:": "Vui lòng sửa những điều sau:", + "Please select a backup file.": "Vui lòng chọn tệp sao lưu.", + "Quality:": "Chất lượng:", + "Quantities must sum to the required amount": "Tổng số lượng phải bằng số lượng yêu cầu", + "Quantity:": "Số lượng:", + "Replace photo": "Thay ảnh", + "Sample #": "Mẫu #", + "Scrap quantity (optional)": "Số lượng phế liệu (tùy chọn)", + "Selected batch does not belong to this work order.": "Lô đã chọn không thuộc lệnh sản xuất này.", + "Showing 10 most recent of :total total work orders": "Hiển thị 10 lệnh sản xuất gần nhất trong tổng số :total", + "Skills & level (1–5)": "Kỹ năng & cấp độ (1–5)", + "Some items failed": "Một số mục không thành công", + "Step started. Materials have been allocated.": "Bước đã bắt đầu. Vật liệu đã được phân bổ.", + "System fields": "Trường hệ thống", + "TOTAL SCRAP:": "TỔNG PHẾ LIỆU:", + "The file content does not match its :type extension.": "Nội dung tệp không khớp với phần mở rộng :type của nó.", + "This shift overlaps with an existing shift on this line. Adjust the times and try again.": "Ca này trùng với một ca hiện có trên dây chuyền này. Hãy điều chỉnh thời gian và thử lại.", + "This version is already published.": "Phiên bản này đã được xuất bản.", + "Title (optional)": "Tiêu đề (tùy chọn)", + "Unknown reason": "Lý do không xác định", + "Uploading backup file...": "Đang tải lên tệp sao lưu...", + "Video": "Video", + "Worker profile": "Hồ sơ công nhân", + "e.g., Assembly Station 1, Quality Check Point": "ví dụ: Trạm lắp ráp 1, Điểm kiểm tra chất lượng", + "e.g., Assembly, Quality Control, Packaging (optional)": "ví dụ: Lắp ráp, Kiểm soát chất lượng, Đóng gói (tùy chọn)", + "e.g., WS-A01, ASSEMBLY-1": "ví dụ: WS-A01, ASSEMBLY-1", + "optional, only for shop-floor staff": "tùy chọn, chỉ dành cho nhân viên xưởng sản xuất", + "photos": "ảnh", + "— Select Material Type —": "— Chọn Loại Vật liệu —", + "⚠ Blocking": "⚠ Chặn", + "Nothing here yet.": "Chưa có gì ở đây.", + ">10": ">10", + "No divisions assigned to this factory yet.": "Chưa có bộ phận nào được chỉ định cho nhà máy này.", + "No rows match the filters.": "Không có hàng nào khớp với bộ lọc.", + "No steps recorded.": "Chưa có bước nào được ghi nhận.", + "Work order :order cancelled.": "Lệnh sản xuất :order đã bị hủy.", + "Work order :order accepted.": "Lệnh sản xuất :order đã được chấp nhận.", + "Work order :order rejected.": "Lệnh sản xuất :order đã bị từ chối.", + "Work order :order paused.": "Lệnh sản xuất :order đã bị tạm dừng.", + "Work order :order resumed.": "Lệnh sản xuất :order đã được tiếp tục.", + "Work order :order reopened.": "Lệnh sản xuất :order đã được mở lại.", + ":count work order(s) cancelled.": ":count lệnh sản xuất đã bị hủy.", + ":count work order(s) accepted.": ":count lệnh sản xuất đã được chấp nhận.", + ":count work order(s) rejected.": ":count lệnh sản xuất đã bị từ chối.", + ":count work order(s) paused.": ":count lệnh sản xuất đã bị tạm dừng.", + ":count work order(s) resumed.": ":count lệnh sản xuất đã được tiếp tục.", + ":count work order(s) reopened.": ":count lệnh sản xuất đã được mở lại.", + ":count skipped (not applicable in their current status).": ":count bị bỏ qua (không áp dụng ở trạng thái hiện tại của chúng).", + "Some of the selected work orders no longer exist.": "Một số lệnh sản xuất đã chọn không còn tồn tại.", + "Planned Start": "Bắt đầu dự kiến", + "Planned End": "Kết thúc dự kiến", + "End Shift": "Kết thúc ca", + "Line Status": "Trạng thái dây chuyền", + "Counted": "Đã đếm", + "More actions": "Thao tác khác", + "Worker Absences": "Sự vắng mặt của công nhân", + "READY": "SẴN SÀNG", + "SKIPPED": "ĐÃ BỎ QUA", + "% of rate": "% của mức chuẩn", + ":count pcs scrap this shift": ":count sản phẩm phế liệu trong ca này", + ":minutes min at reduced speed": ":minutes phút ở tốc độ giảm", + ":time overdue": "quá hạn :time", + "ALL STOPS CLASSIFIED": "TẤT CẢ ĐÃ PHÂN LOẠI", + "Actual / target": "Thực tế / mục tiêu", + "Actual rate against nameplate rate": "Tỷ lệ thực tế so với tỷ lệ định mức", + "Add a note for maintenance…": "Thêm ghi chú cho bảo trì…", + "All stops classified": "Tất cả các lần dừng đã phân loại", + "Analysis": "Phân tích", + "At rate": "Đạt mức chuẩn", + "Availability × Performance × Quality": "Khả dụng × Hiệu suất × Chất lượng", + "Batch change": "Thay đổi lô", + "Below nameplate rate": "Thấp hơn tỷ lệ định mức", + "CLASSIFIED": "ĐÃ PHÂN LOẠI", + "CLEANING": "VỆ SINH", + "Cause set · :reason": "Đã đặt nguyên nhân · :reason", + "Change the cause": "Thay đổi nguyên nhân", + "Changeover / Setup": "Chuyển đổi / Thiết lập", + "Changeovers: :count · :minutes min total": "Số lần chuyển đổi: :count · tổng :minutes phút", + "Cleaning / Sanitation": "Vệ sinh / Khử trùng", + "Current batch": "Lô hiện tại", + "DURATION": "THỜI GIAN", + "Downtime :id · :minutes min": "Dừng máy :id · :minutes phút", + "Downtime by cause": "Thời gian dừng theo nguyên nhân", + "Effective run time": "Thời gian chạy hiệu quả", + "Elapsed shift time": "Thời gian ca đã trôi qua", + "Escalate to maintenance": "Chuyển lên bảo trì", + "Escalated to maintenance · issue #:id opened": "Đã chuyển lên bảo trì · đã mở vấn đề #:id", + "Escalation": "Chuyển cấp", + "Expected output": "Sản lượng dự kiến", + "FAULT": "LỖI", + "Good count against total produced": "Số lượng tốt trên tổng sản lượng", + "IDLE": "RẢNH RỖI", + "Interval detail": "Chi tiết khoảng thời gian", + "Jump to now": "Nhảy đến hiện tại", + "Jumped to now": "Đã nhảy đến hiện tại", + "LINKED": "ĐÃ LIÊN KẾT", + "Live shift": "Ca trực tiếp", + "Lost pcs": "Sản phẩm thất thoát", + "MADE": "ĐÃ SẢN XUẤT", + "MAINTENANCE": "BẢO TRÌ", + "Machine Breakdown": "Sự cố máy móc", + "NEEDS A CAUSE": "CẦN NGUYÊN NHÂN", + "Nameplate rate": "Tỷ lệ định mức", + "Next shift": "Ca tiếp theo", + "Next station": "Trạm tiếp theo", + "No Material Available": "Không có vật liệu", + "No Operator Available": "Không có người vận hành", + "No batch running.": "Không có lô nào đang chạy.", + "No issue type is configured.": "Chưa cấu hình loại vấn đề.", + "No stops recorded this shift.": "Không có lần dừng nào được ghi nhận trong ca này.", + "No work order was running at this station when the stop began, so there is nothing to escalate against.": "Không có lệnh sản xuất nào đang chạy tại trạm này khi bắt đầu dừng máy, nên không có gì để chuyển cấp.", + "No workstation has reported any machine state yet.": "Chưa có trạm làm việc nào báo cáo trạng thái máy.", + "Not scheduled": "Chưa lên lịch", + "ORDER": "LỆNH", + "PASSED": "ĐẠT", + "PLANNED": "DỰ KIẾN", + "Pick a cause": "Chọn nguyên nhân", + "Planned / changeover": "Dự kiến / chuyển đổi", + "Planned Maintenance": "Bảo trì theo kế hoạch", + "Previous shift": "Ca trước", + "Previous station": "Trạm trước", + "Product changeover": "Chuyển đổi sản phẩm", + "QA": "QA", + "QC check": "Kiểm tra QC", + "Quality Issue / Rework": "Vấn đề chất lượng / Gia công lại", + "Quality check recorded": "Đã ghi nhận kiểm tra chất lượng", + "REDUCED SPEED": "GIẢM TỐC ĐỘ", + "RESULT": "KẾT QUẢ", + "Reduced speed": "Giảm tốc độ", + "Running interval": "Khoảng thời gian chạy", + "SETUP": "THIẾT LẬP", + "STOPPED": "ĐÃ DỪNG", + "Scheduled Break": "Nghỉ theo lịch", + "Shift Monitor": "Giám sát ca", + "Shift log is complete.": "Nhật ký ca đã đầy đủ.", + "Shift monitor": "Giám sát ca", + "Shift quantity": "Số lượng trong ca", + "Something went wrong.": "Đã xảy ra lỗi.", + "Speed loss": "Mất tốc độ", + "States": "Trạng thái", + "Station": "Trạm", + "Stop detail": "Chi tiết lần dừng", + "Stop escalated from shift monitor": "Lần dừng được chuyển cấp từ giám sát ca", + "Stop — needs a reason": "Lần dừng — cần lý do", + "Stop — reason set": "Lần dừng — đã đặt lý do", + "Time losses": "Mất thời gian", + "Top cause \":cause\" accounts for :percent% of lost time.": "Nguyên nhân hàng đầu \":cause\" chiếm :percent% thời gian mất.", + "Unclassified": "Chưa phân loại", + "Unclassified stop": "Lần dừng chưa phân loại", + "Unclassified stops": "Các lần dừng chưa phân loại", + "Unclassified stops: :count — oldest is :minutes min at :time": "Các lần dừng chưa phân loại: :count — lâu nhất là :minutes phút lúc :time", + "Unplanned stops against scheduled time": "Các lần dừng ngoài kế hoạch so với thời gian theo lịch", + "Verdict": "Phán quyết", + "WAITING": "ĐANG CHỜ", + "Waiting for Approval": "Chờ phê duyệt", + "causes": "nguyên nhân", + "live · pushed": "trực tiếp · đã đẩy", + "logged automatically": "tự động ghi nhận", + "min lost": "phút mất", + "min slow": "phút chậm", + "min stopped": "phút dừng", + "not scheduled": "chưa lên lịch", + "pcs scrap": "sản phẩm phế liệu", + "pcs/min": "sp/phút", + "reduced speed": "giảm tốc độ", + "stop — needs a cause": "dừng — cần nguyên nhân", + "stopped": "đã dừng", + "supervisor": "giám sát", + "system": "hệ thống", + "− Planned stops": "− Các lần dừng theo kế hoạch", + "− Speed loss": "− Mất tốc độ", + "− Unplanned stops": "− Các lần dừng ngoài kế hoạch", + "Check failed — an issue was raised": "Kiểm tra không đạt — đã tạo một vấn đề", + "no data recorded": "chưa có dữ liệu ghi nhận", + "No data recorded": "Chưa có dữ liệu ghi nhận", + "This stop is not attached to a workstation.": "Lần dừng này không gắn với trạm làm việc nào.", + "Somebody else has already given this stop a cause. Refresh to see it.": "Người khác đã đặt nguyên nhân cho lần dừng này. Làm mới để xem.", + "Already escalated · issue #:id is still open": "Đã chuyển cấp · vấn đề #:id vẫn đang mở", + "Actual output": "Sản lượng thực tế", + "Line overview": "Tổng quan dây chuyền", + "Line Overview": "Tổng quan dây chuyền", + "No production line is configured yet.": "Chưa cấu hình dây chuyền sản xuất nào.", + "No cause": "Không có nguyên nhân", + "Open :station in the shift monitor": "Mở :station trong giám sát ca", + "click a station to open its shift": "bấm vào một trạm để mở ca của nó", + "Blocking issues": "Vấn đề chặn", + "Overdue orders": "Lệnh quá hạn", + "Other open issues": "Các vấn đề đang mở khác", + "Blocked orders": "Lệnh bị chặn", + "No blocking issues.": "Không có vấn đề chặn.", + "No overdue orders.": "Không có lệnh quá hạn.", + "No blocked orders.": "Không có lệnh bị chặn.", + "Acknowledge all": "Xác nhận tất cả", + "Acknowledge :count open issue(s)?": "Xác nhận :count vấn đề đang mở?", + "Acknowledged issues stay on this list until they are resolved.": "Các vấn đề đã xác nhận vẫn ở trong danh sách này cho đến khi được giải quyết.", + "Open in work orders →": "Mở trong lệnh sản xuất →", + "Overdue by": "Quá hạn", + "due :date": "hạn :date", + ":count issues acknowledged.": "Đã xác nhận :count vấn đề.", + ":count issues resolved.": "Đã giải quyết :count vấn đề.", + "Some of the selected issues no longer exist.": "Một số vấn đề đã chọn không còn tồn tại.", + "Open in issues →": "Mở trong vấn đề →", + "Latest alerts": "Cảnh báo mới nhất", + "See all →": "Xem tất cả →", + ":count overdue": ":count quá hạn", + ":count blocked": ":count bị chặn", + "created :ago": "tạo :ago", + "Batch #:number": "Lô #:number", + ":done/:total steps": ":done/:total bước", + "Started :date": "Bắt đầu :date", + "completed :date": "hoàn thành :date", + "est. :n min": "ước tính :n phút", + "Started · :step": "Bắt đầu · :step", + "Completed · :step": "Hoàn thành · :step", + "Problems": "Vấn đề", + "No problems reported on this order.": "Không có vấn đề nào được báo cáo trên lệnh này.", + "Order created": "Lệnh đã tạo", + "Order completed": "Lệnh đã hoàn thành", + "Batch #:number started": "Lô #:number đã bắt đầu", + "Batch #:number completed": "Lô #:number đã hoàn thành", + ":step completed": ":step đã hoàn thành", + "Issue reported: :title": "Vấn đề đã báo cáo: :title", + "step :n/:total": "bước :n/:total", + ":count pcs": ":count sp", + ":produced / :planned pcs": ":produced / :planned sp", + "Product Image": "Ảnh sản phẩm", + "Optional. JPEG, PNG or WebP, up to 5 MB.": "Tùy chọn. JPEG, PNG hoặc WebP, tối đa 5 MB.", + "No image": "Không có ảnh", + "Remove image": "Xóa ảnh", + "Keep image": "Giữ ảnh", + "Customers & order data": "Khách hàng & dữ liệu lệnh" } diff --git a/backend/resources/js/Pages/admin/process-templates/Show.jsx b/backend/resources/js/Pages/admin/process-templates/Show.jsx index 5f1b1bcb..5e2bf3b0 100644 --- a/backend/resources/js/Pages/admin/process-templates/Show.jsx +++ b/backend/resources/js/Pages/admin/process-templates/Show.jsx @@ -125,6 +125,64 @@ function Isa95StepFields({ data, setData, workstationTypes = [] }) { ); } +// Equipment key:value parameters for a step (temperature, humidity, …). Edited as +// rows and stored as a flat object; a client reads it from the work-order snapshot +// (or the live template) to drive equipment. +function ParametersEditor({ value = {}, onChange }) { + const rows = Object.entries(value ?? {}); + + const emit = (pairs) => { + const obj = {}; + for (const [k, v] of pairs) { + if (String(k).trim() !== '') obj[k] = v; + } + onChange(obj); + }; + const setRow = (i, key, val) => emit(rows.map((r, idx) => (idx === i ? [key, val] : r))); + const addRow = () => onChange({ ...(value ?? {}), '': '' }); + const removeRow = (i) => emit(rows.filter((_, idx) => idx !== i)); + + return ( +
+ +

+ {__('Key:value settings the equipment needs (e.g. temperature, humidity). Read via API.')} +

+
+ {rows.map(([k, v], i) => ( +
+ setRow(i, e.target.value, v)} + className="form-input w-1/2" + placeholder={__('key (e.g. temperature_c)')} + /> + setRow(i, k, e.target.value)} + className="form-input w-1/2" + placeholder={__('value')} + /> + +
+ ))} +
+ +
+ ); +} + /* ------------------------------------------------------------------ */ /* Add-step inline form */ /* ------------------------------------------------------------------ */ @@ -139,6 +197,7 @@ function AddStepForm({ productType, processTemplate, processSegments, workstatio required_operators: '', workstation_id: '', workstation_type_id: '', + parameters: {}, process_segment_id: '', is_optional: false, variant_group: '', @@ -264,6 +323,7 @@ function AddStepForm({ productType, processTemplate, processSegments, workstatio + setData('parameters', v)} /> @@ -294,6 +354,7 @@ function EditStepForm({ step, productType, processTemplate, processSegments, wor required_operators: step.required_operators != null ? String(step.required_operators) : '', workstation_id: step.workstation_id != null ? String(step.workstation_id) : '', workstation_type_id: step.workstation_type_id != null ? String(step.workstation_type_id) : '', + parameters: step.parameters ?? {}, process_segment_id: step.process_segment_id != null ? String(step.process_segment_id) : '', is_optional: !!step.is_optional, variant_group: step.variant_group ?? '', @@ -397,6 +458,7 @@ function EditStepForm({ step, productType, processTemplate, processSegments, wor + setData('parameters', v)} /> @@ -516,6 +578,26 @@ function StepInstructionsEditor({ step, productType, processTemplate }) { const fileRef = useRef(null); const itemForm = useForm({ label: '', is_required: false, template_step_id: step.id }); + 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 addOutput = (e) => { + e.preventDefault(); + if (!outputForm.data.key.trim() || !outputForm.data.label.trim()) return; + outputForm.transform((d) => ({ + ...d, + options: d.value_type === 'select' + ? d.options.split(',').map((s) => s.trim()).filter(Boolean) + : null, + })); + outputForm.post(outputsBase, { + preserveScroll: true, + onSuccess: () => outputForm.reset('key', 'label', 'unit', 'options', 'is_required'), + onFinish: () => outputForm.transform((d) => d), + }); + }; + const onFile = (e) => { const file = e.target.files?.[0]; if (!file) return; @@ -612,6 +694,49 @@ function StepInstructionsEditor({ step, productType, processTemplate }) { + + {/* Typed operator outputs */} +
+

{__('Operator outputs')}

+ {outputs.length > 0 && ( +
    + {outputs.map((o) => ( +
  • + {o.label} + {o.key} + {o.value_type} + {o.is_required && {__('required')}} + +
  • + ))} +
+ )} +
+ outputForm.setData('key', e.target.value)} placeholder={__('key (e.g. output_qcpic)')} className="form-input text-sm py-1 w-[160px]" /> + outputForm.setData('label', e.target.value)} placeholder={__('Label')} className="form-input text-sm py-1 flex-1 min-w-[120px]" /> + + {outputForm.data.value_type === 'number' && ( + outputForm.setData('unit', e.target.value)} placeholder={__('unit')} className="form-input text-sm py-1 w-[80px]" /> + )} + {outputForm.data.value_type === 'select' && ( + outputForm.setData('options', e.target.value)} placeholder={__('options, comma-separated')} className="form-input text-sm py-1 w-[180px]" /> + )} + + +
+ {outputForm.errors.options &&

{outputForm.errors.options}

} + {outputForm.errors.key &&

{outputForm.errors.key}

} +
); } diff --git a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx index 00ccebd7..bc8dde64 100644 --- a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx +++ b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx @@ -655,7 +655,7 @@ function ProductionControls({ batch }) { // Single Batch card // --------------------------------------------------------------------------- -function BatchCard({ batch, defaultOpen, labelTemplates = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {} }) { +function BatchCard({ batch, defaultOpen, labelTemplates = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, stepOutputs = {} }) { const [expanded, setExpanded] = useState(defaultOpen); const showControls = batch.status === 'IN_PROGRESS' || batch.status === 'DONE'; @@ -725,7 +725,7 @@ function BatchCard({ batch, defaultOpen, labelTemplates = [], stepPhotos = {}, s {/* Steps */} - + {/* Production controls */} {showControls && } @@ -739,7 +739,7 @@ function BatchCard({ batch, defaultOpen, labelTemplates = [], stepPhotos = {}, s // Batch Steps list (replaces the Livewire component) // --------------------------------------------------------------------------- -function BatchStepList({ steps, labelTemplates = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {} }) { +function BatchStepList({ steps, labelTemplates = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, stepOutputs = {} }) { const [inflightStepId, setInflightStepId] = useState(null); const [photoZoom, setPhotoZoom] = useState(null); const [pickModal, setPickModal] = useState(null); // { step, materials } | null @@ -792,6 +792,17 @@ function BatchStepList({ steps, labelTemplates = [], stepPhotos = {}, stepMedia ); }; + // Record a typed output value (scalar or a picture file). forceFormData carries + // the File for a picture; scalars post a plain value. + const handleRecordOutput = (step, output, value) => { + setInflightCheckId(`out:${step.id}:${output.id}`); + router.post( + `/operator/batch-step/${step.id}/outputs/${output.id}`, + { value }, + { preserveScroll: true, forceFormData: value instanceof File, onFinish: () => setInflightCheckId(null) } + ); + }; + // Starting a step: first ask the server which material lots (if any) need // picking. With lots to pick, open the WO-time picking modal seeded with the // system's proposal; otherwise start the step directly (unchanged behavior). @@ -837,6 +848,8 @@ function BatchStepList({ steps, labelTemplates = [], stepPhotos = {}, stepMedia const checklist = stepChecklists[step.step_number] || []; const completions = step.checklist_completions || []; const completedItemIds = new Set(completions.map((c) => c.checklist_item_id)); + const outputs = stepOutputs[step.step_number] || []; + const outputValues = step.output_values || []; const canCheck = step.status === 'IN_PROGRESS' || step.status === 'READY' || step.status === 'PENDING'; return (
@@ -960,6 +973,17 @@ function BatchStepList({ steps, labelTemplates = [], stepPhotos = {}, stepMedia /> )} + {outputs.length > 0 && ( + + )} + {stepDocs.length > 0 && ( [v.output_id, v])); + + const displayValue = (o, v) => { + if (!v) return null; + switch (o.value_type) { + case 'number': return v.value_number; + case 'boolean': return v.value_boolean ? __('Yes') : __('No'); + case 'date': return v.value_date; + case 'picture': return null; + default: return v.value_text; + } + }; + + return ( +
+

{__('Operator outputs')}

+
    + {outputs.map((o) => { + const v = byOutput[o.id]; + const busy = inflightCheckId === `out:${step.id}:${o.id}`; + const recorded = !!v; + return ( +
  • +
    + {o.label} + {o.unit && ({o.unit})} + {o.is_required && {__('Required')}} + {recorded && v.recorded_by && {v.recorded_by.name}} +
    + {o.value_type === 'picture' ? ( +
    + {v?.file_url && ( + + {o.label} + + )} + +
    + ) : o.value_type === 'boolean' ? ( + + ) : o.value_type === 'select' ? ( + + ) : ( + onRecord(step, o, val)} /> + )} +
  • + ); + })} +
+
+ ); +} + +// Text/number/date output: a controlled field committed on blur/Enter, so the +// operator types the value then it saves without a separate button per row. +function OutputScalarInput({ output, initial, disabled, onSubmit }) { + const [val, setVal] = useState(initial ?? ''); + const type = output.value_type === 'number' ? 'number' : output.value_type === 'date' ? 'date' : 'text'; + const commit = () => { if (String(val).trim() !== '' && String(val) !== String(initial ?? '')) onSubmit(val); }; + return ( + setVal(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }} + placeholder={__('Enter value…')} + className="form-input w-full text-sm" + /> + ); +} + // --------------------------------------------------------------------------- // WO-time lot picking modal (ERP-aligned "suggest + override"): the system // proposes lots (FEFO/FIFO/LIFO); the operator can split/reassign quantities @@ -1829,7 +1950,7 @@ function EngineeringDocsSection({ docs = [], onView }) { // --------------------------------------------------------------------------- export default function WorkOrderDetail() { - const { workOrder, issueTypes = [], scrapReasons = [], workstations = [], issueCustomFields = [], defaultWorkstationId, line, labelTemplates = [], processPhotos = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, engineeringDocuments = [] } = usePage().props; + const { workOrder, issueTypes = [], scrapReasons = [], workstations = [], issueCustomFields = [], defaultWorkstationId, line, labelTemplates = [], processPhotos = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, stepOutputs = {}, engineeringDocuments = [] } = usePage().props; const [engViewer, setEngViewer] = useState(null); // { url, title } for the sandboxed viewer @@ -2006,6 +2127,7 @@ export default function WorkOrderDetail() { stepPhotos={stepPhotos} stepMedia={stepMedia} stepChecklists={stepChecklists} + stepOutputs={stepOutputs} /> ))}
diff --git a/backend/routes/api.php b/backend/routes/api.php index d2fe1872..c64875e0 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -338,6 +338,10 @@ // Scrap entries (operators can record against a work order; admins/supers manage) Route::get('/scrap-entries', [ScrapEntryController::class, 'index']); Route::get('/scrap-entries/{scrapEntry}', [ScrapEntryController::class, 'show']); + // Typed operator outputs recorded on a work order (#B) — read for ERP/reporting. + Route::get('/work-orders/{workOrder}/step-outputs', [\App\Http\Controllers\Api\V1\StepOutputController::class, 'forWorkOrder'])->name('api.v1.work-orders.step-outputs'); + Route::get('/batch-step-outputs/{batchStepOutputValue}/file', [\App\Http\Controllers\Api\V1\StepOutputController::class, 'file'])->name('api.v1.batch-step-outputs.file'); + Route::get('/work-orders/{workOrder}/scrap-entries', [ScrapEntryController::class, 'forWorkOrder']); Route::post('/work-orders/{workOrder}/scrap-entries', [ScrapEntryController::class, 'store']); Route::patch('/scrap-entries/{scrapEntry}', [ScrapEntryController::class, 'update']); diff --git a/backend/routes/web.php b/backend/routes/web.php index aaea6c53..08068569 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -273,6 +273,9 @@ Route::get('/batch-step-document/{batchStepDocument}/file', [OperatorBatchController::class, 'showDocumentFile'])->name('batch-step-document.file'); // Work-instruction checklist: tick / un-tick a step checklist item. Route::post('/batch-step/{batchStep}/checklist/{checklistItem}/toggle', [OperatorBatchController::class, 'toggleChecklistItem'])->name('batch-step.checklist.toggle'); + // Typed step outputs — operator records a value (incl. picture upload). + Route::post('/batch-step/{batchStep}/outputs/{output}', [OperatorBatchController::class, 'recordOutput'])->name('batch-step.outputs.record'); + Route::get('/batch-step-output/{batchStepOutputValue}/file', [OperatorBatchController::class, 'showOutputFile'])->name('batch-step-output.file'); Route::post('/issue', [OperatorIssueController::class, 'store'])->name('issue.store'); Route::post('/scrap', [OperatorScrapController::class, 'store'])->name('scrap.store'); @@ -579,7 +582,7 @@ // Per-line statuses Route::post('/lines/{line}/statuses', [AdminLineStatusController::class, 'storeForLine'])->name('lines.statuses.store'); // Per-line product types - Route::post('/lines/{line}/product-types', [\App\Http\Controllers\Web\Admin\LineManagementController::class, 'syncProductTypes'])->name('lines.product-types.sync'); + Route::post('/lines/{line}/product-types/sync', [\App\Http\Controllers\Web\Admin\LineManagementController::class, 'syncProductTypes'])->name('lines.product-types.sync'); Route::post('/lines/{line}/view-columns', [\App\Http\Controllers\Web\Admin\LineManagementController::class, 'saveViewColumns'])->name('lines.view-columns.save'); Route::post('/lines/{line}/view-template', [\App\Http\Controllers\Web\Admin\LineManagementController::class, 'assignViewTemplate'])->name('lines.view-template.assign'); Route::post('/lines/{line}/default-view', [\App\Http\Controllers\Web\Admin\LineManagementController::class, 'setDefaultView'])->name('lines.default-view.set'); @@ -645,6 +648,10 @@ Route::post('/{process_template}/checklist-items', [\App\Http\Controllers\Web\Admin\TemplateStepChecklistController::class, 'store'])->name('checklist-items.store'); Route::delete('/{process_template}/checklist-items/{checklistItem}', [\App\Http\Controllers\Web\Admin\TemplateStepChecklistController::class, 'destroy'])->name('checklist-items.destroy'); + // Per-step typed operator outputs (values the operator records at execution). + Route::post('/{process_template}/outputs', [\App\Http\Controllers\Web\Admin\TemplateStepOutputController::class, 'store'])->name('outputs.store'); + Route::delete('/{process_template}/outputs/{output}', [\App\Http\Controllers\Web\Admin\TemplateStepOutputController::class, 'destroy'])->name('outputs.destroy'); + // BOM Management (nested under process templates) Route::get('/{process_template}/bom', [BomManagementController::class, 'index'])->name('bom'); Route::post('/{process_template}/bom', [BomManagementController::class, 'store'])->name('bom.store'); diff --git a/backend/tests/Feature/Api/ProcessTemplateApiTest.php b/backend/tests/Feature/Api/ProcessTemplateApiTest.php index a1b02b86..21a23f1e 100644 --- a/backend/tests/Feature/Api/ProcessTemplateApiTest.php +++ b/backend/tests/Feature/Api/ProcessTemplateApiTest.php @@ -14,8 +14,11 @@ class ProcessTemplateApiTest extends TestCase use RefreshDatabase; protected User $admin; + protected User $operator; + protected string $adminToken; + protected string $operatorToken; protected function setUp(): void @@ -32,8 +35,15 @@ protected function setUp(): void $this->operatorToken = $this->operator->createToken('test')->plainTextToken; } - private function authAdmin() { return $this->withHeader('Authorization', "Bearer {$this->adminToken}"); } - private function authOperator() { return $this->withHeader('Authorization', "Bearer {$this->operatorToken}"); } + private function authAdmin() + { + return $this->withHeader('Authorization', "Bearer {$this->adminToken}"); + } + + private function authOperator() + { + return $this->withHeader('Authorization', "Bearer {$this->operatorToken}"); + } public function test_can_list_templates_for_product_type(): void { @@ -84,6 +94,45 @@ public function test_admin_can_add_step(): void ->assertJsonPath('data.step_number', 1); } + public function test_admin_can_set_and_read_equipment_parameters_via_api(): void + { + $pt = ProductType::factory()->create(); + $template = ProcessTemplate::factory()->create(['product_type_id' => $pt->id]); + + $this->authAdmin()->postJson("/api/v1/process-templates/{$template->id}/steps", [ + 'name' => 'Reflow oven', + 'parameters' => ['temperature_c' => '250', 'humidity_pct' => '40'], + ])->assertStatus(201) + ->assertJsonPath('data.parameters.temperature_c', '250'); + + // Live read: the current template exposes the params for equipment control. + $this->authAdmin()->getJson("/api/v1/process-templates/{$template->id}") + ->assertOk() + ->assertJsonPath('data.steps.0.parameters.humidity_pct', '40'); + } + + public function test_parameters_must_be_an_object(): void + { + $pt = ProductType::factory()->create(); + $template = ProcessTemplate::factory()->create(['product_type_id' => $pt->id]); + + $this->authAdmin()->postJson("/api/v1/process-templates/{$template->id}/steps", [ + 'name' => 'Bad', 'parameters' => 'not-an-array', + ])->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(); diff --git a/backend/tests/Feature/StepTypedOutputsTest.php b/backend/tests/Feature/StepTypedOutputsTest.php new file mode 100644 index 00000000..e0d551a9 --- /dev/null +++ b/backend/tests/Feature/StepTypedOutputsTest.php @@ -0,0 +1,284 @@ + $r, 'guard_name' => 'web']); + } + $this->admin = tap(User::factory()->create(), fn ($u) => $u->assignRole('Admin')); + $this->operator = tap(User::factory()->create(), fn ($u) => $u->assignRole('Operator')); + + $this->line = Line::factory()->create(); + $this->productType = ProductType::factory()->create(); + $this->template = ProcessTemplate::factory()->create(['product_type_id' => $this->productType->id]); + $this->templateStep = TemplateStep::factory()->create([ + 'process_template_id' => $this->template->id, 'step_number' => 1, 'name' => 'Assemble', + ]); + + $this->workOrder = WorkOrder::factory()->create([ + 'line_id' => $this->line->id, + 'product_type_id' => $this->productType->id, + 'process_snapshot' => ['template_id' => $this->template->id], + ]); + $batch = Batch::factory()->create(['work_order_id' => $this->workOrder->id]); + $this->batchStep = BatchStep::factory()->create([ + 'batch_id' => $batch->id, 'step_number' => 1, 'name' => 'Assemble', + 'status' => BatchStep::STATUS_IN_PROGRESS, + ]); + } + + private function def(array $attrs = []): TemplateStepOutput + { + return TemplateStepOutput::create(array_merge([ + 'process_template_id' => $this->template->id, + 'template_step_id' => $this->templateStep->id, + 'key' => 'output_torque', 'label' => 'Torque', 'value_type' => 'number', 'is_required' => true, + ], $attrs)); + } + + private function asOperator() + { + return $this->actingAs($this->operator)->withSession(['selected_line_id' => $this->line->id]); + } + + // ── Admin authoring ────────────────────────────────────────────────────── + + public function test_admin_adds_a_typed_output_definition(): void + { + $base = "/admin/product-types/{$this->productType->id}/process-templates/{$this->template->id}"; + + $this->actingAs($this->admin)->post("{$base}/outputs", [ + 'template_step_id' => $this->templateStep->id, + 'key' => 'output_qcpic', 'label' => 'QC photo', 'value_type' => 'picture', 'is_required' => true, + ])->assertRedirect(); + + $this->assertDatabaseHas('template_step_outputs', [ + 'process_template_id' => $this->template->id, 'key' => 'output_qcpic', 'value_type' => 'picture', + ]); + } + + public function test_select_output_requires_options(): void + { + $base = "/admin/product-types/{$this->productType->id}/process-templates/{$this->template->id}"; + + $this->actingAs($this->admin)->post("{$base}/outputs", [ + 'template_step_id' => $this->templateStep->id, + 'key' => 'grade', 'label' => 'Grade', 'value_type' => 'select', + ])->assertSessionHasErrors('options'); + } + + public function test_operator_cannot_author_outputs(): void + { + $base = "/admin/product-types/{$this->productType->id}/process-templates/{$this->template->id}"; + + $this->actingAs($this->operator)->post("{$base}/outputs", [ + 'template_step_id' => $this->templateStep->id, 'key' => 'x', 'label' => 'X', 'value_type' => 'text', + ])->assertForbidden(); + } + + // ── Completion gate ────────────────────────────────────────────────────── + + public function test_required_output_blocks_step_completion(): void + { + $output = $this->def(); + $service = app(\App\Services\WorkOrder\BatchService::class); + + try { + $service->completeStep($this->batchStep, $this->operator); + $this->fail('Expected completion to be blocked by the required output.'); + } catch (\Exception) { + $this->assertSame(BatchStep::STATUS_IN_PROGRESS, $this->batchStep->fresh()->status); + } + + BatchStepOutputValue::create([ + 'batch_step_id' => $this->batchStep->id, 'output_id' => $output->id, + 'value_number' => 5, 'recorded_by_id' => $this->operator->id, 'recorded_at' => now(), + ]); + + $service->completeStep($this->batchStep->fresh(), $this->operator); + $this->assertSame(BatchStep::STATUS_DONE, $this->batchStep->fresh()->status); + } + + // ── Operator recording ─────────────────────────────────────────────────── + + public function test_operator_view_carries_step_outputs(): void + { + $this->def(['label' => 'Torque']); + + $this->asOperator() + ->get(route('operator.work-order.detail', $this->workOrder)) + ->assertOk() + ->assertInertia(fn (\Inertia\Testing\AssertableInertia $p) => $p + ->component('operator/WorkOrderDetail') + ->has('stepOutputs.1', 1) + ->where('stepOutputs.1.0.label', 'Torque')); + } + + public function test_operator_records_a_number_output(): void + { + $output = $this->def(['value_type' => 'number']); + + $this->asOperator() + ->post("/operator/batch-step/{$this->batchStep->id}/outputs/{$output->id}", ['value' => '4.2']) + ->assertRedirect(); + + $value = BatchStepOutputValue::firstWhere('output_id', $output->id); + $this->assertEqualsWithDelta(4.2, (float) $value->value_number, 0.0001); + $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']); + $file = UploadedFile::fake()->image('qc.jpg', 640, 480); + + $this->asOperator() + ->post("/operator/batch-step/{$this->batchStep->id}/outputs/{$output->id}", ['value' => $file]) + ->assertRedirect()->assertSessionHas('success'); + + $value = BatchStepOutputValue::firstWhere('output_id', $output->id); + $this->assertNotNull($value->file_path); + $this->assertTrue(Storage::exists($value->file_path)); + + // Serves inline over the authenticated operator endpoint. + $this->asOperator() + ->get("/operator/batch-step-output/{$value->id}/file") + ->assertOk() + ->assertHeader('X-Content-Type-Options', 'nosniff'); + } + + public function test_non_image_upload_to_a_picture_output_is_rejected(): void + { + $output = $this->def(['key' => 'output_qcpic', 'value_type' => 'picture']); + $file = UploadedFile::fake()->create('notes.pdf', 20, 'application/pdf'); + + $this->asOperator() + ->post("/operator/batch-step/{$this->batchStep->id}/outputs/{$output->id}", ['value' => $file]) + ->assertSessionHasErrors('value'); + + $this->assertDatabaseCount('batch_step_output_values', 0); + } + + public function test_recording_is_idempotent_overwrite(): void + { + $output = $this->def(['value_type' => 'number']); + $url = "/operator/batch-step/{$this->batchStep->id}/outputs/{$output->id}"; + + $this->asOperator()->post($url, ['value' => '1'])->assertRedirect(); + $this->asOperator()->post($url, ['value' => '2'])->assertRedirect(); + + // One live value (the latest); the prior is soft-deleted (audit). + $this->assertSame(1, BatchStepOutputValue::where('output_id', $output->id)->count()); + $this->assertEqualsWithDelta(2.0, (float) BatchStepOutputValue::firstWhere('output_id', $output->id)->value_number, 0.0001); + } + + public function test_read_api_returns_recorded_outputs_for_a_work_order(): void + { + $num = $this->def(['key' => 'output_torque', 'value_type' => 'number']); + BatchStepOutputValue::create([ + 'batch_step_id' => $this->batchStep->id, 'output_id' => $num->id, + 'value_number' => 5.5, 'recorded_by_id' => $this->operator->id, 'recorded_at' => now(), + ]); + + // The read API is for privileged/ERP callers — grant the view ability. + \Spatie\Permission\Models\Permission::findOrCreate('view work orders', 'web'); + $this->admin->givePermissionTo('view work orders'); + + $token = $this->admin->createToken('t')->plainTextToken; + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/v1/work-orders/{$this->workOrder->id}/step-outputs") + ->assertOk() + ->assertJsonPath('data.0.key', 'output_torque') + ->assertJsonPath('data.0.value', 5.5) + ->assertJsonPath('data.0.step_number', 1); + } + + public function test_output_from_another_template_is_rejected(): void + { + $otherTemplate = ProcessTemplate::factory()->create(['product_type_id' => $this->productType->id, 'version' => 2]); + $otherStep = TemplateStep::factory()->create(['process_template_id' => $otherTemplate->id, 'step_number' => 1]); + $stranger = TemplateStepOutput::create([ + 'process_template_id' => $otherTemplate->id, 'template_step_id' => $otherStep->id, + 'key' => 'x', 'label' => 'X', 'value_type' => 'number', + ]); + + $this->asOperator() + ->post("/operator/batch-step/{$this->batchStep->id}/outputs/{$stranger->id}", ['value' => '1']) + ->assertRedirect()->assertSessionHas('error'); + + $this->assertDatabaseCount('batch_step_output_values', 0); + } +} diff --git a/backend/tests/Feature/Web/Admin/LineProductTypesSyncTest.php b/backend/tests/Feature/Web/Admin/LineProductTypesSyncTest.php new file mode 100644 index 00000000..feb1d652 --- /dev/null +++ b/backend/tests/Feature/Web/Admin/LineProductTypesSyncTest.php @@ -0,0 +1,93 @@ +seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Admin'); + } + + public function test_admin_syncs_product_types_to_a_line_via_the_frontend_url(): void + { + $line = Line::factory()->create(); + $a = ProductType::factory()->create(); + $b = ProductType::factory()->create(); + + $response = $this->actingAs($this->admin) + ->post("/admin/lines/{$line->id}/product-types/sync", [ + 'product_type_ids' => [$a->id, $b->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + $this->assertEqualsCanonicalizing( + [$a->id, $b->id], + $line->productTypes()->pluck('product_types.id')->all(), + ); + } + + public function test_sync_replaces_the_previous_assignment(): void + { + $line = Line::factory()->create(); + $old = ProductType::factory()->create(); + $new = ProductType::factory()->create(); + $line->productTypes()->sync([$old->id]); + + $this->actingAs($this->admin) + ->post("/admin/lines/{$line->id}/product-types/sync", ['product_type_ids' => [$new->id]]) + ->assertRedirect(); + + $this->assertSame([$new->id], $line->productTypes()->pluck('product_types.id')->all()); + } + + public function test_empty_payload_clears_all_assignments(): void + { + $line = Line::factory()->create(); + $pt = ProductType::factory()->create(); + $line->productTypes()->sync([$pt->id]); + + $this->actingAs($this->admin) + ->post("/admin/lines/{$line->id}/product-types/sync", []) + ->assertRedirect(); + + $this->assertCount(0, $line->productTypes()->get()); + } + + public function test_unknown_product_type_id_is_rejected(): void + { + $line = Line::factory()->create(); + + $this->actingAs($this->admin) + ->post("/admin/lines/{$line->id}/product-types/sync", ['product_type_ids' => [999999]]) + ->assertSessionHasErrors('product_type_ids.0'); + } + + public function test_guest_cannot_sync(): void + { + $line = Line::factory()->create(); + + $this->post("/admin/lines/{$line->id}/product-types/sync", ['product_type_ids' => []]) + ->assertRedirect('/login'); + } +} diff --git a/backend/tests/Feature/Web/Admin/ProcessTemplateStepWebTest.php b/backend/tests/Feature/Web/Admin/ProcessTemplateStepWebTest.php index 39b68eaa..28b363d1 100644 --- a/backend/tests/Feature/Web/Admin/ProcessTemplateStepWebTest.php +++ b/backend/tests/Feature/Web/Admin/ProcessTemplateStepWebTest.php @@ -95,6 +95,21 @@ public function test_admin_can_set_isa95_workstation_type_and_standard_times(): ]); } + public function test_admin_can_set_equipment_parameters_on_a_step(): void + { + [$pt, $tpl] = $this->template(); + + $this->actingAs($this->admin) + ->post($this->base($pt, $tpl).'/steps', [ + 'name' => 'Reflow oven', + 'parameters' => ['temperature_c' => '250', 'humidity_pct' => '40'], + ])->assertRedirect(); + + $step = \App\Models\TemplateStep::where('process_template_id', $tpl->id) + ->where('name', 'Reflow oven')->firstOrFail(); + $this->assertSame(['temperature_c' => '250', 'humidity_pct' => '40'], $step->parameters); + } + public function test_admin_can_update_step(): void { [$pt, $tpl] = $this->template(); diff --git a/backend/tests/Unit/Services/SnapshotServiceTest.php b/backend/tests/Unit/Services/SnapshotServiceTest.php index ead274b2..e7dd119b 100644 --- a/backend/tests/Unit/Services/SnapshotServiceTest.php +++ b/backend/tests/Unit/Services/SnapshotServiceTest.php @@ -78,6 +78,36 @@ public function test_snapshot_step_has_required_fields(): void $this->assertArrayHasKey('workstation_type_id', $step); $this->assertArrayHasKey('setup_time_minutes', $step); $this->assertArrayHasKey('run_time_per_unit_minutes', $step); + // Equipment parameters (Feature A). + $this->assertArrayHasKey('parameters', $step); + } + + public function test_snapshot_step_carries_equipment_parameters(): void + { + $template = ProcessTemplate::factory()->withSteps(1)->create(); + $template->steps()->first()->update(['parameters' => ['temperature_c' => '250']]); + + $step = $this->service->createSnapshot($template->fresh())['steps'][0]; + + $this->assertSame(['temperature_c' => '250'], $step['parameters']); + } + + public function test_snapshot_parameters_merge_segment_defaults_with_step_overrides(): void + { + $segment = \App\Models\ProcessSegment::factory()->create([ + 'parameters' => ['temperature_c' => '200', 'humidity_pct' => '40'], + ]); + + $template = ProcessTemplate::factory()->withSteps(1)->create(); + // Step overrides temperature, inherits humidity from the segment. + $template->steps()->first()->update([ + 'process_segment_id' => $segment->id, + 'parameters' => ['temperature_c' => '250'], + ]); + + $step = $this->service->createSnapshot($template->fresh())['steps'][0]; + + $this->assertSame(['temperature_c' => '250', 'humidity_pct' => '40'], $step['parameters']); } public function test_snapshot_step_carries_isa95_standard_times(): void diff --git a/mobile/lang/vi.json b/mobile/lang/vi.json index 810eb828..66532eb4 100644 --- a/mobile/lang/vi.json +++ b/mobile/lang/vi.json @@ -33,7 +33,7 @@ "Active (line is ready for production)": "Đang hoạt động (dây chuyền đã sẵn sàng để sản xuất)", "Active (ready for production)": "Đang hoạt động (sẵn sàng cho sản xuất)", "Active (start listening on daemon start)": "Đang hoạt động (bắt đầu nghe khi bắt đầu daemon)", - "Active (template is ready for use in work orders)": "Hoạt động (mẫu đã sẵn sàng để sử dụng trong các lệnh làm việc)", + "Active (template is ready for use in work orders)": "Hoạt động (mẫu đã sẵn sàng để sử dụng trong các lệnh sản xuất)", "Active (workstation is ready for use)": "Đang hoạt động (trạm làm việc đã sẵn sàng để sử dụng)", "Active downtime": "Thời gian ngừng hoạt động", "Active Lines": "Dây chuyền hoạt động", @@ -120,7 +120,7 @@ "All Types": "Tất cả các loại", "All types": "Tất cả các loại", "All Users": "Tất cả người dùng", - "All work orders": "Tất cả các lệnh làm việc", + "All work orders": "Tất cả các lệnh sản xuất", "ALL-ACCESS": "TRUY CẬP TẤT CẢ", "Allow operators to record more units than the planned quantity.": "Cho phép người vận hành ghi nhiều đơn vị hơn số lượng dự kiến.", "Allow overproduction": "Cho phép sản xuất thừa", @@ -136,7 +136,7 @@ "Anomaly Reason": "Lý do bất thường", "Anomaly Reasons": "Lý do bất thường", "Anomaly reasons": "Lý do bất thường", - "Any extra field — stored as JSON on the work order": "Bất kỳ trường bổ sung nào — được lưu trữ dưới dạng JSON trên lệnh làm việc", + "Any extra field — stored as JSON on the work order": "Bất kỳ trường bổ sung nào — được lưu trữ dưới dạng JSON trên lệnh sản xuất", "API Tokens": "Mã thông báo API", "API tokens": "Mã thông báo API", "Apply": "Áp dụng", @@ -174,9 +174,9 @@ "Auto-generated if empty": "Tự động tạo nếu trống", "Auto-scroll": "Tự động cuộn", "Auxiliary Material": "Vật liệu phụ trợ", - "AVAIL": "CÓ SẴN", + "AVAIL": "KHẢ DỤNG", "Availability": "Khả dụng", - "Availability × Perf × Qual": "Sẵn có × Hoàn hảo × Chất lượng", + "Availability × Perf × Qual": "Khả dụng × Hiệu suất × Chất lượng", "Available": "Có sẵn", "Available hooks and events": "Các móc và sự kiện có sẵn", "Available keys:": "Các phím có sẵn:", @@ -198,7 +198,7 @@ "Back to Templates": "Quay lại Mẫu", "Back to Users": "Quay lại Người dùng", "Back to Workstations": "Quay lại trạm làm việc", - "Backlog": "Hàng đợi", + "Backlog": "Hàng chờ", "BACKWARD · SOURCE LOTS": "LÙI · CÁC LÔ NGUỒN", "Base Hourly Rate": "Mức cơ bản theo giờ", "Basic": "Cơ bản", @@ -220,7 +220,7 @@ "Blocked Orders": "Lệnh bị chặn", "Blocked since": "Bị chặn kể từ", "Blocked WO": "WO bị chặn", - "Blocked Work Orders": "Lệnh làm việc bị chặn", + "Blocked Work Orders": "Lệnh sản xuất bị chặn", "BLOCKING": "CHẶN", "Blocking": "Chặn", "Blocking Issues": "Sự cố chặn", @@ -313,11 +313,11 @@ "complete": "hoàn thành", "Complete failed": "Hoàn thành không thành công", "Complete step": "Bước hoàn thành", - "Complete Work Order": "Hoàn thành lệnh làm việc", + "Complete Work Order": "Hoàn thành lệnh sản xuất", "Completed": "Đã hoàn thành", "Completed By": "Hoàn thành bởi", "Completed with errors": "Đã hoàn thành với lỗi", - "Completed Work Orders": "Lệnh làm việc đã hoàn thành", + "Completed Work Orders": "Lệnh sản xuất đã hoàn thành", "Completed:": "Đã hoàn thành:", "Completing…": "Đang hoàn thành…", "Completion": "Tỉ lệ hoàn thành", @@ -392,7 +392,7 @@ "Create Division": "Tạo Bộ phận", "Create Factory": "Tạo nhà máy", "Create First Shift": "Tạo ca đầu tiên", - "Create First Work Order": "Tạo lệnh làm việc đầu tiên", + "Create First Work Order": "Tạo lệnh sản xuất đầu tiên", "Create Issue Type": "Tạo loại vấn đề", "Create Line": "Tạo dây chuyền", "Create line": "Tạo dây chuyền", @@ -416,8 +416,8 @@ "Create User": "Tạo người dùng", "Create View Template": "Tạo mẫu xem", "Create Wage Group": "Tạo Nhóm Lương", - "Create Work Order": "Tạo lệnh làm việc", - "Create work order": "Tạo lệnh làm việc", + "Create Work Order": "Tạo lệnh sản xuất", + "Create work order": "Tạo lệnh sản xuất", "Create Worker": "Tạo công nhân", "Create worker profile": "Tạo hồ sơ công nhân", "Create Workstation": "Tạo Trạm làm việc", @@ -538,7 +538,7 @@ "Detail": "Chi tiết", "DETAILS": "CHI TIẾT", "Details": "Chi tiết", - "Determines how work orders are grouped for planning.": "Xác định cách nhóm các lệnh công việc để lập kế hoạch.", + "Determines how work orders are grouped for planning.": "Xác định cách nhóm các lệnh sản xuất để lập kế hoạch.", "Deviation": "Độ lệch", "Dimension": "Kích thước", "Disable": "Vô hiệu hóa", @@ -606,7 +606,7 @@ "Edit User": "Chỉnh sửa người dùng", "Edit View Template": "Chỉnh sửa Mẫu", "Edit Wage Group": "Chỉnh sửa nhóm lương", - "Edit Work Order": "Chỉnh sửa lệnh làm việc", + "Edit Work Order": "Chỉnh sửa lệnh sản xuất", "Edit Worker": "Chỉnh sửa công nhân", "Edit worker": "Chỉnh sửa công nhân", "Edit Workstation": "Chỉnh sửa trạm làm việc", @@ -741,7 +741,7 @@ "High": "Cao", "Higher = sooner": "Cao hơn = sớm hơn", "History & reasons": "Lịch sử & lý do", - "History of bulk work-order imports.": "Lịch sử nhập lệnh làm việc số lượng lớn.", + "History of bulk work-order imports.": "Lịch sử nhập lệnh sản xuất số lượng lớn.", "horizon": "chân trời", "Host": "Máy chủ", "HOURS": "GIỜ", @@ -756,7 +756,7 @@ "I understand this will add demo data to the system": "Tôi hiểu điều này sẽ thêm dữ liệu demo vào hệ thống", "I'll do it later": "Tôi sẽ làm việc đó sau", "idle": "nhàn rỗi", - "If selected, every imported work order will be assigned to this line, overriding any line_code column in the file.": "Nếu chọn, mọi lệnh làm việc được nhập sẽ được chỉ định cho dây chuyền này, ghi đè lên bất kỳ cột line_code nào trong tệp.", + "If selected, every imported work order will be assigned to this line, overriding any line_code column in the file.": "Nếu chọn, mọi lệnh sản xuất được nhập sẽ được chỉ định cho dây chuyền này, ghi đè lên bất kỳ cột line_code nào trong tệp.", "Ignore this column": "Bỏ qua cột này", "Immutable": "bất biến", "IMPACT": "TÁC ĐỘNG", @@ -769,7 +769,7 @@ "Import materials from CSV, XLS or XLSX file (e.g. Subiekt GT export)": "Nhập tài liệu từ tệp CSV, XLS hoặc XLSX (ví dụ: xuất Subiekt GT)", "Import Strategy": "Chiến lược nhập", "Import Summary": "Tóm tắt lượt nhập", - "Import work orders from a CSV, XLS or XLSX file with custom column mapping": "Nhập lệnh làm việc từ tệp CSV, XLS hoặc XLSX với cấu hình ánh xạ cột tùy chỉnh", + "Import work orders from a CSV, XLS or XLSX file with custom column mapping": "Nhập lệnh sản xuất từ tệp CSV, XLS hoặc XLSX với cấu hình ánh xạ cột tùy chỉnh", "imported": "đã nhập", "Imported CSV data": "Dữ liệu CSV đã nhập", "IN PROGRESS": "ĐANG TIẾN HÀNH", @@ -843,7 +843,7 @@ "LAST SCAN": "QUÉT CUỐI CÙNG", "LAST SEEN {{ago}} AGO": "XEM CUỐI CÙNG {{ago}} TRƯỚC", "Last used": "Lần sử dụng cuối cùng", - "Latest work orders with status and progress": "Lệnh làm việc mới nhất với trạng thái và tiến độ", + "Latest work orders with status and progress": "Lệnh sản xuất mới nhất với trạng thái và tiến độ", "Leader": "Lãnh đạo", "Leave all unchecked to allow all product types on this line.": "Để trống tất cả để cho phép tất cả các loại sản phẩm trên dây chuyền này.", "leave blank to keep current": "để trống để giữ hiện tại", @@ -940,7 +940,7 @@ "MATCHED": "ĐÃ PHÙ HỢP", "Matching Logic": "Logic phù hợp", "MATERIAL": "VẬT LIỆU", - "Material": "Vật liệu/Vật tư", + "Material": "Vật liệu", "Material lot explorer": "Nhà thám hiểm lô vật liệu", "Material lots": "Lô vật liệu", "material name": "tên vật liệu", @@ -1011,10 +1011,10 @@ "New User": "Người dùng mới", "New Wage Group": "Nhóm lương mới", "New WO": "WO mới", - "NEW WORK ORDER": "LỆNH LÀM VIỆC MỚI", - "New Work Order": "Lệnh làm việc mới", - "New work order": "Lệnh làm việc mới", - "NEW WORK ORDERS START HERE": "LỆNH LÀM VIỆC MỚI BẮT ĐẦU TẠI ĐÂY", + "NEW WORK ORDER": "LỆNH SẢN XUẤT MỚI", + "New Work Order": "Lệnh sản xuất mới", + "New work order": "Lệnh sản xuất mới", + "NEW WORK ORDERS START HERE": "LỆNH SẢN XUẤT MỚI BẮT ĐẦU TẠI ĐÂY", "New Worker": "Công nhân mới", "New worker": "Công nhân mới", "New Workstation": "Trạm làm việc mới", @@ -1042,7 +1042,7 @@ "No areas": "Không có khu vực", "No audit logs found": "Không tìm thấy nhật ký kiểm tra", "No available lots": "Không có lô nào có sẵn", - "No batches available for this work order.": "Không có lô nào khả dụng cho lệnh làm việc này.", + "No batches available for this work order.": "Không có lô nào khả dụng cho lệnh sản xuất này.", "No batches created yet": "Chưa có lô nào được tạo", "No batches yet.": "Chưa có lô nào.", "No BOM": "Không có BOM", @@ -1150,7 +1150,7 @@ "No work orders found.": "Không tìm thấy lệnh sản xuất nào.", "No work orders in this range": "Không có lệnh sản xuất nào trong phạm vi này", "No work orders scheduled for this week.": "Không có lệnh sản xuất nào được lên lịch cho tuần này.", - "No work orders yet": "Chưa có lệnh làm việc nào", + "No work orders yet": "Chưa có lệnh sản xuất nào", "No workers": "Không có công nhân", "No workers yet": "Chưa có công nhân", "No workstation types": "Không có loại trạm làm việc", @@ -1213,7 +1213,7 @@ "Operation": "hoạt động", "Operations": "Hoạt động", "OPERATOR": "NGƯỜI VẬN HÀNH", - "Operator": "Vận hành viên (Operator)", + "Operator": "Người vận hành", "Operator clicks Start/Complete on each step at each workstation. Full traceability.": "Người vận hành nhấp vào Bắt đầu/Hoàn thành trên mỗi bước tại mỗi trạm làm việc. Truy xuất nguồn gốc đầy đủ.", "Operator enters total produced quantity at the end. No step tracking.": "Người vận hành nhập tổng số lượng sản xuất vào cuối. Không theo dõi bước.", "OPERATORS": "NGƯỜI VẬN HÀNH", @@ -1232,7 +1232,7 @@ "order": "đặt hàng", "Order #": "Số lệnh", "Order Details": "Chi tiết đặt hàng", - "Order Number": "Số lệnh làm việc", + "Order Number": "Số lệnh sản xuất", "Order number": "Số lệnh", "Orders": "Lệnh", "orders": "mệnh lệnh", @@ -1249,7 +1249,7 @@ "OVERDUE": "QUÁ HẠN", "Overdue": "Quá hạn", "Overdue WO": "Quá hạn WO", - "Overdue Work Orders": "Lệnh làm việc quá hạn", + "Overdue Work Orders": "Lệnh sản xuất quá hạn", "overload": "quá tải", "overload / alert": "quá tải/cảnh báo", "OVERSIGHT": "GIÁM SÁT", @@ -1279,9 +1279,9 @@ "Pay tiers": "Trả bậc", "Payload format": "Định dạng tải trọng", "Payload rules": "Quy tắc tải trọng", - "PCS": "chiếc", - "pcs": "cái", - "PCS PLANNED": "CHIẾC KẾ HOẠCH", + "PCS": "sp", + "pcs": "sp", + "PCS PLANNED": "SP DỰ KIẾN", "PENDING": "Đang chờ xử lý", "Pending": "Chờ duyệt", "People": "mọi người", @@ -1290,7 +1290,7 @@ "Per Operation": "Mỗi hoạt động", "Per step": "Mỗi bước", "Per Unit": "mỗi đơn vị", - "PERF": "HOÀN HẢO", + "PERF": "HIỆU SUẤT", "Performance": "Hiệu suất", "Period Type": "Loại kỳ", "Personnel classes": "Lớp nhân sự", @@ -1438,7 +1438,7 @@ "Recent Imports": "Các lượt nhập gần đây", "Recent Issues": "Các vấn đề gần đây", "RECENT LOTS": "LÔ GẦN ĐÂY", - "Recent Work Orders": "Lệnh làm việc gần đây", + "Recent Work Orders": "Lệnh sản xuất gần đây", "Recently edited": "Đã chỉnh sửa gần đây", "Recipe / Materials": "Công thức / Nguyên liệu", "Reconnect all": "Kết nối lại tất cả", @@ -1454,7 +1454,7 @@ "Register a tool or machine": "Đăng ký một công cụ hoặc máy móc", "Reject": "Từ chối", "Reject this work order?": "Từ chối lệnh sản xuất này?", - "Reject work order": "Từ chối lệnh làm việc", + "Reject work order": "Từ chối lệnh sản xuất", "Rejected": "Bị từ chối", "Release Batch": "Mở khóa lô sản xuất", "Release failed": "Phát hành không thành công", @@ -1578,7 +1578,7 @@ "Select reason": "Chọn lý do", "Select type...": "Chọn loại...", "Select WO": "Chọn WO", - "Select work order": "Chọn lệnh làm việc", + "Select work order": "Chọn lệnh sản xuất", "Select workstation": "Chọn trạm làm việc", "Select...": "Chọn...", "SELECTED": "ĐÃ CHỌN", @@ -1643,7 +1643,7 @@ "Source": "Nguồn", "Source System": "Hệ thống nguồn", "SOURCES": "NGUỒN", - "Standard work order list with status, batches, priority and actions.": "Danh sách lệnh làm việc tiêu chuẩn với trạng thái, lô, mức độ ưu tiên và hành động.", + "Standard work order list with status, batches, priority and actions.": "Danh sách lệnh sản xuất tiêu chuẩn với trạng thái, lô, mức độ ưu tiên và hành động.", "Start": "Bắt đầu", "Start a step before picking a lot.": "Bắt đầu một bước trước khi chọn nhiều.", "Start Date": "Ngày bắt đầu", @@ -1743,7 +1743,7 @@ "This is a": "Đây là một", "THIS WEEK": "TUẦN NÀY", "This will record a blocking issue and pause production.": "Điều này sẽ ghi lại sự cố chặn và tạm dừng sản xuất.", - "This work order has no bill of materials. Nothing will be allocated.": "Lệnh làm việc này không có định mức nguyên vật liệu (BOM). Không có gì sẽ được phân bổ.", + "This work order has no bill of materials. Nothing will be allocated.": "Lệnh sản xuất này không có định mức nguyên vật liệu (BOM). Không có gì sẽ được phân bổ.", "Throughput": "Thông lượng", "Time": "thời gian", "Time range": "Khung giờ", @@ -1777,15 +1777,15 @@ "total done": "tổng cộng đã hoàn thành", "Total Lines": "Tổng số dây chuyền", "Total Orders": "Tổng số lệnh", - "total pcs": "tổng số chiếc", + "total pcs": "tổng số sp", "Total Produced Qty": "Tổng số lượng sản xuất", "Total Product Types": "Tổng số loại sản phẩm", "Total rows": "Tổng số hàng", "total rows": "tổng số hàng", "Total Templates": "Tổng số mẫu", "Total Users": "Tổng số người dùng", - "Total Work Orders": "Tổng số lệnh làm việc", - "total work orders": "tổng số lệnh làm việc", + "Total Work Orders": "Tổng số lệnh sản xuất", + "total work orders": "tổng số lệnh sản xuất", "Track all system changes and user activities": "Theo dõi tất cả thay đổi hệ thống và hoạt động của người dùng", "Tracking": "Theo dõi", "Tracking Type": "Loại theo dõi", @@ -1889,16 +1889,16 @@ "wk": "tuần", "Work": "công việc", "work": "làm việc", - "WORK ORDER": "LỆNH LÀM VIỆC", - "Work Order": "Lệnh làm việc", - "Work order": "Lệnh làm việc", - "work order description": "mô tả lệnh làm việc", - "Work Order Details": "Chi tiết lệnh làm việc", + "WORK ORDER": "LỆNH SẢN XUẤT", + "Work Order": "Lệnh sản xuất", + "Work order": "Lệnh sản xuất", + "work order description": "mô tả lệnh sản xuất", + "Work Order Details": "Chi tiết lệnh sản xuất", "Work order status is changed manually. Board statuses are visual labels.": "Trạng thái lệnh sản xuất được thay đổi theo cách thủ công. Trạng thái bảng là nhãn trực quan.", - "WORK ORDERS": "LỆNH LÀM VIỆC", - "Work Orders": "Lệnh làm việc", - "Work orders": "Lệnh làm việc", - "Work orders and bulk imports.": "Lệnh làm việc và nhập hàng loạt.", + "WORK ORDERS": "LỆNH SẢN XUẤT", + "Work Orders": "Lệnh sản xuất", + "Work orders": "Lệnh sản xuất", + "Work orders and bulk imports.": "Lệnh sản xuất và nhập hàng loạt.", "Work Orders by Status": "Lệnh sản xuất theo trạng thái", "Work orders, issues, and lines summary cards": "Thẻ tóm tắt lệnh sản xuất, vấn đề và dây chuyền", "Work patterns": "Mẫu công việc", @@ -2037,7 +2037,7 @@ "Light Mode": "Chế độ ánh sáng", "Line total": "Tổng số dây chuyền", "Material cost": "Chi phí vật liệu", - "Material, labor and additional cost per finished work order.": "Vật liệu, nhân công và chi phí bổ sung cho mỗi lệnh làm việc đã hoàn thành.", + "Material, labor and additional cost per finished work order.": "Vật liệu, nhân công và chi phí bổ sung cho mỗi lệnh sản xuất đã hoàn thành.", "Mixed currencies - totals are summed without conversion.": "Các loại tiền tệ hỗn hợp - tổng số được tính tổng mà không cần chuyển đổi.", "No cost data for this work order.": "Không có dữ liệu chi phí cho lệnh sản xuất này.", "No skills defined.": "Không có kỹ năng được xác định.", @@ -2078,7 +2078,7 @@ "the system currency from Settings. Amounts in other currencies are summed without conversion and flagged.": "loại tiền tệ của hệ thống từ Cài đặt. Số tiền bằng loại tiền tệ khác được cộng lại mà không cần chuyển đổi và được gắn cờ.", "This month": "Tháng này", "Total cost": "Tổng chi phí", - "Total cost = materials + labor + additional costs, per finished work order. Cost per unit = total / produced quantity.": "Tổng chi phí = vật liệu + nhân công + chi phí bổ sung, trên mỗi lệnh làm việc đã hoàn thành. Chi phí mỗi đơn vị = tổng/số lượng sản xuất.", + "Total cost = materials + labor + additional costs, per finished work order. Cost per unit = total / produced quantity.": "Tổng chi phí = vật liệu + nhân công + chi phí bổ sung, trên mỗi lệnh sản xuất đã hoàn thành. Chi phí mỗi đơn vị = tổng/số lượng sản xuất.", "Ukrainian Hryvnia": "Hryvnia Ucraina", "Unit price": "Đơn giá", "US Dollar": "đô la Mỹ", @@ -2105,7 +2105,7 @@ "No EAN": "Không có EAN", "No results": "Không có kết quả", "No scans in this shift": "Không quét trong ca này", - "No work orders with assigned EAN codes": "Không có lệnh làm việc nào được gán mã EAN", + "No work orders with assigned EAN codes": "Không có lệnh sản xuất nào được gán mã EAN", "Open station": "Mở trạm làm việc", "Packed": "Đóng gói", "Packed (shift)": "Đã đóng gói (ca)", @@ -2125,8 +2125,8 @@ "Waiting for scan…": "Đang chờ quét…", "Work orders to pack": "Lệnh sản xuất cần đóng gói", "e.g. 5901234123457": "ví dụ: 5901234123457", - "pcs.": "chiếc.", - "— select work order —": "— chọn lệnh làm việc —", + "pcs.": "sp.", + "— select work order —": "— chọn lệnh sản xuất —", "Add tag": "Thêm thẻ", "All connectivity": "Tất cả các kết nối", "Anonymous": "Ẩn danh", @@ -2161,7 +2161,7 @@ "Little-endian": "Little endian", "Live workstation states from connected machines.": "Trạng thái trạm làm việc trực tiếp từ các máy được kết nối.", "Machine Monitor": "Màn hình máy", - "Material lot": "Lô vật tư", + "Material lot": "Lô vật liệu", "Modbus Connections": "Kết nối Modbus", "Modbus TCP": "Modbus TCP", "Name *": "Tên *", @@ -2276,7 +2276,7 @@ "topic": "chủ đề", "topics": "chủ đề", "unit": "đơn vị", - "work orders": "lệnh làm việc", + "work orders": "lệnh sản xuất", "— none —": "— không có —", "+ Add Area": "+ Thêm khu vực", "-- choose --": "-- chọn --", @@ -2321,7 +2321,7 @@ "Application log": "Nhật ký ứng dụng", "Area (ISA-95)": "Khu vực (ISA-95)", "Areas": "Khu vực", - "Assign barcodes to work orders": "Gán mã vạch cho lệnh làm việc", + "Assign barcodes to work orders": "Gán mã vạch cho lệnh sản xuất", "Assigned to": "Được giao cho", "Auto-recorded from machine state :state": "Tự động ghi từ trạng thái máy :state", "Auto-refresh disabled": "Tự động làm mới bị vô hiệu hóa", @@ -2846,10 +2846,10 @@ "Regenerate": "tái sinh", "Regenerate Recovery Codes": "Tạo lại mã khôi phục", "Register": "Đăng ký", - "Register Material Lot": "Đăng ký lô vật tư", + "Register Material Lot": "Đăng ký lô vật liệu", "Register first lot": "Đăng ký lô đầu tiên", "Register lot": "Đăng ký lô", - "Register material lot": "Đăng ký lô vật tư", + "Register material lot": "Đăng ký lô vật liệu", "Register your first lot to start traceable consumption.": "Đăng ký lô đầu tiên của bạn để bắt đầu tiêu thụ có thể theo dõi.", "Reject (no further action)": "Từ chối (không xử lý thêm)", "Reject work order :order?": "Từ chối lệnh sản xuất :order?", @@ -3028,11 +3028,11 @@ "Where": "Ở đâu", "Where from": "từ đâu đến", "Who": "Ai", - "Work order fully packed": "Lệnh làm việc đã đóng gói đầy đủ", + "Work order fully packed": "Lệnh sản xuất đã đóng gói đầy đủ", "Work order not found": "Không tìm thấy lệnh sản xuất", "Work order not in a packable state (current: :status)": "Lệnh sản xuất không ở trạng thái có thể đóng gói (hiện tại: :status)", "Work order span updated.": "Đã cập nhật khoảng thời gian lệnh sản xuất.", - "Work order updated successfully.": "Lệnh công việc được cập nhật thành công.", + "Work order updated successfully.": "Lệnh sản xuất được cập nhật thành công.", "Workers in this class": "Công nhân thuộc lớp này", "Workstation routing": "Định tuyến trạm làm việc", "Yes": "Có", @@ -3044,7 +3044,7 @@ "available": "có sẵn", "below min level": "dưới mức tối thiểu", "completed": "hoàn thành", - "counted as availability loss": "được tính là mất khả năng sẵn có", + "counted as availability loss": "được tính là tổn thất khả dụng", "e.g. 2 weeks, 500 hours": "ví dụ: 2 tuần, 500 giờ", "e.g. AREA-01": "ví dụ: KHU-01", "e.g. Assembly Hall A": "ví dụ: Hội quán A", @@ -3071,7 +3071,7 @@ "pull values from the work order's imported data.": "lấy các giá trị từ dữ liệu đã nhập của lệnh sản xuất.", "quarantined": "bị cách ly", "see backward genealogy API for full chain.": "xem API phả hệ ngược để biết chuỗi đầy đủ.", - "select work order": "chọn lệnh làm việc", + "select work order": "chọn lệnh sản xuất", "step(s)": "(các) bước", "subtracted from planned time": "trừ vào thời gian dự kiến", "units across active batches": "đơn vị trên các lô hoạt động", @@ -3104,7 +3104,7 @@ "Line :id": "Dây chuyền :id", "Lots released": "Lô đã giải phóng", "Never used": "Chưa bao giờ sử dụng", - "No active work orders.": "Không có lệnh làm việc đang hoạt động.", + "No active work orders.": "Không có lệnh sản xuất đang hoạt động.", "No automatic refresh — reload the page to see changes": "Không tự động làm mới - tải lại trang để xem các thay đổi", "No data.": "Không có dữ liệu.", "No open issues.": "Không có vấn đề mở.", @@ -3115,7 +3115,7 @@ "Production period split, overproduction rules, step sequencing": "Phân chia thời gian sản xuất, quy tắc sản xuất thừa, trình tự các bước", "Quarantined": "Cách ly", "Recent issues": "Các vấn đề gần đây", - "Recent work orders": "Lệnh làm việc gần đây", + "Recent work orders": "Lệnh sản xuất gần đây", "Reserved qty": "Số lượng dự trữ", "Revoke token ':name'? This cannot be undone.": "Thu hồi mã thông báo ':name'? Điều này không thể hoàn tác được.", "Role:": "Vai trò:", @@ -3212,7 +3212,7 @@ "+ New Subassembly": "+ Cụm lắp ráp mới", "+ New Tool": "+ Công cụ mới", "+ New Wage Group": "+ Nhóm lương mới", - "+ New Work Order": "+ Lệnh làm việc mới", + "+ New Work Order": "+ Lệnh sản xuất mới", "+ New Worker": "+ Công nhân mới", "No LOT sequences yet.": "Chưa có quy tắc đánh số lô nào.", "No accounts yet.": "Chưa có tài khoản nào.", @@ -3233,7 +3233,7 @@ "No tools yet.": "Chưa có công cụ nào.", "No view templates yet.": "Chưa có mẫu xem nào.", "No wage groups yet.": "Chưa có nhóm lương nào.", - "No work orders yet.": "Chưa có lệnh làm việc nào.", + "No work orders yet.": "Chưa có lệnh sản xuất nào.", "No workers yet.": "Chưa có công nhân.", "No workstation types yet.": "Chưa có loại trạm làm việc nào.", "Area": "Khu vực", @@ -3297,7 +3297,7 @@ "activated": "kích hoạt", "deactivated": "bị vô hiệu hóa", "Scrap reason :status successfully.": "Lý do phế liệu :status thành công.", - "This work order does not belong to the selected line.": "Lệnh công việc này không thuộc dây chuyền đã chọn.", + "This work order does not belong to the selected line.": "Lệnh sản xuất này không thuộc dây chuyền đã chọn.", "Scrap recorded successfully.": "Phế liệu được ghi thành công.", "Man": "người đàn ông", "Environment": "Môi trường", @@ -3407,7 +3407,7 @@ "Install a module from a ZIP file": "Cài đặt mô-đun từ tệp ZIP", "Interval Value": "Giá trị khoảng", "Lead Time (days)": "Thời gian thực hiện (ngày)", - "Material Type": "Loại vật tư", + "Material Type": "Loại vật liệu", "Mfg Date": "Ngày Sản xuất", "New Integration": "Tích hợp mới", "New status name…": "Tên trạng thái mới…", @@ -3437,11 +3437,11 @@ "Which reasons cause the most scrap (Pareto), and scrap rate per line.": "Nguyên nhân nào gây ra nhiều phế liệu nhất (Pareto) và tỷ lệ phế liệu trên mỗi dây chuyền.", "Workstations — :name": "Trạm làm việc — :name", "e.g. ACME-STEEL-2026-W24-001": "ví dụ: ACME-THÉP-2026-W24-001", - "e.g. pcs, kg, l": "ví dụ: chiếc, kg, l", - "e.g. pcs, kg, l, m. Optional.": "ví dụ: chiếc, kg, l, m. Không bắt buộc.", + "e.g. pcs, kg, l": "ví dụ: sp, kg, l", + "e.g. pcs, kg, l, m. Optional.": "ví dụ: sp, kg, l, m. Không bắt buộc.", "e.g., WIDGET-A, PROD-001": "ví dụ: WIDGET-A, PROD-001", "e.g., Widget Type A, Standard Component": "ví dụ: Loại tiện ích A, Thành phần tiêu chuẩn", - "e.g., pcs, kg, m (optional)": "ví dụ: chiếc, kg, m (tùy chọn)", + "e.g., pcs, kg, m (optional)": "ví dụ: sp, kg, m (tùy chọn)", "never": "không bao giờ", "or place the module folder in": "hoặc đặt thư mục mô-đun vào", "— Global (all lines) —": "— Toàn cầu (tất cả dây chuyền) —", @@ -3616,15 +3616,15 @@ "Setup": "thiết lập", "Waiting": "Đang chờ", "Cleaning": "Vệ sinh", - "Component requirements exploded from planned work orders, netted against on-hand stock, with a shortage list.": "Nhu cầu linh kiện/vật tư được phân rã từ các lệnh sản xuất dự kiến, đối trừ với lượng tồn kho hiện có để lập danh sách thiếu hụt.", - "Component": "Linh kiện / Vật tư", + "Component requirements exploded from planned work orders, netted against on-hand stock, with a shortage list.": "Nhu cầu linh kiện/vật liệu được phân rã từ các lệnh sản xuất dự kiến, đối trừ với lượng tồn kho hiện có để lập danh sách thiếu hụt.", + "Component": "Linh kiện / Vật liệu", "Net": "Nhu cầu ròng", - "Short": "ngắn", + "Short": "Thiếu", "Shortfall": "Thiếu hụt", "Driving work orders": "Lệnh sản xuất liên quan", - "Shortages": "Thiếu hụt vật tư", + "Shortages": "Thiếu hụt vật liệu", "Search components…": "Tìm kiếm thành phần…", - "No shortages — on-hand stock covers the planned work orders.": "Không có thiếu hụt - lượng tồn kho sẵn có đáp ứng đủ cho các lệnh sản xuất dự kiến.", + "No shortages — on-hand stock covers the planned work orders.": "Không có thiếu hụt - lượng tồn kho hiện có đáp ứng đủ cho các lệnh sản xuất dự kiến.", "No shortages.": "Không thiếu.", "Net requirements by component": "Nhu cầu ròng theo linh kiện", "No planned work orders in this period.": "Không có lệnh sản xuất dự kiến nào trong giai đoạn này.", @@ -3964,7 +3964,7 @@ "Edit line": "Chỉnh sửa dây chuyền", "Edit maintenance schedule": "Chỉnh sửa lịch bảo trì", "Edit mapping": "Chỉnh sửa ánh xạ", - "Edit material": "Chỉnh sửa vật tư", + "Edit material": "Chỉnh sửa vật liệu", "Edit pallet": "Chỉnh sửa pallet", "Edit personnel class": "Chỉnh sửa phân loại nhân sự", "Edit product type": "Chỉnh sửa loại sản phẩm", @@ -4075,7 +4075,7 @@ "MQTT connectivity": "Kết nối MQTT", "MQTT off": "Tắt MQTT", "MQTT wildcards (+, #) are accepted": "Chấp nhận ký tự đại diện MQTT (+, #)", - "MRP · shortages": "MRP · thiếu hụt vật tư", + "MRP · shortages": "MRP · thiếu hụt vật liệu", "MY STATION": "TRẠM CỦA TÔI", "Machine monitor": "Giám sát máy móc", "Maintenance history": "Lịch sử bảo trì", @@ -4086,11 +4086,11 @@ "Mark resolved": "Đánh dấu đã giải quyết", "Mark this event as cancelled?": "Đánh dấu sự kiện này là đã hủy?", "Marks the order as effectively complete": "Đánh dấu lệnh sản xuất là đã hoàn thành thực sự", - "Material #{{id}}": "Vật tư #{{id}}", + "Material #{{id}}": "Vật liệu #{{id}}", "Material (optional)": "Nguyên vật liệu (tùy chọn)", - "Material is in use": "Vật tư đang được sử dụng", - "Material sources": "Nguồn vật tư", - "Material type (optional)": "Loại vật tư (tùy chọn)", + "Material is in use": "Vật liệu đang được sử dụng", + "Material sources": "Nguồn vật liệu", + "Material type (optional)": "Loại vật liệu (tùy chọn)", "Max (optional)": "Tối đa (tùy chọn)", "Max 10 MB · .csv, .txt": "Tối đa 10 MB · .csv, .txt", "Menu": "Menu", @@ -4267,7 +4267,7 @@ "PREPEND CURRENT YEAR (E.G. 2026LOT0001)": "THÊM NĂM HIỆN TẠI VÀO TRƯỚC (VÍ DỤ: 2026LOT0001)", "PRODUCED": "ĐÃ SẢN XUẤT", "Pack & Ship": "Đóng gói & Giao hàng", - "Pack {{units}} units of {{each}} each = {{pieces}} pieces": "Đóng gói {{units}} đơn vị, mỗi đơn vị {{each}} = {{pieces}} chiếc", + "Pack {{units}} units of {{each}} each = {{pieces}} pieces": "Đóng gói {{units}} đơn vị, mỗi đơn vị {{each}} = {{pieces}} sp", "Packaging EANs": "Mã EAN đóng gói", "Pad size": "Kích thước đệm", "Pakowanie": "Đóng gói", @@ -4541,7 +4541,7 @@ "cycle calc": "tính toán chu kỳ", "draft": "bản nháp", "e.g. 2 with frequency Weekly = every 2 weeks": "v.d. 2 với tần suất Hàng tuần = mỗi 2 tuần", - "e.g. Energy overage, emergency materials, etc.": "v.d. Vượt định mức năng lượng, vật tư khẩn cấp, v.v.", + "e.g. Energy overage, emergency materials, etc.": "v.d. Vượt định mức năng lượng, vật liệu khẩn cấp, v.v.", "e.g. Factory A, Staging": "v.d. Nhà máy A, Khu vực tập kết", "e.g. Final inspection": "v.d. Kiểm tra thành phẩm", "e.g. LOT": "v.d. LÔ", @@ -4573,7 +4573,7 @@ "orders scheduled": "lệnh đã lập lịch", "orders today": "lệnh hôm nay", "overlap": "chồng chéo", - "pcs, kg, m": "cái, kg, m", + "pcs, kg, m": "sp, kg, m", "processed": "đã xử lý", "read-only": "chỉ đọc", "req": "yêu cầu", @@ -4592,5 +4592,26 @@ "{{name}} disconnected": "{{name}} đã ngắt kết nối", "{{protocol}} Connections": "Kết nối {{protocol}}", "— Ignore —": "— Bỏ qua —", - "— select reason —": "— chọn lý do —" -} \ No newline at end of file + "— select reason —": "— chọn lý do —", + "Detach this segment": "Tách phân đoạn này", + "This order has an exact start and end time. Moving it to a shift cell will clear them.": "Lệnh này có thời gian bắt đầu và kết thúc chính xác. Di chuyển nó vào ô ca làm việc sẽ xóa chúng.", + "Already undone": "Đã hoàn tác", + "Could not undo": "Không thể hoàn tác", + "Med": "Trung bình", + "Could not reschedule": "Không thể dời lịch", + "Rescheduled": "Đã dời lịch", + "No orders match.": "Không có lệnh nào khớp.", + "No changes yet.": "Chưa có thay đổi nào.", + "Return to backlog": "Trở về hàng chờ", + "Undone": "Đã hoàn tác", + "Replace exact time plan?": "Thay thế kế hoạch thời gian chính xác?", + "Undo": "Hoàn tác", + "{{n}} unscheduled in backlog": "{{n}} lệnh chưa lên lịch trong hàng chờ", + "All tiers": "Tất cả hạng", + "Bronze": "Đồng", + "Silver": "Bạc", + "Gold": "Vàng", + "VIP": "VIP", + "Long-press a bar to move it · drag its edges to resize · snaps to {{n}} min": "Nhấn giữ thanh để di chuyển · kéo mép để thay đổi kích thước · bám theo bước {{n}} phút", + "Long-press a block to move it across shifts, days or lines · drag its edges to stretch · drag an edge onto another line to continue the order there · tap it for actions": "Nhấn giữ khối để di chuyển qua các ca, ngày hoặc dây chuyền · kéo mép để kéo giãn · kéo một mép sang dây chuyền khác để tiếp tục lệnh ở đó · chạm để xem thao tác" +}