diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df3f05a14..e6d6c9f6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,13 +140,21 @@ jobs: # along in a release built from it. The three bundled examples are # tracked source and stay — the include rules come first because rsync # takes the first matching rule. + # + # Every pattern is ANCHORED with a leading slash, meaning + # backend/modules and nothing else. Without it rsync matches a + # directory called `modules` at any depth, and this repository has a + # second one: resources/js/Pages/admin/modules. Unanchored, these rules + # stripped the Modules admin screen out of every release — so the one + # screen that installs a module was missing from the package, and the + # UI answered "Page unavailable" with no way to tell why. rsync -a backend/ "${DIST}/backend/" \ - --include='modules/' \ - --include='modules/README.md' \ - --include='modules/ExampleHooks/***' \ - --include='modules/ExampleShowcase/***' \ - --include='modules/OrderPinger/***' \ - --exclude='modules/*' \ + --include='/modules/' \ + --include='/modules/README.md' \ + --include='/modules/ExampleHooks/***' \ + --include='/modules/ExampleShowcase/***' \ + --include='/modules/OrderPinger/***' \ + --exclude='/modules/*' \ --exclude='.env' \ --exclude='node_modules' \ --exclude='tests' \ @@ -172,6 +180,28 @@ jobs: mkdir -p "${DIST}/backend/storage/framework/views" touch "${DIST}/backend/storage/logs/.gitkeep" + # Every tracked file under backend/resources/ has to be in the package. + # + # This is the check that was missing when an unanchored rsync exclude + # quietly dropped resources/js/Pages/admin/modules — three files out of + # 228, in a package nobody counts by hand. The Dockerfile check below + # did not catch it: the directory it copies was there, only emptied. + # + # resources/ is entirely source that ships; anything excluded from it + # is a mistake by definition, which is what makes this safe to assert. + missing_resources=0 + while read -r tracked; do + rel="${tracked#backend/}" + if [ ! -e "${DIST}/backend/${rel}" ]; then + echo "::error::${tracked} is tracked but missing from the release package" + missing_resources=$((missing_resources + 1)) + fi + done < <(git ls-files backend/resources) + if [ "$missing_resources" -ne 0 ]; then + echo "::error::${missing_resources} tracked file(s) under backend/resources did not make it into the package" + exit 1 + fi + # The package promises `docker compose up -d --build` works from the # unpacked folder, so every path the Dockerfile copies has to be in it. # Nothing compared these two files before, which is how v0.22.0 shipped diff --git a/CHANGELOG.md b/CHANGELOG.md index 756f695af..428a5527c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,242 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.25.0] - 2026-09-29 + +### Added + +- **A module installed after the fact now has a working frontend.** Its React pages were + never in the bundle — the bundle is compiled before the module exists, and the production + image deletes `node_modules` right after building — so every page of an uploaded module + rendered the missing-page card while its routes answered normally. A module now ships its + own compiled JS, which core loads before the first render and reads as a third source in + the page resolver. Precedence is unchanged: core, then a compiled-in module, then a runtime + one. The shared contract lives in `packages/module`, so core's runtime and a module's + externals come from one list rather than two maintained by hand — and a module carries no + React of its own, which would break hooks with two copies on a page. +- **The install scripts now ask about anonymous usage reports.** The web setup wizard has + asked since reporting became opt-in, but a Docker install never reaches that wizard — the + entrypoint creates the admin and marks the application installed — so nobody installing with + `install.sh` or `install.ps1` was ever asked. The prompt defaults to **no**, and an + unattended run is treated as no rather than as consent. The answer is written to + `OPENMES_TELEMETRY` in `.env`, passed to the container, and recorded once by the entrypoint + before the install is marked complete, so it can never overwrite a later choice made in + Settings → System. +- **A module can reach the operator's station screen**: a display region on the workstation + page, and the same rule filter the user and worker forms use, so a module's own key + survives `validated()` when a step is started or completed. Nothing else was needed — + `StepStarted` and `StepCompleted` are already dispatched from the model observer, inside + the service transaction and on every path that moves a step. + +- **A module's `requires_core` is now enforced.** Every manifest has carried it and nothing + read it, so a module built against extension points this core does not have installed + cleanly and then quietly did nothing. Installing or enabling one now says so instead, + naming both versions. A module that declares no requirement is unaffected, and an + unreadable one is refused rather than treated as "any version". +- **A module can add its own fields to the user and worker forms** — shown by core, + validated server-side, stored by the module. Three pieces: `HookRegistry` (complete but + until now unused) carries the field's description to the page, where `ModuleFields` draws + it; the Form Request rule sets pass through a `FilterRegistry` filter, which is what makes + the module's key survive `validated()` — without a declared rule it was dropped between the + browser and the controller, silently; and a new `persist.*` hook tells the module, inside + the controller's transaction, that the record was saved, so a failed write there rolls the + whole save back rather than leaving the field lost. With no module listening none of it + costs anything: the page is sent `{}` and the rule set comes back untouched. + + A module distributed as a ZIP cannot ship working React into a released install — the page + globs are expanded when core is built — so contributing data that core renders is the only + arrangement that works at all. +- Module seams: `MenuRegistry::addOperatorItem()` puts a module's screen on the operator top bar as a tab; `modules//lang/.json` strings are merged into the frontend translations; `ImportRegistry` accepts module importers through the `import.entities` filter; `Tests\Support\ModuleTestCase` migrates a module's tables inside the test transaction. Module-contributed nav entries are tinted with the accent colour and highlight on their own pages (they registered absolute URLs, which the path-based active check never matched). + +### Fixed + +- **A failing module no longer takes the whole application down.** Providers were registered + inside a try/catch, but Laravel calls `boot()` later, outside it — so a module throwing + there took every route with it, including the admin screen needed to disable the module. + Recovery meant deleting its directory by hand. Providers now run behind + `ModuleProviderGuard`, which catches both phases: a module that fails contributes nothing + and is logged, and the rest of the application is unaffected. +- The first scan or save after signing in no longer fails with 419: screens that post with fetch read the CSRF token from the page head, which an Inertia visit did not refresh after login regenerated it. The head now follows the token every page carries. +- Printing a label (work order, finished goods, pallet, carton, unit, IQC), a packing list or a batch report no longer fails with a server error when the order, lot or pallet number contains a slash: download names are made safe. +- A held unit no longer leaves as shipped with its pallet (nor counts in its finished-goods documents), and the scrap-on-failed-test policy leaves units that already shipped alone. +- Tester logs in the one-cycle layout: a step's `lo`/`hi` limits are kept with its measurement, a run that crosses midnight ends the next day instead of before it started, the closing verdict letter decides the run as documented, and a failed step numbered 0 is no longer dropped. +- Putting a carton on a pallet judged the pallet's quality before the carton's units were on it, so a pallet of tested units stayed "pending" and could not be shipped. +- The "Batch created" message on the operator's order page was not translated. +- Putting a carton on a pallet counts each unit once (a unit scanned straight + onto the pallet before its box was placed is no longer counted twice), refuses + another order's pallet and a box whose unit already sits on a different pallet. +- Packing history rows (packed, palletised) and cartons opened at the bench are + attributed to the bench the operator picked for the session, not only to the + account's default workstation. +- A scanned component lot matches its material lot whatever the identifier + normalise setting says (both sides are folded for the comparison). +- The tester event-stream parser skips a `step_start` without `step_id` and a + `run_start` without `station` instead of failing the whole import. +- Blocked serialised units show a red "Blocked" chip on the label station and + in traceability instead of a neutral chip with the raw status key. +- The operator workstation page drops the two dead "Cleaning" / "Failure" + buttons under the line name (disabled stubs; the machine-state panel above + them does that job). Setting a stop state there (stopped, fault, waiting, + cleaning, maintenance) now asks for an optional note, stored with the state. +- The operator header no longer overflows on tablet widths (iPad Mini and + narrower): the links drop to their own row and the bar grows with them. + +- Number fields in the admin forms carry the same bounds the server enforces + (a planned quantity cannot be typed as negative or zero, a price or priority + as negative), so the browser refuses the value instead of a failed round trip. +- Lot tracking of materials and its picking strategy can be switched from + Settings → System → Production; before, the setting existed only in the + database. +- A unit blocked after failed tests is released by a later passing verdict, as + the fail policy always said; before, the only way out of a block was scrap. +- A pallet's quality gate now reads each unit's latest verdict; it used to read + the oldest, so a unit that passed and later failed could still ship. +- Rescanning a unit's label at the packing station no longer counts it on the + packing step again, a unit does not go into a carton or onto a pallet of + another work order, and a unit already on a pallet is not moved to another + one by a scan (which left the first pallet's count wrong). +- Carton numbers come from a database sequence on Postgres, like pallet + numbers, so two benches opening a carton at once no longer collide. +- The traceability search normalises the typed identifier the way scanning + does, so a serial typed with spaces or in lower case is found. +- Compact tester logs without a run id are no longer recorded twice when the + same file is imported again. +- The unit label puts the production date on its own line under the order and + product, where the height allows, instead of cutting the line off under the + QR code on small labels. +- **The release package no longer ships without the Modules admin screen.** The rsync rules + that keep locally installed modules out of a release were unanchored, so they matched any + directory called `modules` — and this repository has a second one, + `resources/js/Pages/admin/modules`. Three files were dropped from every package, which left + the one screen that installs a module answering "Page unavailable" with no way to tell why. + The rules are anchored to `backend/modules`, and the release now fails if any tracked file + under `backend/resources` is missing from the package. +- **The Docker build no longer downloads the RoadRunner binary from GitHub.** `vendor/bin/rr + get-binary` asked api.github.com for the release list on every build, and five services in + `docker-compose.yml` build from this Dockerfile — so one `up --build` made that call several + times over, in parallel. A resolver that answered NODATA under that burst failed the whole + install with an error that pointed at RoadRunner rather than at the network. The binary now + comes from the official image, pinned to the `spiral/roadrunner` release in `composer.lock`. +- **Uninstalling a module now runs the module's own uninstall hook**, while its classes are + still on disk — afterwards there is nothing left to call, so the hook never ran at all. It + is a module's only chance to undo what it did outside its own tables. A hook that fails + leaves the module in place to be retried, and the success message now says plainly that the + module's database tables are kept. +- **The scanner mode setting now does something.** Settings → System has offered a choice + between a keyboard-wedge reader and manual entry since it was merged, and its own + description promised the operator "a visible field" — but the packing station never read + the setting, and no such field existed. Picking `manual` therefore left the station with + no way to enter a code at all. The station now honours the setting: `hid` keeps the + document-level capture, `manual` detaches it and shows the field. + +### Changed + +- Settings → Import no longer sends a live update per imported row. A plant file with its production scenario touches a thousand rows or more, each update a synchronous call to the websocket server after the commit; on a slower host that ran the request past its time limit and answered an empty error 500, although the data had been saved. Open screens pick the new data up when they next load, as after a data import. A failed import now names its reason on the page (the screen is admin-only) instead of a generic message, and a log that cannot be written no longer turns that answer into an empty 500. +- The audit log page no longer trips a PHP deprecation on rows that name no entity (a login, a settings change), and the `deprecations` log channel is declared in `config/logging.php`: under Octane the channel the framework added at runtime never reached the log manager, so every deprecation notice ended in "Log [deprecations] is not defined" through the emergency logger. + +- SN label station per workstation: the workstation form picks what the station offers there - start units on PSN, bind the serial label, issue numbers, components, sub-assemblies - so the first bench shows only "Start unit on PSN" and the labelling bench only the two scans. Nothing ticked keeps everything; supervisors, admins and the whole-line view always see everything. +- The operator's work order view lists the order's serial units - serial number, PSN, status, last event and carton/pallet - next to the step quantities. +- Packing station: on "auto" a unit of another order than the bench's open carton goes into a carton of its own order (the operator's open one, else a new one) instead of being refused; the scan field reads "PSN or serial number", and the EAN cards show only when EAN packing is in use. +- Packing station: rescanning a unit that is already packed says so ("already packed - nothing changed", with its carton, pallet and packing time) instead of repeating the green confirmation; on "auto" such a rescan no longer aims at the bench's own box, so it is neither refused nor leaves an empty carton open. +- Unit hold: a blocked unit no longer gets its serial label at the label station (it read as the next step done); it waits for a passing retest or a supervisor's release. A supervisor's PSN re-binding still goes through. +- Packing station: the carton size is enforced by the server (a full box is refused, "auto" starts the next one) and the station closes a full box whichever carton the unit went into, also without a pallet on the bench; a box with no pallet goes onto the bench's pallet with the unit, and a box already on another pallet is refused; "auto" no longer takes over a box another packer is filling; a refused scan leaves no empty carton behind; closing a box or a pallet twice (a double click, two stations) books its packaging once; a unit already packed is not weighed again; each unit counts on its own batch's packing step; the "Pack into" choice falls back to "auto" when its carton or pallet closes. +- Tester API: a run is booked on the token that sent it (the log's operator name is kept, it no longer picks the account); a run with a blank serial number is refused instead of landing on a unit still waiting for its label; an unknown station stays unknown rather than taking the account's own bench; only a test's pass lifts a test hold, and an older pass arriving late does not. +- Components: a held, scrapped or shipped sub-assembly is not installed; a shipped or scrapped product keeps its parts (no unbinding); two benches scanning the same board at once cannot both install it. +- Label station: the actions pinned to a workstation are enforced by the server too; a supervisor can re-bind the PSN of a blocked unit from the station. +- BOM: per-carton and per-pallet lines are divided by the packing step's size in BOM explosion, net requirements and the requirements endpoint too; editing a line without sending its basis keeps it; BOM validation moved into Form Requests; the web step form validates the weight check like the API. +- Configuration import: BOM lines are matched with their component product type, and lot sequences from older files (no purpose) import as LOT sequences; a scenario file can no longer create administrator accounts. +- Migrations: the lot-sequence uniqueness is partial (a deleted sequence frees its slot) and the new migrations roll back on SQLite and PostgreSQL. +- Traceability console: the serial-units list syncs units on the floor plus those finished in the last 30 days, not the whole table. +- UI: dropdown search no longer raises the keyboard on touch screens and its clear button has a label; the active-orders table's column picker shows column names; pallet actions wrap on phones; failed pallet and label-station actions say so. +- Unit hold: a hold put on by failed tests is now a moment in the unit's history ("Blocked after 3 failed tests", right after the fail that caused it), and so is its release by a passing retest; before, only the status changed and the history showed no reason. +- Demo data: the print-shop and machine-shop demos can be re-seeded before the shift starts (their running orders no longer start in the future), which failed the seeder tests every night. +- Traceability: a unit's process-history rows show test parameters under the caption and wrap long titles, so the result chip and the date are no longer cut off. +- Test attempts can be counted per test: a new setting (Settings → Production → Unit serialisation) counts only the failed verdicts at the same test station since its last pass, so a retest loop per tester holds the unit after N attempts. The default keeps counting every failed verdict of the unit. +- Sub-assemblies registered by their own serial where they are made: the SN label station gets a Sub-assemblies mode listing the serial-tracked materials the order's routing produces; scanning one into a product later links the product to that unit and its history. Benches that make such parts get the label-station tab automatically. Production scenarios can replay it (`subassembly` event). +- Finished goods by pallet (setting, off by default): a closed pallet is received into the finished-goods warehouse and a shipped one issued from it, as drafts or posted at once; the work-order completion receipt is skipped then. Stock documents record their pallet. +- IQC label: a Material lot (IQC) label template type with the lot, material, quantity, inspection status and supplier lot, printed from the material lot page (PDF/ZPL); a default template is seeded and an install without one prints the built-in layout. +- Production hold on material shortage: Settings → Production → Material availability gains a third choice, holding a batch until stock covers its whole work order (the planner's shortage check, enforced at the batch start, with the operator told what is missing); the choice also blocks steps like the existing option. The card now sets both switches from one choice. +- Settings → Import carries warehouses too, so a plant file can set up its finished-goods warehouse and link lines to it. +- Traceability console: browse work orders, material lots and pallets in searchable, filterable tables and open a trace from the row instead of pasting a number; work orders are now traceable by their own order number. +- Traceability results redesigned on the shared components: identifier and status chip header, routing drawn as the step list with per-step duration and consumed lots, ingredient lots as a table, translated statuses throughout; batch status and step durations added to the trace payload. +- Stepper gains a `body` slot for block content under a step's caption. +- Serialised units: identifier formats, normalisation, process-serial requirement and uniqueness, and the failed-test policy (block after N attempts, or scrap) are now system settings (Settings → System → Unit serialisation) instead of fixed behaviour. Binding a label records who, where and when in the unit's history; a re-bind needs a supervisor and a reason; packing refuses scrapped or blocked units and records the packing event. +- Components: scan sub-assemblies, lots or bare vendor serials into a serialised unit at the SN Label Station (Components mode) or via `POST /api/v1/serial-units/{unit}/components`; the traceability console shows a unit's installed and removed components and traces a component identifier to every unit it went into. +- Test-station log import (`traceability:import-jsonl`) understands more than one file layout, detected by structure and listed in `config/traceability.php`; runs are stamped with the tester's own time, applied in time order, and de-duplicated by run id. +- Lot pattern tokens: `[doy]` (1-based, 3-digit day of year) and `[date:o1]` (last digit of the ISO week-numbering year); a plain `[date]` no longer errors. +- Default label templates include a Serial Unit label; its barcode and QR carry the serial number itself. +- Settings → Import accepts a configuration file prepared by hand for a new plant, not only an export: tables are applied in dependency order whatever order the file uses, a row's `id` is the file's own reference (foreign keys such as a workstation's line, a step's workstation or a BOM item's step and material are remapped to the rows the import created), rows are upserted by their natural key so a re-import updates instead of duplicating, and label templates, lot sequences, product–line links and system settings not yet stored (from the settings form's own keys) are imported too. Each setting from a file is checked against the settings form's own rule (a serial pattern must compile, attempt counts stay in range) and invalid ones are skipped; the plant timezone applies at once; the production flow mode is never taken from a file. A reference into a table the file carries but that did not land is dropped instead of borrowing whatever row has that id here, other `*_id` columns (users and the like) are never copied, and a live row wins over a deleted one with the same key. The export writes the same table set and leaves soft-deleted rows out. +- Settings → Import replays an optional production scenario (`scenario` section): operator accounts, material lots and a timed list of shop-floor events — work orders, schedule placements, batches, step starts/logs/completions, units started on a PSN, SN labels, components, tester runs, holds, scrap, packing, cartons and pallets — each through the same service the operator screens use, with the clock set to the event's time. One transaction with the configuration: a failing event saves nothing and names the event. Format in the admin guide. +- Operators see only the tabs their bench needs. The tabs follow the steps the + routing sends to the selected workstation: a packing step gives "Packing", a + production step gives the queue and the workstation table, plus "SN labels" + when the product has a serial or process-serial sequence. Picking a packing + bench opens straight on the packing station. An admin can pin a bench's tabs + by hand (Lines, Workstations, "Operator screens"; the default is "Automatic"). Supervisors, admins and the + whole-line view keep every tab; a hidden screen still opens from a link. The + workstation create/update validation moved to Form Requests. +- `Dropdown` (`@openmes/ui`, web and native) gets a search box: on any list + longer than 4 rows (or with `searchable`), the menu opens with a focused + search field that narrows the rows as you type, case- and accent-insensitive; + ↑/↓/Enter pick from the matches, Escape closes. The placeholder and the + "no matches" note come from the app root through the new `UILabelsProvider`, + so every dropdown (DataTable's column filters included) is translated without + per-call props. +- The operator's line and bench travel in the address (`?line=…&workstation=…`) + on every operator screen (including a work order's detail page) and in the + top links, so a bookmarked or shared link to the queue, a station or an order + opens on that line and bench; a line the operator is not assigned to is ignored. +- The operator workstation table is the app's data table too: search, filters + on the configurable columns, the column picker (still remembered per line), + sorting; the production block (to produce / produced / remaining, a column per + shift, the actions) is fixed. `DataTable` gained `rowDetail` (a fold-out row, + used for the routing graph) and `columnVisibility` / + `onColumnVisibilityChange` so a page can remember the reader's columns. +- The operator queue's "Active work orders" list is the app's data table + (search, per-column filters, column picker, paging) instead of a hand-built + table; double-click opens the order, the board-status badge still cycles on + tap. The label station stacks its scan fields (process serial over serial + label; unit over component) with the action button below them. +- The operator's work order page shows the routing graph above the batch + steps, each node carrying the step's live status. Clicking a step shows only + its station's steps and jumps to it; clicking it again shows every step. The + graph component moved to `components/flow/RoutingGraph.jsx` (shared with the + process template editor) and operator screens now have a toast provider. +- The operator's workstation table folds a routing graph out under every order + that tracks steps ("Show routing" for all of them, or the arrow button on one + row); clicking a step opens the order on that step (`?step=`), which the + order page also writes to the address as the step filter changes. +- The order chosen on the unit label station and for a new pallet on the + packing station is kept in the address (`?work_order=`), so a reload or a + shared link comes back to it. +- Packing station, fewer clicks per box: with the bench on "auto" and no + carton open, the first scan of an order that packs into cartons opens one + (the operator's own open carton is reused), so "New carton" is no longer a + step between boxes and no unit lands loose on the pallet by mistake. A packing + step can turn off the per-unit label ("Print a label for each unit when it + is scanned", on by default for a box label read off the unit's serial); off, + the label dialog shows only for a unit that has no label yet (`label_needed` + in the scan response). +- The packing station has an operator address, `/operator/packaging`, beside + the other operator screens; an operator opening `/packaging/station` is + redirected there with the same query. Supervisors and admins keep + `/packaging/station` in the sidebar. The operator view drops the + shift / logged-in line under the title (the header already shows both). +- The colour stripe at the left edge of rows and cards (operator queue and + workstation view, machine monitor tiles, module cards) is gone; the status + stays in the row tint, the badge or the switch. +- Label templates show "Default for its type" in words with a yes/no filter + instead of a bare star. +- User administration validates through Form Requests. The rule set lived inline in the + controller in two near-identical copies, one per action, which the project's own conventions + forbid and which is how the two copies drifted apart. Behaviour is unchanged with one + deliberate exception: editing somebody whose crew or wage group has since been deactivated + now saves, where before their own stored value was refused because the pickers no longer + offered it. The same fix the worker screen already carries. +- The reader capture moved out of the packing station into `useScanBuffer`, unchanged in + behaviour and now covered by unit tests, so the pages that need it next do not each grow + their own copy. + ## [0.24.3] - 2026-09-25 ### Changed @@ -191,6 +427,93 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- Units can start on their process serial alone. The first station starts the + unit ("Start unit on PSN": the PSN scanned off a label, or the next one from + the product's sequence, with its PSN label) or issues a PSN-only batch; + components bind to the unit by its PSN; the product label later puts the + serial number onto that same unit, and a tester run carrying both numbers + does the same. Units without an SN yet are shown and labelled by their PSN. +- Holding a non-conforming unit with an error code (the plant's scrap reasons): + "Block" on the label station (unit list and component view) and + `POST /api/v1/serial-units/{id}/block`. A held unit is not packed and fails its + pallet's quality gate; a passing retest no longer lifts a hold put on by hand + (it still lifts the tests' own hold). Supervisors and admins release it with a + reason ("Release", `/unblock`). +- Weight check at packing: a packing step can set an expected weight and a + tolerance (grams). The station then asks for the unit's weight after the scan + (a keyboard-wedge scale types it), refuses a reading outside the tolerance + (kept on the unit's history) and records the weight on the "packed" event. + A held or scrapped unit is refused before it is weighed. +- Traceability shows timings. A batch's steps carry start → end, the run time + and how long the batch waited since the previous step, plus a lead time for + the batch. A serialised unit's history shows how long after the previous + record each event came, a test's own run time (from the tester's start/end + stamps) and the unit's lead time from its first record to shipping (or so + far). Presentation of stamps already stored; nothing new is recorded. + +- Packaging comes from the BOM. A BOM line on a packing step can be counted + per carton or per pallet ("1 carton per carton"); the packing step's own + capacities convert it to a per-unit figure when the order freezes, so + planning, reservation and backflush stay correct. The packing station lists + the packaging each packing step takes with what the batch needs, what is + reserved (and from which lots) and what has been used; with lot tracking on, + the operator picks the packaging lots before the first unit goes into the + box - a scan before that is refused and opens the pick. Closing a carton + books one of every per-carton line, a closing pallet one of every + per-pallet line. +- Label templates have a preview on sample data: an action on the list, a + button in the drawer, and a live preview under the form that follows the + fields. Each template type offers only the fields its layout prints, the + unit and carton layouts respect what is ticked, the carton label is always + one page (units beyond the space are counted as "+N more"), and a barcode + format that cannot encode the value (EAN-13 for an order number) falls back + to CODE 128 instead of failing the print. +- A material lot's own trace lists the serialised units it was scanned into + as a component (with the ones it was taken out of), so a recall on a lot + that never went through batch consumption still finds every unit. +- Packing list per pallet: a PDF with every carton and the serial numbers in + it (and units packed loose), totals, quality and signature lines - from the + pallets list, the packing station's active pallet and the pallet's trace. +- Numbers issued in batches ahead of the line: "Issue batch…" at the label + station registers N units on the order with their serial and process + serial and prints all their labels in one file; the pre-printed label is + then applied by scanning it. +- A component can be taken out of a unit at the label station (a repair, a + wrong scan), with a reason kept on the unit's history; the lot or + sub-assembly is free to go into another unit. +- Testers post their results to the API: `POST /api/v1/test-runs` takes the + station's own JSONL log (the lines it writes to file), OpenMES's native JSON + shape, or the decoded records, and records the run the way the file import + does - unit registered on first sight, fail policy applied, pallet quality + re-read, duplicate runs ignored. `GET /api/v1/serial-units` looks units up by + exact serial number or process serial. Documented in `docs/API_DOCUMENTATION.md`. +- An end-to-end walkthrough of the serialised-unit flow + (`tests/e2e/traceability-flow/`): a Playwright spec that drives configuration, + label station, component binding, packing and shipping through the real UI + with a screenshot of every step, and an `index.html` that describes the + process step by step around those screenshots. +- Pallets are reachable from the menu (Warehouses → Pallets); the page existed + but no menu entry led to it. The list's label column opens the same label + preview (print, ZPL, template choice) the stations use instead of PDF and ZPL + links that left the page, and no longer shows a pointless filter box. +- Label templates are reachable from the menu (Production → Label Templates) and + are created and edited in a drawer on the list, like the other admin lists; + the old create/edit pages redirect into it. The type, size, barcode format and + field names in the form and the list are translated instead of shown in + English, and a new template starts with the usual fields for its type. +- Packing is a routing step. A process-template step has a kind (production or + packing); a packing step carries what it packs into (unit, carton or pallet), + units per carton, cartons per pallet and an optional label template. The kind + and its config are frozen into the work order's snapshot and copied onto the + batch steps, so the packing station lists each order's packing step with its + progress, starts it on the first scanned unit and counts every packed unit on + it. A carton closes itself (and prints its list) when it reaches the + configured size, and a pallet closes itself (and prints its label) when it + holds the configured number of closed cartons, or of units when the step packs + straight onto pallets; a closed pallet takes nothing more. The label template + a packing step names is the one the station prints for that level (unit, carton + or pallet). Each scan can be aimed at a target: the operator's carton or + pallet, another open carton or pallet, or none. - Plan work-order availability with a plant-local start date and time in creation, editing and scheduling. Preserve delivery deadlines when moving orders; keep undated orders available and guard early production and postponement of started diff --git a/CLAUDE.md b/CLAUDE.md index 90ebbd753..13912b5aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d # dev ove ## Hard rules -1. **English-first** — all code, Blade/JSX text, validation messages, seeders, comments are English. Other languages exist only as translations in `backend/lang/*.json`. +1. **English-first** — all code, Blade/JSX text, validation messages, seeders, comments are English. Other languages exist only as translations in `backend/lang/*.json`. This covers **everything published to this repository**, not only code: pull request titles *and* descriptions, issue comments, commit messages, README and docs. This repo is public and read by people who do not share your first language. 2. **i18n parity** — `lang/en.json` and `lang/pl.json` must contain the same key set. Adding a UI string means adding the key to **both** files (English value = key itself in `en.json`). These are strict JSON: **no trailing comma** on the last entry, or `json_decode` fails and the whole UI silently falls back to English. New keys are appended, so the files conflict on almost every merge — resolve with `.claude/skills/i18n-lang-files/`, never by hand-editing the conflict markers (a text-level union resurrects deliberately deleted keys and duplicates others). 3. **Form Requests for validation** — never validate inline in controllers. Frontend validation is UX only; the backend rule set is authoritative. 4. **Never rename migration filenames after merge** — the filename is the migration's identity; renaming breaks every existing database on upgrade (duplicate-table crash in the entrypoint migrate). diff --git a/HOOKS.md b/HOOKS.md index 53d9b27f1..e0cd83e34 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -29,6 +29,7 @@ Three reference modules ship in the repo (all disabled by default): - [Scheduling hook — `WorkOrderScheduled`](#scheduling-hook--workorderscheduled) - [2. Menu hooks](#2-menu-hooks) - [3. Dashboard widget hooks](#3-dashboard-widget-hooks) +- [4. Page display hooks and filters](#4-page-display-hooks-and-filters) - [Enabling a module](#enabling-a-module) - [Best practices](#best-practices) - [Complete hook reference](#complete-hook-reference) @@ -227,6 +228,63 @@ $menu->addGroupItem('yourmod', 'Overview', url('/modules/your-module'), order: 1 Resolve URLs with `url()` (not `route()`) so registration never depends on route load order or a cached route table. +Every entry a module contributes is tinted with the accent colour in the sidebar, +and lights up as active on its own pages (the registered URL is matched by path). + +### Operator panel tabs + +A module that ships an operator screen registers it as a tab on the operator +top bar, next to Queue / Workstation: + +```php +// label, url, order (built-in tabs are 10 and 20), optional path prefix that +// keeps the tab highlighted (defaults to the link's own path). +$menu->addOperatorItem('Team', url('/operator/team'), order: 30); +``` + +Operator tabs are Inertia links — the page behind them is a React page under the +module's `resources/js/Pages/`. They arrive in the browser as `moduleNav.operator` +and render tinted like sidebar entries. + +### Translations + +A module's own strings live in `modules//lang/.json` (the same +source-string-keyed shape as core's `lang/*.json`). The frontend merges them +under the core file at bootstrap — a module can add strings, never redefine a +core one — and the provider loads the same directory for PHP: + +```php +$this->loadJsonTranslationsFrom(__DIR__.'/../lang'); +``` + +### Import entities + +`ImportRegistry` (Admin → Import) accepts importers from modules through the +`import.entities` filter: + +```php +app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]); +``` + +`MyImporter` extends `App\Import\AbstractEntityImporter`; the screen, the queued +job, the sample file and the routes pick it up. + +### Testing a module + +`Tests\Support\ModuleTestCase` registers the module's provider on each test's +fresh application and migrates the module's directory inside the test +transaction (the suite's `migrate:fresh` runs before any provider boots, so a +module's tables are never part of that schema): + +```php +class MyModuleTest extends \Tests\Support\ModuleTestCase +{ + protected string $module = 'MyModule'; + protected string $provider = \Modules\MyModule\Providers\MyModuleServiceProvider::class; + protected string $probeTable = 'my_module_things'; +} +``` + ## 3. Dashboard widget hooks `App\Services\WidgetRegistry` lets a module add cards to the admin dashboard. @@ -251,6 +309,81 @@ KPIs; `main` renders as a full-width card at the bottom of the dashboard column. --- +## 4. Page display hooks and filters + +`App\Extension\HookRegistry` carries contributions to named points on a page; the +controller that owns the page resolves its points with `renderMany()` and hands them +over as the `hooks` prop, and the page renders `` +from `resources/js/lib/hooks.jsx`. `App\Extension\FilterRegistry` lets a module +change a value core computed. With no module listening, a page is sent `{}` and every +filter returns its default — a community install renders exactly what it did before. + +A point that **replaces** a core control (marked below) checks `hasHook()` first and +skips its own control when a module contributed. + +```php +app(HookRegistry::class)->listen('display.operator.work_order.sections', fn (array $ctx) => [ + 'title' => 'Recipe vs weighed', + 'body' => Recipe::summary($ctx['workOrderId']), +]); + +app(FilterRegistry::class)->addFilter('operator.can_logout', fn (bool $can, array $ctx) => $can && ! Panel::isShared($ctx['user'])); +``` + +> A `component` (`ext:/`, resolved under +> `modules//resources/js/Components/`) only exists in a build that contained +> the module. A module installed from a ZIP into a released install must contribute +> the plain card fields instead. + +### Display hooks + +``` +display.operator.workstation.shift_cell context: entry, workOrder, shift, canCorrect (replaces the whole shift cell) +display.operator.work_order.sections context: workOrder (rendered after the BOM section) +display.operator.quantity_field props: value, onChange, variant (replaces the operator's number input, page-wide) +display.operator.layout (no context) (rendered at the top of every operator screen) +display.settings.system.tabs contribution: slot, title, component (one tab each on Settings → System) +``` + +The PHP-side context a listener receives: + +| Hook | Resolved by | PHP context | +|---|---|---| +| `display.operator.workstation.shift_cell` | `Operator\WorkstationController::index` | `line`, `workstation`, `lineId`, `workstationId` | +| `display.operator.work_order.sections` | `Operator\WorkOrderController::show` | `workOrderId`, `workstationId` | +| `display.operator.quantity_field` | `WorkstationController::index`, `WorkOrderController::show` + `::queue` | same as the page's other points | +| `display.operator.layout` | `HandleInertiaRequests` (shared `operatorHooks`) | `user` | +| `display.settings.system.tabs` | `SettingsController::showSystemSettings` | — | + +Notes: + +- **`quantity_field`** — `QuantityField.jsx` renders the first contribution's + component with `value`, `onChange(value)` (the value, not an event), `variant` + (`big` for a modal's single field, `compact` inline) and every other input prop + (`min`, `max`, `step`, `aria-label`, …) passed through. +- **`settings.system.tabs`** — each contribution becomes a tab with value + `ext-` (linkable as `?tab=ext-`) labelled `title`. Its component + renders outside the core settings form, so core's Save button is not shown + there: the module saves through its own route. +- **`operator.layout`** is resolved on every Inertia response, like any shared + prop — a listener should return `null` cheaply where it has nothing to show. + +### Filters + +``` +operator.tabs list of {key, label, url, prefixes} the operator top bar's core tabs +operator.module_tabs list of {label, url, order, prefix} module tabs, per request +operator.can_logout bool (default true) whether the operator chrome shows its logout button +``` + +`operator.tabs` and `operator.can_logout` receive `['user' => $user]` as context. +`operator.module_tabs` runs over what `addOperatorItem()` registered, on every +request, so a module can show a tab only where it applies without re-registering. +`operator.can_logout` hides the button only — a module that forbids logout must +still refuse the `POST /logout` itself. + +--- + ## Enabling a module 1. **Admin → Modules → your module → Enable** (or add its name to the @@ -311,6 +444,14 @@ class SyncToErp implements ShouldQueue **Widgets** (`App\Services\WidgetRegistry`): `register` — zones `kpi`, `main`, `sidebar`. +**Display hooks** (`App\Extension\HookRegistry`): `display.operator.workstation.actor`, +`display.operator.workstation.shift_cell`, `display.operator.work_order.sections`, +`display.operator.quantity_field`, `display.operator.layout`, +`display.settings.system.tabs`. + +**Filters** (`App\Extension\FilterRegistry`): `operator.tabs`, +`operator.module_tabs`, `operator.can_logout`, `import.entities`. + --- ## Support diff --git a/README.md b/README.md index aced7fa8c..3296d0ccd 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Dedicated station for scanning finished products with a barcode reader (EAN/QR) **How it works:** -1. Operator opens `/packaging/station` on a dedicated workstation or tablet +1. Operator opens `/operator/packaging` on a dedicated workstation or tablet (supervisors and admins reach the same screen at `/packaging/station`) 2. Scans an EAN barcode with a USB/Bluetooth reader (or types it manually) 3. The system looks up which work order the EAN belongs to and increments its `packed_qty` counter 4. Live stats update every 3 seconds: packed today, plan, backlog, realisation % @@ -187,7 +187,8 @@ Dedicated station for scanning finished products with a barcode reader (EAN/QR) | URL | Access | Description | |---|---|---| -| `/packaging/station` | Operator, Supervisor, Admin | Scanning station | +| `/operator/packaging` | Operator | Scanning station (operator shell) | +| `/packaging/station` | Supervisor, Admin | Scanning station (admin shell; operators are redirected to `/operator/packaging`) | | `/packaging/` | Supervisor, Admin | Admin overview | | `/packaging/eans` | Supervisor, Admin | EAN code management | diff --git a/backend/.gitignore b/backend/.gitignore index bf8e8e9bc..b973d20ca 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -15,11 +15,16 @@ /auth.json /node_modules /public/build +# Compiled frontends of installed modules, copied here by ModuleManager when a +# module is enabled. Belongs to the installation, not to the repository. +/public/modules /public/hot /public/storage /storage/*.key /storage/pail /vendor +# Generated on every Vite build by openmesRuntime() from packages/module/contract.js. +/resources/js/runtime.generated.js Homestead.json Homestead.yaml Thumbs.db diff --git a/backend/Dockerfile b/backend/Dockerfile index b84c68b1f..1e6a10142 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -50,10 +50,20 @@ COPY packages/ /var/www/packages/ # Install PHP dependencies RUN composer install --no-dev --optimize-autoloader --no-interaction -# Fetch the RoadRunner binary for Laravel Octane (concurrent app server). -# Placed in the project root — where Octane's RoadRunner server discovers it. -RUN ./vendor/bin/rr get-binary --location /var/www/html \ - && chmod +x /var/www/html/rr +# The RoadRunner binary for Laravel Octane (concurrent app server), taken from +# the official image rather than downloaded during the build. +# +# `vendor/bin/rr get-binary` asks api.github.com for the release list, which +# makes every build depend on GitHub being reachable and on DNS answering at +# that moment. Five services in docker-compose.yml build from this Dockerfile, +# so a single `up --build` made that call several times over in parallel — enough +# for a home router's resolver to return NODATA and fail the install outright. +# +# The version is pinned to match `spiral/roadrunner` in composer.lock. Bump both +# together: Octane loads the PHP side from vendor and executes this binary, and +# they are meant to be the same release. +COPY --from=ghcr.io/roadrunner-server/roadrunner:2025.1.14 /usr/bin/rr /var/www/html/rr +RUN chmod +x /var/www/html/rr # Install Node dependencies and build assets RUN npm ci && npm run build diff --git a/backend/app/Console/Commands/ImportLineBundle.php b/backend/app/Console/Commands/ImportLineBundle.php new file mode 100644 index 000000000..7258973b4 --- /dev/null +++ b/backend/app/Console/Commands/ImportLineBundle.php @@ -0,0 +1,176 @@ + [model, upsert key fields, ref field => source bundle key]. + * Order matters: referenced entities are processed before their referrers. + */ + private const ENTITIES = [ + 'lines' => [Line::class, ['code'], []], + 'workstations' => [Workstation::class, ['code'], ['line_id' => 'lines']], + 'product_types' => [ProductType::class, ['code'], []], + 'process_templates' => [ProcessTemplate::class, ['name'], ['product_type_id' => 'product_types']], + 'template_steps' => [TemplateStep::class, ['process_template_id', 'step_number'], ['process_template_id' => 'process_templates', 'workstation_id' => 'workstations']], + 'material_types' => [MaterialType::class, ['code'], []], + 'materials' => [Material::class, ['code'], ['material_type_id' => 'material_types']], + 'bom_items' => [BomItem::class, ['process_template_id', 'template_step_id', 'material_id'], ['process_template_id' => 'process_templates', 'template_step_id' => 'template_steps', 'material_id' => 'materials']], + 'issue_types' => [IssueType::class, ['code'], []], + 'lot_sequences' => [LotSequence::class, ['name'], ['product_type_id' => 'product_types']], + 'label_templates' => [LabelTemplate::class, ['name'], []], + 'work_orders' => [WorkOrder::class, ['order_no'], ['line_id' => 'lines', 'product_type_id' => 'product_types']], + ]; + + private const SKIPPED_KEYS = [ + 'sites' => 'optional module (table/model not installed)', + 'areas' => 'optional module (table/model not installed)', + ]; + + public function handle(WorkOrderService $workOrders): int + { + $totalCreated = 0; + $totalUpdated = 0; + $errors = 0; + + foreach ((array) $this->argument('paths') as $path) { + if (!is_file($path)) { + $this->error("File not found: {$path}"); + $errors++; + continue; + } + + $file = json_decode((string) file_get_contents($path), true); + if (!is_array($file)) { + $this->error("Invalid JSON: {$path}"); + $errors++; + continue; + } + + $this->info("Importing {$path}"); + $posMap = []; + + foreach (self::ENTITIES as $key => [$modelClass, $keyFields, $refs]) { + if (!isset($file[$key]) || !is_array($file[$key]) || $file[$key] === []) { + continue; + } + [$created, $updated, $failed] = $this->upsertEntity($key, $modelClass, $keyFields, $refs, $file[$key], $file, $posMap, $workOrders); + $totalCreated += $created; + $totalUpdated += $updated; + $errors += $failed; + $this->line(sprintf(' %-18s %d created, %d updated, %d failed', $key, $created, $updated, $failed)); + } + + foreach (self::SKIPPED_KEYS as $key => $reason) { + if (isset($file[$key])) { + $this->warn(" {$key} skipped: {$reason}"); + } + } + } + + $this->info("Done: {$totalCreated} created, {$totalUpdated} updated, {$errors} errors."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return array{0:int,1:int,2:int} created, updated, failed + */ + private function upsertEntity(string $key, string $modelClass, array $keyFields, array $refs, array $rows, array $file, array &$posMap, WorkOrderService $workOrders): array + { + $probe = new $modelClass(); + if (!Schema::hasTable($probe->getTable())) { + $this->warn(" {$key} skipped: table {$probe->getTable()} does not exist"); + + return [0, 0, 0]; + } + + $fillable = array_flip($probe->getFillable()); + $posMap[$key] = []; + $created = 0; + $updated = 0; + $failed = 0; + + foreach ($rows as $i => $row) { + $pos = $i + 1; + try { + foreach ($refs as $field => $srcKey) { + if (array_key_exists($field, $row)) { + $row[$field] = $this->resolveRef($row[$field], $srcKey, $file, $posMap, $key, $pos, $field); + } + } + + if ($key === 'work_orders' + && (($row['process_snapshot'] ?? null) === 'auto' || !array_key_exists('process_snapshot', $row))) { + $row['process_snapshot'] = $workOrders->buildProcessSnapshot($row['product_type_id'] ?? null) ?? ['steps' => []]; + } + + $attrs = array_intersect_key($row, $fillable); + $keyValues = array_intersect_key($attrs, array_flip($keyFields)); + + $model = $modelClass::query()->where($keyValues)->first(); + if ($model !== null) { + $model->update(array_diff_key($attrs, $keyValues)); + $updated++; + } else { + $model = $modelClass::create($attrs); + $created++; + } + $posMap[$key][$pos] = $model; + } catch (Throwable $e) { + $failed++; + $label = $row['code'] ?? $row['name'] ?? $row['order_no'] ?? ''; + $this->error(" {$key}[{$pos}] {$label}: {$e->getMessage()}"); + } + } + + return [$created, $updated, $failed]; + } + + /** + * Resolve a bundle reference (1-based position in the referenced array of + * this file) to the imported model's id. + */ + private function resolveRef(mixed $value, string $srcKey, array $file, array $posMap, string $ownerKey, int $pos, string $field): ?int + { + $srcPos = (int) $value; + $src = $file[$srcKey] ?? null; + if (!is_array($src) || !isset($src[$srcPos - 1])) { + $this->warn(" {$ownerKey}[{$pos}].{$field}: position {$srcPos} of {$srcKey} is not defined in this file, leaving null"); + + return null; + } + + return $posMap[$srcKey][$srcPos]->id ?? null; + } +} diff --git a/backend/app/Console/Commands/ImportTestStationJsonl.php b/backend/app/Console/Commands/ImportTestStationJsonl.php new file mode 100644 index 000000000..7364838df --- /dev/null +++ b/backend/app/Console/Commands/ImportTestStationJsonl.php @@ -0,0 +1,151 @@ +collectFiles(); + if ($files === []) { + $this->error('No .jsonl files found for the given paths.'); + + return self::FAILURE; + } + + $options = array_filter([ + 'work_order' => $this->resolveWorkOrder(), + 'workstation' => $this->resolveWorkstation(), + 'operator' => $this->resolveOperator(), + ]); + + // Parse everything first so runs can be applied in time order. + $runs = []; + $errors = 0; + foreach ($files as $file) { + try { + $run = $parser->parseFile($file); + if ($run === null) { + $this->warn("[SKIP] {$file}: no complete run in file"); + + continue; + } + $runs[] = [$file, $run]; + } catch (Throwable $e) { + $this->error("[ERROR] {$file}: {$e->getMessage()}"); + $errors++; + } + } + usort($runs, fn ($a, $b) => ($a[1]->processedAt()?->getTimestamp() ?? 0) <=> ($b[1]->processedAt()?->getTimestamp() ?? 0)); + + $imported = 0; + $skipped = 0; + foreach ($runs as [$file, $run]) { + try { + $result = $importer->import($run, [...$options, 'source' => basename($file)]); + if ($result['status'] === TestLogImporter::DUPLICATE) { + $this->line("[SKIP] {$file}: run {$run->runId} already imported"); + $skipped++; + + continue; + } + $this->line(sprintf('[OK] %s: unit %s (%s) verdict %s%s -> %s', + $file, $result['unit']->serial_no, $run->station ?? 'n/a', strtoupper($run->verdict), + $run->verdict === TestRun::FAIL && $run->failedSteps ? ', failed steps: '.implode(', ', $run->failedSteps) : '', + $result['unit']->status, + )); + $imported++; + } catch (Throwable $e) { + $this->error("[ERROR] {$file}: {$e->getMessage()}"); + $errors++; + } + } + + $this->info("Done: {$imported} imported, {$skipped} skipped, {$errors} errors."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** @return string[] */ + private function collectFiles(): array + { + $files = []; + foreach ((array) $this->argument('paths') as $path) { + if (is_dir($path)) { + $found = glob(rtrim($path, '/').'/*.jsonl') ?: []; + sort($found); + $files = array_merge($files, $found); + } elseif (is_file($path)) { + $files[] = $path; + } else { + $this->warn("Path not found: {$path}"); + } + } + + return array_values(array_unique($files)); + } + + private function resolveWorkOrder(): ?WorkOrder + { + $value = $this->option('work-order'); + if ($value === null || $value === '') { + return null; + } + $workOrder = ctype_digit((string) $value) ? WorkOrder::find($value) : WorkOrder::where('order_no', $value)->first(); + if ($workOrder === null) { + $this->warn("Work order not found: {$value} (continuing without work order)"); + } + + return $workOrder; + } + + private function resolveWorkstation(): ?Workstation + { + $value = $this->option('workstation'); + if ($value === null || $value === '') { + return null; + } + $workstation = Workstation::where('code', $value)->first(); + if ($workstation === null) { + $this->warn("Workstation not found: {$value} (using the station named in each log)"); + } + + return $workstation; + } + + private function resolveOperator(): ?User + { + $value = $this->option('operator'); + if ($value === null || $value === '') { + return null; + } + $user = User::where('email', $value)->orWhere('name', $value)->orWhere('username', $value)->first(); + if ($user === null) { + $this->warn("Operator not found: {$value} (using the operator named in each log)"); + } + + return $user; + } +} diff --git a/backend/app/Extension/HookRegistry.php b/backend/app/Extension/HookRegistry.php index f8cbe3fa5..ba15b89b5 100644 --- a/backend/app/Extension/HookRegistry.php +++ b/backend/app/Extension/HookRegistry.php @@ -2,6 +2,8 @@ namespace App\Extension; +use Illuminate\Support\Facades\Log; + /** * Display hooks — named points on a page where a module may contribute something. * @@ -15,6 +17,14 @@ * outlives the request, and a registry that accumulated across requests would * render a module's contribution twice on the second hit. * + * ⚠️ `component` works only for a module that was present when the core frontend + * was built. The page glob below is expanded by Vite at build time, and the + * release ZIP ships a prebuilt frontend — so a module INSTALLED FROM A ZIP can + * never deliver a working component, and naming one produces an empty region and + * a console warning. Such a module must contribute the data fields instead and + * let core render them. This is the same trap that produced a blank + * /admin/plant-reports; do not walk into it again. + * * Naming a hook point: `display..`, e.g. * display.admin.lines.form.fields * display.admin.lines.table.columns @@ -31,14 +41,43 @@ * * Deliberately NOT shared from HandleInertiaRequests: resolving every hook on * every request would run a module's queries on pages that never use them. + * + * The same registry also carries `persist.` points, resolved with + * dispatch() rather than render(): those are called for their effect, inside the + * controller's transaction, so a module can store what it contributed. */ class HookRegistry { /** @var array}>> */ private array $handlers = []; - /** Fields a contribution may carry; anything else is dropped. */ - private const ALLOWED = ['component', 'props', 'slot', 'title', 'metric', 'body', 'href', 'external', 'tone']; + /** + * Fields a contribution may carry; anything else is dropped. + * + * Two groups. The card fields (title/metric/body/href/external/tone) are what + * WidgetRegistry has always carried. The field fields (name/type/label/…) + * describe one form input, which is what a module needs to add a field to a + * core form — see ModuleFields.jsx for what renders them. + * + * `required` is cosmetic, an asterisk and nothing more: whether the value is + * actually demanded is decided by the module's validation rule, contributed + * separately through FilterRegistry. A contribution cannot make the backend + * demand anything. + */ + private const ALLOWED = [ + 'component', 'props', 'slot', + 'title', 'metric', 'body', 'href', 'external', 'tone', + 'name', 'type', 'label', 'value', 'options', 'placeholder', 'help', 'required', + ]; + + /** + * Input types a contributed field may ask for. + * + * Kept short on purpose. Every entry is a control core already renders, and a + * contribution naming anything else is dropped rather than guessed at — a + * page that renders an unknown control is how a typo becomes a broken form. + */ + private const FIELD_TYPES = ['text', 'select', 'checkbox']; /** * Contribute to a hook point. @@ -82,12 +121,41 @@ public function render(string $hook, array $context = []): array continue; } - $out[] = array_intersect_key($result, array_flip(self::ALLOWED)); + $contribution = array_intersect_key($result, array_flip(self::ALLOWED)); + + if (! $this->fieldTypeIsRenderable($contribution, $hook)) { + continue; + } + + $out[] = $contribution; } return $out; } + /** + * A contributed field naming a type core cannot draw is dropped, with a line + * in the log — the same degradation the browser side applies to a component + * this build does not contain. + * + * @param array $contribution + */ + private function fieldTypeIsRenderable(array $contribution, string $hook): bool + { + if (! isset($contribution['type']) || in_array($contribution['type'], self::FIELD_TYPES, true)) { + return true; + } + + Log::warning('A module contributed a field of a type this application cannot render.', [ + 'hook' => $hook, + 'type' => $contribution['type'], + 'field' => $contribution['name'] ?? null, + 'renderable' => self::FIELD_TYPES, + ]); + + return false; + } + /** * Resolve several hooks against one shared context — the shape a controller * hands to its page. Points with no listeners are omitted, so a community @@ -115,4 +183,35 @@ public function renderMany(array $hooks, array $context = []): array return $out; } + + /** + * Tell the listeners that a record was saved, so a module can store what it + * contributed to the form. + * + * Unlike render(), this returns nothing and is called for its effect. Call it + * INSIDE the controller's transaction: a module's write belongs to the same + * unit of work as the record it hangs off, or a failure there leaves the + * account saved and its module field silently lost. + * + * A listener that throws therefore rolls the whole save back, which is the + * intended behaviour and not an accident — see the transaction test. + * + * The context carries the validated input and the saved model; a module reads + * only its own prefixed keys from it. + * + * @param array $context + */ + public function dispatch(string $hook, array $context = []): void + { + $handlers = $this->handlers[$hook] ?? []; + usort($handlers, fn ($a, $b) => $a['order'] <=> $b['order']); + + foreach ($handlers as $handler) { + $payload = $handler['payload']; + + if (is_callable($payload)) { + $payload($context); + } + } + } } diff --git a/backend/app/Http/Controllers/Api/V1/ProcessTemplateController.php b/backend/app/Http/Controllers/Api/V1/ProcessTemplateController.php index 977debda8..c4716bd71 100644 --- a/backend/app/Http/Controllers/Api/V1/ProcessTemplateController.php +++ b/backend/app/Http/Controllers/Api/V1/ProcessTemplateController.php @@ -19,7 +19,7 @@ class ProcessTemplateController extends Controller public function index(Request $request, ProductType $productType): JsonResponse { $query = $productType->processTemplates()->with('steps.workstation'); - if (!$request->boolean('include_inactive')) { + if (! $request->boolean('include_inactive')) { $query->where('is_active', true); } @@ -32,6 +32,7 @@ public function show(ProcessTemplate $processTemplate): JsonResponse { $this->authorize('view', $processTemplate); $processTemplate->load(['productType', 'steps.workstation']); + return response()->json(['data' => $processTemplate]); } @@ -44,7 +45,7 @@ public function store(StoreProcessTemplateRequest $request, ProductType $product $data['is_active'] = $data['is_active'] ?? true; // Auto-bump version if not provided - if (!isset($data['version'])) { + if (! isset($data['version'])) { $maxVersion = $productType->processTemplates()->max('version') ?? 0; $data['version'] = $maxVersion + 1; } @@ -61,6 +62,7 @@ public function update(UpdateProcessTemplateRequest $request, ProcessTemplate $p { $this->authorize('update', $processTemplate); $processTemplate->update($request->validated()); + return response()->json([ 'message' => 'Process template updated', 'data' => $processTemplate->fresh(['steps']), @@ -74,13 +76,15 @@ public function destroy(ProcessTemplate $processTemplate): JsonResponse // Optional: prevent deletion if used by work orders // (work orders snapshot the template so this is safe, but warn anyway) $processTemplate->delete(); + return response()->json(['message' => 'Process template deleted']); } public function toggleActive(ProcessTemplate $processTemplate): JsonResponse { $this->authorize('update', $processTemplate); - $processTemplate->update(['is_active' => !$processTemplate->is_active]); + $processTemplate->update(['is_active' => ! $processTemplate->is_active]); + return response()->json([ 'message' => $processTemplate->is_active ? 'Activated' : 'Deactivated', 'data' => $processTemplate, @@ -94,10 +98,11 @@ public function addStep(StoreTemplateStepRequest $request, ProcessTemplate $proc $this->authorize('update', $processTemplate); $data = $request->validated(); - if (!isset($data['step_number'])) { + if (! isset($data['step_number'])) { $data['step_number'] = ($processTemplate->steps()->max('step_number') ?? 0) + 1; } $data['process_template_id'] = $processTemplate->id; + $data = $this->withStepKind($data); $step = TemplateStep::create($data); @@ -110,7 +115,12 @@ public function addStep(StoreTemplateStepRequest $request, ProcessTemplate $proc public function updateStep(UpdateTemplateStepRequest $request, TemplateStep $templateStep): JsonResponse { $this->authorize('update', $templateStep->processTemplate); - $templateStep->update($request->validated()); + $data = $request->validated(); + if (array_key_exists('kind', $data) || array_key_exists('config', $data)) { + $data = $this->withStepKind($data + ['kind' => $templateStep->kind]); + } + $templateStep->update($data); + return response()->json([ 'message' => 'Step updated', 'data' => $templateStep->fresh(['workstation']), @@ -121,6 +131,7 @@ public function destroyStep(TemplateStep $templateStep): JsonResponse { $this->authorize('update', $templateStep->processTemplate); $templateStep->delete(); + return response()->json(['message' => 'Step deleted']); } @@ -143,7 +154,7 @@ public function reorderSteps(Request $request, ProcessTemplate $processTemplate) ], 422); } - DB::transaction(function () use ($stepIds, $processTemplate) { + DB::transaction(function () use ($stepIds) { // Use temporary high numbers first to avoid uniqueness collisions foreach ($stepIds as $i => $id) { TemplateStep::where('id', $id)->update(['step_number' => 10000 + $i]); @@ -158,4 +169,15 @@ public function reorderSteps(Request $request, ProcessTemplate $processTemplate) 'data' => $processTemplate->fresh(['steps.workstation']), ]); } + + /** Same normalisation as the admin editor: no config on a production step, a clean one on packing. */ + private function withStepKind(array $data): array + { + $data['kind'] = $data['kind'] ?? TemplateStep::KIND_PRODUCTION; + $data['config'] = $data['kind'] === TemplateStep::KIND_PACKING + ? TemplateStep::normalisePackingConfig($data['config'] ?? []) + : null; + + return $data; + } } diff --git a/backend/app/Http/Controllers/Api/V1/SerialUnitController.php b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php index 8d09230ef..6d54c0bcb 100644 --- a/backend/app/Http/Controllers/Api/V1/SerialUnitController.php +++ b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php @@ -3,12 +3,19 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\V1\BindUnitComponentApiRequest; +use App\Http\Requests\Api\V1\RecordSerialUnitStepRequest; +use App\Http\Requests\Api\V1\StoreSerialUnitRequest; +use App\Http\Requests\BlockUnitRequest; +use App\Http\Requests\UnblockUnitRequest; use App\Models\BatchStep; +use App\Models\ScrapReason; use App\Models\SerialUnit; +use App\Services\Traceability\BindingException; use App\Services\Traceability\SerialTraceService; +use App\Support\UnitSerialisation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Validation\Rule; /** * Per-unit (serial) genealogy API. Read access for any authenticated user; @@ -23,7 +30,11 @@ public function index(Request $request): JsonResponse $units = SerialUnit::query() ->when($request->query('work_order_id'), fn ($q, $id) => $q->where('work_order_id', $id)) ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s)) - ->when($request->query('search'), fn ($q, $s) => $q->where('serial_no', 'like', "%{$s}%")) + ->when($request->query('serial_no'), fn ($q, $s) => $q->where('serial_no', app(UnitSerialisation::class)->normalize($s))) + ->when($request->query('psn'), fn ($q, $s) => $q->where('psn', app(UnitSerialisation::class)->normalize($s))) + ->when($request->query('search'), fn ($q, $s) => $q->where( + fn ($qq) => $qq->where('serial_no', 'like', "%{$s}%")->orWhere('psn', 'like', "%{$s}%") + )) ->orderByDesc('id') ->limit(100) ->get(); @@ -36,30 +47,52 @@ public function show(SerialUnit $serialUnit): JsonResponse return response()->json(['data' => $this->serials->getHistory($serialUnit)]); } - public function store(Request $request): JsonResponse + public function store(StoreSerialUnitRequest $request): JsonResponse { - $data = $request->validate([ - 'serial_no' => ['required', 'string', 'max:100'], - 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], - 'batch_id' => ['nullable', 'integer', 'exists:batches,id'], - 'material_id' => ['nullable', 'integer', 'exists:materials,id'], - 'status' => ['nullable', Rule::in(SerialUnit::STATUSES)], - ]); + $data = $request->validated(); + + if (empty($data['serial_no'])) { + try { + $unit = $this->serials->startUnit($data['psn'], $request->user(), ['work_order_id' => $data['work_order_id'] ?? null]); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $unit], 201); + } $unit = $this->serials->registerUnit($data['serial_no'], $data); return response()->json(['data' => $unit], 201); } - public function recordStep(Request $request, SerialUnit $serialUnit): JsonResponse + /** Hold a non-conforming unit with an error code (a scrap reason). */ + public function block(BlockUnitRequest $request, SerialUnit $serialUnit): JsonResponse + { + try { + $unit = $this->serials->blockUnit($serialUnit, $request->user(), ScrapReason::findOrFail($request->validated('scrap_reason_id')), $request->validated('note')); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $unit]); + } + + /** Release a held unit - supervisors and admins. */ + public function unblock(UnblockUnitRequest $request, SerialUnit $serialUnit): JsonResponse + { + try { + $unit = $this->serials->unblockUnit($serialUnit, $request->user(), $request->validated('note')); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $unit]); + } + + public function recordStep(RecordSerialUnitStepRequest $request, SerialUnit $serialUnit): JsonResponse { - $data = $request->validate([ - 'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'], - 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], - 'parameters' => ['nullable', 'array'], - 'result' => ['nullable', Rule::in(['pass', 'fail', 'rework'])], - 'notes' => ['nullable', 'string', 'max:500'], - ]); + $data = $request->validated(); $step = isset($data['batch_step_id']) ? BatchStep::find($data['batch_step_id']) : null; @@ -70,4 +103,22 @@ public function recordStep(Request $request, SerialUnit $serialUnit): JsonRespon 'data' => $entry->load(['workstation:id,name,code', 'operator:id,name']), ], 201); } + + /** Scan a component onto a unit (a sub-assembly serial, a lot, or a bare identifier). */ + public function bindComponent(BindUnitComponentApiRequest $request, SerialUnit $serialUnit): JsonResponse + { + $data = $request->validated(); + try { + $component = $this->serials->bindComponent($serialUnit, $data['identifier'], $request->user(), [ + 'material_id' => $data['material_id'] ?? null, + 'quantity' => $data['quantity'] ?? 1, + 'batch_step_id' => $data['batch_step_id'] ?? null, + 'workstation_id' => $data['workstation_id'] ?? null, + ]); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['message' => __('Component bound'), 'data' => $component->load('material:id,code,name')], 201); + } } diff --git a/backend/app/Http/Controllers/Api/V1/TestRunController.php b/backend/app/Http/Controllers/Api/V1/TestRunController.php new file mode 100644 index 000000000..58b5f0c98 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/TestRunController.php @@ -0,0 +1,72 @@ +records(); + if ($records === []) { + return response()->json(['message' => __('The request holds no test-run records.')], 422); + } + + try { + $run = $this->parser->parseRecords($records); + } catch (InvalidArgumentException $e) { + return response()->json(['message' => __('Test log not recognised: :reason', ['reason' => $e->getMessage()])], 422); + } + if ($run === null) { + return response()->json(['message' => __('The records do not hold a complete test run.')], 422); + } + + try { + $result = $this->importer->import($run, array_filter([ + 'work_order' => $request->filled('work_order') ? WorkOrder::where('order_no', $request->input('work_order'))->first() : null, + 'workstation' => $request->filled('workstation') ? Workstation::where('code', $request->input('workstation'))->first() : null, + // The run is booked on the token that sent it; the name in the log + // stays in the run's parameters, it does not pick the account. + 'operator' => $request->user(), + 'source' => $request->input('source', 'api'), + ])); + } catch (InvalidArgumentException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $duplicate = $result['status'] === TestLogImporter::DUPLICATE; + $unit = $result['unit']; + + return response()->json([ + 'message' => $duplicate ? __('Test run already recorded') : __('Test run recorded'), + 'status' => $result['status'], + 'data' => [ + 'unit' => ['id' => $unit->id, 'serial_no' => $unit->serial_no, 'psn' => $unit->psn, 'status' => $unit->status, 'work_order_id' => $unit->work_order_id], + 'run_id' => $run->runId, + 'verdict' => $run->verdict, + 'history_id' => $result['entry']?->id, + ], + ], $duplicate ? 200 : 201); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/AuditLogController.php b/backend/app/Http/Controllers/Web/Admin/AuditLogController.php index 50aa7c045..4c320b2d2 100644 --- a/backend/app/Http/Controllers/Web/Admin/AuditLogController.php +++ b/backend/app/Http/Controllers/Web/Admin/AuditLogController.php @@ -31,15 +31,17 @@ public function index(Request $request) if ($request->filled('start_date') && $request->filled('end_date')) { $query->whereBetween('created_at', [ - $request->start_date . ' 00:00:00', - $request->end_date . ' 23:59:59', + $request->start_date.' 00:00:00', + $request->end_date.' 23:59:59', ]); } $auditLogs = $query->paginate(20); // Get distinct entity types for filter + // A row with no entity (a login, a settings change) has no type to list. $entityTypes = AuditLog::selectRaw('DISTINCT entity_type') + ->whereNotNull('entity_type') ->pluck('entity_type') ->map(function ($type) { return class_basename($type); @@ -52,15 +54,15 @@ public function index(Request $request) $users = User::select('id', 'name', 'username')->orderBy('name')->get(); return \Inertia\Inertia::render('admin/AuditLogs', [ - 'auditLogs' => $auditLogs, + 'auditLogs' => $auditLogs, 'entityTypes' => $entityTypes, - 'users' => $users, - 'filters' => [ + 'users' => $users, + 'filters' => [ 'entity_type' => $request->input('entity_type', ''), - 'user_id' => $request->input('user_id', ''), - 'action' => $request->input('action', ''), - 'start_date' => $request->input('start_date', ''), - 'end_date' => $request->input('end_date', ''), + 'user_id' => $request->input('user_id', ''), + 'action' => $request->input('action', ''), + 'start_date' => $request->input('start_date', ''), + 'end_date' => $request->input('end_date', ''), ], ]); } @@ -85,8 +87,8 @@ public function export(Request $request) if ($request->filled('start_date') && $request->filled('end_date')) { $query->whereBetween('created_at', [ - $request->start_date . ' 00:00:00', - $request->end_date . ' 23:59:59', + $request->start_date.' 00:00:00', + $request->end_date.' 23:59:59', ]); } @@ -101,7 +103,7 @@ public function export(Request $request) $csv .= Csv::row([ $log->created_at->toIso8601String(), $log->user ? $log->user->username : 'System', - class_basename($log->entity_type) . ' #' . $log->entity_id, + class_basename($log->entity_type).' #'.$log->entity_id, $log->action, $log->ip_address ?? 'N/A', $changes, @@ -110,14 +112,14 @@ class_basename($log->entity_type) . ' #' . $log->entity_id, return Response::make($csv, 200, [ 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="audit_log_' . date('Y-m-d_H-i-s') . '.csv"', + 'Content-Disposition' => 'attachment; filename="audit_log_'.date('Y-m-d_H-i-s').'.csv"', ]); } protected function formatChanges(AuditLog $log): string { if ($log->action === 'created') { - return 'Created with ' . count($log->after_state ?? []) . ' fields'; + return 'Created with '.count($log->after_state ?? []).' fields'; } if ($log->action === 'deleted') { @@ -130,6 +132,7 @@ protected function formatChanges(AuditLog $log): string $oldValue = $log->before_state[$field] ?? 'null'; $changes[] = "{$field}: {$oldValue} -> {$newValue}"; } + return implode('; ', $changes); } diff --git a/backend/app/Http/Controllers/Web/Admin/BatchReportController.php b/backend/app/Http/Controllers/Web/Admin/BatchReportController.php index 36f9ab394..6863b0822 100644 --- a/backend/app/Http/Controllers/Web/Admin/BatchReportController.php +++ b/backend/app/Http/Controllers/Web/Admin/BatchReportController.php @@ -23,7 +23,7 @@ public function pdf(Batch $batch) $pdf = Pdf::loadView('admin.reports.batch-report-pdf', $data) ->setPaper('a4', 'portrait'); - $filename = 'report-'.($batch->lot_number ?? 'batch-'.$batch->id).'.pdf'; + $filename = 'report-'.\App\Support\DownloadName::safe($batch->lot_number ?? 'batch-'.$batch->id).'.pdf'; return $pdf->download($filename); } diff --git a/backend/app/Http/Controllers/Web/Admin/BomManagementController.php b/backend/app/Http/Controllers/Web/Admin/BomManagementController.php index caa842784..ea414612e 100644 --- a/backend/app/Http/Controllers/Web/Admin/BomManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/BomManagementController.php @@ -3,12 +3,13 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Web\Admin\StoreBomItemRequest; +use App\Http\Requests\Web\Admin\UpdateBomItemRequest; use App\Models\BomItem; use App\Models\Material; use App\Models\ProcessTemplate; use App\Models\ProductType; use App\Services\Material\BomService; -use Illuminate\Http\Request; use Inertia\Inertia; class BomManagementController extends Controller @@ -61,6 +62,7 @@ public function index(ProductType $productType, ProcessTemplate $processTemplate 'step_number' => $item->templateStep?->step_number, 'step_name' => $item->templateStep?->name, 'quantity_per_unit' => $item->quantity_per_unit, + 'per' => $item->per ?? BomItem::PER_UNIT, 'scrap_percentage' => $item->scrap_percentage, 'consumed_at' => $item->consumed_at, 'notes' => $item->notes, @@ -84,49 +86,19 @@ public function index(ProductType $productType, ProcessTemplate $processTemplate 'id' => $s->id, 'step_number' => $s->step_number, 'name' => $s->name, + 'kind' => $s->kind, + 'config' => $s->config, ]), ]); } - public function store(Request $request, ProductType $productType, ProcessTemplate $processTemplate) + public function store(StoreBomItemRequest $request, ProductType $productType, ProcessTemplate $processTemplate) { if ($processTemplate->product_type_id !== $productType->id) { abort(404); } - // A line is exactly one of material / product-type. `required_without` + - // `prohibits` enforces exactly-one; `component_kind` (sent by the UI) is - // ignored so older material-only callers keep working. - $validated = $request->validate([ - 'material_id' => [ - 'required_without:product_type_id', - 'prohibits:product_type_id', - 'nullable', - 'exists:materials,id', - \Illuminate\Validation\Rule::unique('bom_items', 'material_id') - ->where('process_template_id', $processTemplate->id) - ->whereNull('deleted_at'), - ], - 'product_type_id' => [ - 'required_without:material_id', - 'nullable', - 'exists:product_types,id', - // A product can't be a component of itself. - \Illuminate\Validation\Rule::notIn([$productType->id]), - \Illuminate\Validation\Rule::unique('bom_items', 'product_type_id') - ->where('process_template_id', $processTemplate->id) - ->whereNull('deleted_at'), - ], - 'template_step_id' => 'nullable|exists:template_steps,id', - 'quantity_per_unit' => 'required|numeric|gt:0', - 'scrap_percentage' => 'nullable|numeric|min:0|max:100', - 'consumed_at' => 'nullable|in:start,during,end', - 'notes' => 'nullable|string', - ], [ - 'material_id.unique' => __('This material is already in the BOM for this template.'), - 'product_type_id.unique' => __('This product type is already in the BOM for this template.'), - 'product_type_id.not_in' => __('A product type cannot be a component of itself.'), - ]); + $validated = $request->validated(); // Persist only the component reference that was set. $data = [ @@ -134,6 +106,7 @@ public function store(Request $request, ProductType $productType, ProcessTemplat 'product_type_id' => $validated['product_type_id'] ?? null, 'template_step_id' => $validated['template_step_id'] ?? null, 'quantity_per_unit' => $validated['quantity_per_unit'], + 'per' => $validated['per'] ?? BomItem::PER_UNIT, 'scrap_percentage' => $validated['scrap_percentage'] ?? null, 'consumed_at' => $validated['consumed_at'] ?? null, 'notes' => $validated['notes'] ?? null, @@ -145,19 +118,16 @@ public function store(Request $request, ProductType $productType, ProcessTemplat ->with('success', __('Component added to BOM.')); } - public function update(Request $request, ProductType $productType, ProcessTemplate $processTemplate, BomItem $bomItem) + public function update(UpdateBomItemRequest $request, ProductType $productType, ProcessTemplate $processTemplate, BomItem $bomItem) { if ($processTemplate->product_type_id !== $productType->id || $bomItem->process_template_id !== $processTemplate->id) { abort(404); } - $validated = $request->validate([ - 'template_step_id' => 'nullable|exists:template_steps,id', - 'quantity_per_unit' => 'required|numeric|gt:0', - 'scrap_percentage' => 'nullable|numeric|min:0|max:100', - 'consumed_at' => 'nullable|in:start,during,end', - 'notes' => 'nullable|string', - ]); + $validated = $request->validated(); + // A caller that does not send the basis keeps the line's: a per-carton + // line must not silently turn per-unit (and its need grow by the box size). + $validated['per'] = $validated['per'] ?? ($request->has('per') ? BomItem::PER_UNIT : ($bomItem->per ?? BomItem::PER_UNIT)); $this->bomService->updateItem($bomItem, $validated); diff --git a/backend/app/Http/Controllers/Web/Admin/LotSequenceController.php b/backend/app/Http/Controllers/Web/Admin/LotSequenceController.php index 9209bfbc7..4a612b78a 100644 --- a/backend/app/Http/Controllers/Web/Admin/LotSequenceController.php +++ b/backend/app/Http/Controllers/Web/Admin/LotSequenceController.php @@ -21,7 +21,7 @@ public function index() 'productTypeNames' => fn () => ProductType::pluck('name', 'id'), // The drawer's option lists — fetched on first open, not per visit. 'productTypes' => Inertia::optional(fn () => $this->activeProductTypes()), - 'patternTokens' => Inertia::optional(fn () => LotPatternFormatter::TOKENS), + 'patternTokens' => Inertia::optional(fn () => (new LotPatternFormatter)->describe(now())), ]); } @@ -29,7 +29,7 @@ public function create() { return Inertia::render('admin/lot-sequences/Create', [ 'productTypes' => $this->activeProductTypes(), - 'patternTokens' => LotPatternFormatter::TOKENS, + 'patternTokens' => (new LotPatternFormatter)->describe(now()), ]); } @@ -48,7 +48,7 @@ public function edit(LotSequence $lotSequence) 'pattern', 'pad_size', 'year_prefix', 'reset_period', ), 'productTypes' => $this->activeProductTypes(), - 'patternTokens' => LotPatternFormatter::TOKENS, + 'patternTokens' => (new LotPatternFormatter)->describe(now()), ]); } diff --git a/backend/app/Http/Controllers/Web/Admin/MaterialManagementController.php b/backend/app/Http/Controllers/Web/Admin/MaterialManagementController.php index afc626182..f5c4d1103 100644 --- a/backend/app/Http/Controllers/Web/Admin/MaterialManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/MaterialManagementController.php @@ -77,73 +77,75 @@ public function show(Material $material, CustomFieldService $customFields) ->limit(20) ->get() ->map(fn ($lot) => [ - 'id' => $lot->id, - 'lot_number' => $lot->lot_number, - 'supplier_lot_no' => $lot->supplier_lot_no, + 'id' => $lot->id, + 'lot_number' => $lot->lot_number, + 'supplier_lot_no' => $lot->supplier_lot_no, 'quantity_received' => $lot->quantity_received, - 'quantity_available'=> $lot->quantity_available, - 'expiry_date' => $lot->expiry_date?->toDateString(), - 'status' => $lot->status, - 'is_expired' => $lot->isExpired(), + 'quantity_available' => $lot->quantity_available, + 'expiry_date' => $lot->expiry_date?->toDateString(), + 'status' => $lot->status, + 'is_expired' => $lot->isExpired(), ]); $recentMovements = \App\Models\StockMovement::forMaterial($material->id) ->limit(15) ->get() ->map(fn ($mv) => [ - 'id' => $mv->id, - 'performed_at' => $mv->performed_at?->toIso8601String(), + 'id' => $mv->id, + 'performed_at' => $mv->performed_at?->toIso8601String(), 'movement_type' => $mv->movement_type, - 'quantity' => $mv->quantity, + 'quantity' => $mv->quantity, 'balance_after' => $mv->balance_after, - 'source_type' => $mv->source_type, - 'source_id' => $mv->source_id, - 'reason' => $mv->reason, - 'performed_by' => $mv->performedBy ? ['name' => $mv->performedBy->name] : null, + 'source_type' => $mv->source_type, + 'source_id' => $mv->source_id, + 'reason' => $mv->reason, + // System-written notes in the UI locale; a user's own note as typed. + 'reason_display' => \App\Support\StockMovementReason::translate($mv->reason), + 'performed_by' => $mv->performedBy ? ['name' => $mv->performedBy->name] : null, ]); return Inertia::render('admin/materials/Show', [ 'material' => [ - 'id' => $material->id, - 'code' => $material->code, - 'description' => $material->description, - 'material_type_id' => $material->material_type_id, - 'name' => $material->name, - 'is_active' => $material->is_active, - 'unit_of_measure' => $material->unit_of_measure, - 'tracking_type' => $material->tracking_type, + 'id' => $material->id, + 'code' => $material->code, + 'description' => $material->description, + 'material_type_id' => $material->material_type_id, + 'name' => $material->name, + 'is_active' => $material->is_active, + 'unit_of_measure' => $material->unit_of_measure, + 'tracking_type' => $material->tracking_type, 'default_scrap_percentage' => $material->default_scrap_percentage, - 'stock_quantity' => $material->stock_quantity, - 'reserved_quantity' => $material->reserved_quantity ?? 0, - 'available_quantity' => $material->available_quantity, - 'min_stock_level' => $material->min_stock_level, - 'unit_price' => $material->unit_price, - 'price_currency' => $material->price_currency, - 'external_code' => $material->external_code, - 'external_system' => $material->external_system, - 'custom_fields' => $material->custom_fields, - 'material_type' => $material->materialType ? ['name' => $material->materialType->name] : null, - 'sources' => $material->sources->map(fn ($s) => [ - 'id' => $s->id, - 'external_code' => $s->external_code, + 'stock_quantity' => $material->stock_quantity, + 'reserved_quantity' => $material->reserved_quantity ?? 0, + 'available_quantity' => $material->available_quantity, + 'min_stock_level' => $material->min_stock_level, + 'unit_price' => $material->unit_price, + 'price_currency' => $material->price_currency, + 'external_code' => $material->external_code, + 'external_system' => $material->external_system, + 'custom_fields' => $material->custom_fields, + 'material_type' => $material->materialType ? ['name' => $material->materialType->name] : null, + 'sources' => $material->sources->map(fn ($s) => [ + 'id' => $s->id, + 'external_code' => $s->external_code, 'integration_config' => $s->integrationConfig ? ['system_name' => $s->integrationConfig->system_name] : null, ])->values(), - 'bom_items' => $material->bomItems->map(fn ($item) => [ - 'id' => $item->id, + 'bom_items' => $material->bomItems->map(fn ($item) => [ + 'id' => $item->id, 'quantity_per_unit' => $item->quantity_per_unit, 'scrap_percentage' => $item->scrap_percentage, 'process_template' => $item->processTemplate ? [ - 'name' => $item->processTemplate->name, + 'name' => $item->processTemplate->name, 'product_type' => $item->processTemplate->productType ? ['name' => $item->processTemplate->productType->name] : null, ] : null, ])->values(), ], - 'lots' => $lots, + 'lots' => $lots, 'recentMovements' => $recentMovements, - 'materialTypes' => Inertia::optional(fn () => MaterialType::orderBy('name')->get(['id', 'name'])), - 'customFields' => $customFields->clientConfig('material'), + 'materialTypes' => Inertia::optional(fn () => MaterialType::orderBy('name')->get(['id', 'name'])), + 'customFields' => $customFields->clientConfig('material'), ]); } diff --git a/backend/app/Http/Controllers/Web/Admin/ModulesController.php b/backend/app/Http/Controllers/Web/Admin/ModulesController.php index a93a7592b..d9a079895 100644 --- a/backend/app/Http/Controllers/Web/Admin/ModulesController.php +++ b/backend/app/Http/Controllers/Web/Admin/ModulesController.php @@ -46,6 +46,19 @@ public function enable(Request $request, string $name) return redirect()->back()->with('error', __('Module ":name" not found.', ['name' => $name])); } + // The install-time check does not cover a module that was already on + // disk — shipped with the image, or installed before core was rolled + // back. Enabling is the other moment it matters, and the one that runs + // its migrations. + $requiresCore = $this->manager->manifest($name)['requires_core'] ?? null; + + if (! $this->manager->coreSatisfies($requiresCore)) { + return redirect()->route('admin.modules.index')->with('error', __( + 'Module ":name" requires OpenMES :required; this installation is :current.', + ['name' => $module['display_name'], 'required' => $requiresCore, 'current' => config('version.current')], + )); + } + $this->manager->enable($name); $this->clearCache(); @@ -133,11 +146,25 @@ public function destroy(string $name) return redirect()->back()->with('error', __('Module ":name" not found.', ['name' => $name])); } - $this->manager->uninstall($name); + try { + $this->manager->uninstall($name); + } catch (\Throwable $e) { + report($e); + + // The module is still on disk, so this can be retried once whatever + // its uninstall hook tripped over is dealt with. + return redirect()->route('admin.modules.index')->with('error', __( + 'Module ":name" could not be uninstalled: :msg', + ['name' => $module['display_name'], 'msg' => $e->getMessage()], + )); + } + $this->clearCache(); - return redirect()->route('admin.modules.index') - ->with('success', __('Module ":name" uninstalled.', ['name' => $module['display_name']])); + return redirect()->route('admin.modules.index')->with('success', __( + 'Module ":name" uninstalled. Its database tables were left in place; reinstalling keeps the data.', + ['name' => $module['display_name']], + )); } protected function clearCache(): void diff --git a/backend/app/Http/Controllers/Web/Admin/PalletController.php b/backend/app/Http/Controllers/Web/Admin/PalletController.php index a5b6ab8c4..9976dff10 100644 --- a/backend/app/Http/Controllers/Web/Admin/PalletController.php +++ b/backend/app/Http/Controllers/Web/Admin/PalletController.php @@ -8,15 +8,23 @@ use App\Models\LabelTemplate; use App\Models\Pallet; use App\Models\WorkOrder; +use Illuminate\Http\Request; use Inertia\Inertia; class PalletController extends Controller { use \App\Http\Controllers\Concerns\StaysOnList; - public function index() + public function index(Request $request) { + // /admin/pallets/{id}/edit lands here with ?edit={id}: the record is + // handed over so the page opens its drawer on it, like every other list. + $editPallet = $request->integer('edit') + ? Pallet::find($request->integer('edit'))?->only('id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'location', 'destination', 'erp_reference') + : null; + return Inertia::render('admin/pallets/Index', [ + 'editPallet' => $editPallet, // Closures so the drawer's partial reload (only=[workOrders,statuses]) // doesn't compute these just for Inertia to discard them. 'workOrderNumbers' => fn () => WorkOrder::pluck('order_no', 'id'), @@ -43,16 +51,10 @@ public function store(PalletRequest $request) return $this->saved($request, redirect()->route('admin.pallets.index'), __('Pallet created.')); } + /** Editing happens in the list's drawer; a deep link to the old page opens it there. */ public function edit(Pallet $pallet) { - return Inertia::render('admin/pallets/Edit', [ - 'pallet' => $pallet->only( - 'id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'location', 'destination', 'erp_reference', - ), - 'workOrders' => $this->workOrderOptions(), - 'statuses' => PalletStatus::options(), - 'labelTemplates' => $this->activeLabelTemplates(), - ]); + return redirect()->route('admin.pallets.index', ['edit' => $pallet->id]); } public function update(PalletRequest $request, Pallet $pallet) diff --git a/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php b/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php index 8daf8e4f5..fae6b3fcf 100644 --- a/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php @@ -107,6 +107,10 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate) ->get(); return Inertia::render('admin/process-templates/Show', [ + // Packing steps may name the label they print; only label types packing prints. + 'labelTemplates' => \App\Models\LabelTemplate::where('is_active', true) + ->whereIn('type', [\App\Models\LabelTemplate::TYPE_SERIAL_UNIT, \App\Models\LabelTemplate::TYPE_CARTON, \App\Models\LabelTemplate::TYPE_PALLET]) + ->orderBy('type')->orderBy('name')->get(['id', 'name', 'type', 'size']), 'productType' => $processTemplate->productType->only('id', 'name'), 'processTemplate' => [ 'id' => $processTemplate->id, @@ -132,6 +136,8 @@ public function show(ProductType $productType, ProcessTemplate $processTemplate) 'is_optional' => (bool) $s->is_optional, 'variant_group' => $s->variant_group, 'is_default_variant' => (bool) $s->is_default_variant, + 'kind' => $s->kind, + 'config' => $s->config, 'workstation' => $s->workstation ? [ 'id' => $s->workstation->id, 'name' => $s->workstation->name, @@ -342,6 +348,11 @@ private function stepPayload(Request $request): array $data['is_optional'] = $request->boolean('is_optional'); $data['variant_group'] = $request->filled('variant_group') ? $request->input('variant_group') : null; $data['is_default_variant'] = $data['variant_group'] !== null && $request->boolean('is_default_variant'); + // A production step carries no config; a packing step keeps only what it declares. + $data['kind'] = $data['kind'] ?? TemplateStep::KIND_PRODUCTION; + $data['config'] = $data['kind'] === TemplateStep::KIND_PACKING + ? TemplateStep::normalisePackingConfig($data['config'] ?? []) + : null; return $data; } @@ -358,7 +369,7 @@ private function stepAuditFields(TemplateStep $step): array 'id', 'step_number', 'name', 'instruction', 'workstation_id', 'workstation_type_id', 'process_segment_id', 'estimated_duration_minutes', 'setup_time_minutes', 'run_time_per_unit_minutes', 'parameters', - 'is_optional', 'variant_group', 'is_default_variant', + 'is_optional', 'variant_group', 'is_default_variant', 'kind', 'config', ]); } diff --git a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php index 976d4b279..112b551ff 100644 --- a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php +++ b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php @@ -3,10 +3,16 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Models\Customer; +use App\Models\Line; +use App\Models\Material; +use App\Models\ProductType; use App\Models\SerialUnit; +use App\Models\SerialUnitComponent; use App\Models\WorkOrder; use App\Services\Traceability\SerialTraceService; use App\Services\Traceability\TraceabilityService; +use App\Support\UnitSerialisation; use Carbon\Carbon; use Illuminate\Http\Request; use Inertia\Inertia; @@ -27,6 +33,9 @@ public function __construct( public function index(Request $request) { $term = trim((string) $request->query('q', '')); + // Serials, process serials and component identifiers are stored the way + // the plant normalises them (case, spacing); look them up the same way. + $identifier = app(UnitSerialisation::class)->normalize($term) ?? $term; $result = null; if ($term !== '') { @@ -51,14 +60,35 @@ public function index(Request $request) 'recall' => $this->tracer->recallImpact(collect([$lot])), 'forward' => $this->mapForward($this->tracer->forwardTrace($lot)), 'backward' => $this->tracer->backwardTraceLot($lot), + // Serialised units the lot was scanned into as a component - the + // recall answer for parts that never went through batch consumption. + 'units' => $this->tracer->componentTrace(app(UnitSerialisation::class)->normalize($lot->lot_number) ?? $lot->lot_number)['units'], ]; - } elseif ($unit = SerialUnit::where('serial_no', $term)->first()) { + } elseif ($unit = SerialUnit::where(function ($q) use ($term, $identifier) { + $q->whereIn('serial_no', [$term, $identifier])->orWhereIn('psn', [$term, $identifier]); + })->first()) { $result = [ 'type' => 'serial', 'recall' => $this->tracer->recallImpactForSerial($unit), 'components' => $this->tracer->componentLineJourneys($unit)['components'], + 'installed' => $this->tracer->installedComponents($unit), + // A sub-assembly: the products it went into (empty for a finished product). + 'installed_in' => $unit->serial_no ? $this->tracer->componentTrace($unit->serial_no)['units'] : [], 'data' => $this->mapSerial($this->serials->getHistory($unit)), ]; + } elseif ($componentId = SerialUnitComponent::whereIn('identifier', [$term, $identifier])->value('identifier')) { + // A component known only by the serial on its label: which units hold it. + $result = [ + 'type' => 'component', + 'data' => $this->tracer->componentTrace($componentId), + ]; + } elseif ($workOrder = WorkOrder::where('order_no', $term)->first()) { + // The order number is what the shop floor knows - and what the + // console's own orders table links with. + $result = [ + 'type' => 'work_order', + 'data' => $this->tracer->workOrderTrace($workOrder), + ]; } elseif (WorkOrder::where('customer_order_no', $term)->exists()) { // Customer order number is non-unique → aggregate all matching WOs. $result = [ @@ -71,6 +101,13 @@ public function index(Request $request) return Inertia::render('admin/traceability/Index', [ 'term' => $term, 'result' => $result, + // id => name lookups for the browse tables, whose rows are synced + // collections carrying only the foreign keys. + 'lineNames' => Line::pluck('name', 'id'), + 'productTypeNames' => ProductType::pluck('name', 'id'), + 'customerNames' => Customer::pluck('name', 'id'), + 'materialNames' => Material::pluck('name', 'id'), + 'workOrderNumbers' => WorkOrder::pluck('order_no', 'id'), ]); } @@ -79,24 +116,32 @@ private function mapBatch(array $g): array { $b = $g['batch']; $byStep = $g['consumptions_by_step']; + $steps = $b->steps->sortBy('step_number')->values(); return [ 'batch' => [ 'id' => $b->id, 'batch_number' => $b->batch_number, 'lot_number' => $b->lot_number, + 'status' => $b->status, 'work_order' => $b->workOrder ? [ 'order_no' => $b->workOrder->order_no, 'product' => $b->workOrder->productType?->name, ] : null, - 'steps' => $b->steps->map(fn ($s) => [ + // Timings alongside the genealogy: when each step started and ended, + // how long it ran, and how long the batch waited between steps. + 'lead_time_minutes' => $this->batchLeadTime($b->steps), + 'steps' => $steps->map(fn ($s, $i) => [ 'id' => $s->id, 'step_number' => $s->step_number, 'name' => $s->name, 'status' => $s->status, 'workstation' => $s->workstation?->name, 'completed_by' => $s->completedBy?->name, + 'started_at' => $s->started_at ? Carbon::parse($s->started_at)->format('Y-m-d H:i') : null, 'completed_at' => $s->completed_at ? Carbon::parse($s->completed_at)->format('Y-m-d H:i') : null, + 'duration_minutes' => $s->duration_minutes ?? self::minutesBetween($s->started_at, $s->completed_at), + 'waited_minutes' => $i > 0 ? self::minutesBetween($steps[$i - 1]->completed_at, $s->started_at) : null, 'consumptions' => ($byStep[$s->id] ?? collect())->map(fn ($c) => [ 'lot_number' => $c->materialLot?->lot_number, 'material' => $c->materialLot?->material?->name, @@ -137,22 +182,67 @@ private function mapForward(array $f): array ]; } + /** Whole minutes from one stamp to the next; null unless both exist and are in order. */ + private static function minutesBetween($from, $to): ?int + { + if (! $from || ! $to) { + return null; + } + $a = Carbon::parse($from); + $b = Carbon::parse($to); + + return $b->lt($a) ? null : (int) $a->diffInMinutes($b); + } + + /** First start to last finish across the batch's steps. */ + private function batchLeadTime($steps): ?int + { + $starts = $steps->pluck('started_at')->filter(); + $ends = $steps->pluck('completed_at')->filter(); + if ($starts->isEmpty() || $ends->isEmpty()) { + return null; + } + + return self::minutesBetween($starts->map(fn ($t) => Carbon::parse($t))->min(), $ends->map(fn ($t) => Carbon::parse($t))->max()); + } + /** Flatten a serial unit + its process history. */ private function mapSerial(SerialUnit $u): array { + // Timings from the stamps the stations left: the gap since the previous + // record, a test's own run time (the tester reports start and end), and + // the lead time from the first record to shipping (or to the last record). + $history = $u->history->sortBy('processed_at')->values(); + $first = $history->first()?->processed_at; + $last = $u->shipped_at ?? $history->last()?->processed_at; + return [ + 'lead_time_seconds' => $first && $last && ! Carbon::parse($last)->lt(Carbon::parse($first)) ? (int) Carbon::parse($first)->diffInSeconds(Carbon::parse($last)) : null, + 'lead_time_to_shipping' => (bool) $u->shipped_at, 'serial_no' => $u->serial_no, + 'psn' => $u->psn, 'status' => $u->status, 'product' => $u->workOrder?->productType?->name ?? $u->material?->name, 'work_order' => $u->workOrder?->order_no, - 'history' => $u->history->map(fn ($h) => [ + 'carton_no' => $u->carton?->carton_no, + 'pallet_no' => $u->pallet?->pallet_no, + 'packed_at' => $u->packed_at?->format('Y-m-d H:i'), + 'shipped_at' => $u->shipped_at?->format('Y-m-d H:i'), + 'history' => $history->map(fn ($h, $i) => [ 'workstation' => $h->workstation?->name, 'line' => $h->workstation?->line?->name, 'step' => $h->batchStep?->name, 'operator' => $h->operator?->name, 'processed_at' => $h->processed_at ? Carbon::parse($h->processed_at)->format('Y-m-d H:i:s') : null, + 'since_previous_seconds' => $i > 0 && $h->processed_at && $history[$i - 1]->processed_at + ? max(0, (int) Carbon::parse($history[$i - 1]->processed_at)->diffInSeconds(Carbon::parse($h->processed_at), false)) + : null, + 'duration_seconds' => ! empty($h->parameters['started_at']) && ! empty($h->parameters['ended_at']) + ? max(0, (int) Carbon::parse($h->parameters['started_at'])->diffInSeconds(Carbon::parse($h->parameters['ended_at']), false)) + : null, 'result' => $h->result, 'parameters' => $h->parameters, + 'notes' => $h->notes, ])->values(), ]; } diff --git a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php index c6f0ceb3c..f56a4794c 100644 --- a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php @@ -2,20 +2,24 @@ namespace App\Http\Controllers\Web\Admin; +use App\Extension\HookRegistry; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreUserRequest; +use App\Http\Requests\UpdateUserRequest; use App\Models\User; use App\Models\Worker; use App\Models\Workstation; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; use Inertia\Inertia; use Spatie\Permission\Models\Role; class UserManagementController extends Controller { + private const FIELDS_HOOK = 'display.admin.users.form.fields'; + + private const SAVED_HOOK = 'persist.admin.users'; + /** * Display a listing of users. Rows live-sync via the `users` shape (safe * columns only); role + workstation names come as props keyed by id. @@ -58,25 +62,32 @@ private function workforce(): \App\Extension\Contracts\WorkforceProvider } /** - * Ids from an option list, for validating against exactly what was offered. + * Fields an installed module contributes to this form. * - * Deliberately not `exists:crews,id`: that rule queries a table this - * installation may not have, and it would also accept a value the form - * never offered. An empty list rejects everything, which is the correct - * answer when nothing records crews. + * Empty on a community install, where the prop is `{}` and ModuleFields + * renders nothing. A module ships no JSX of its own — see HookRegistry. * - * @param list $options - * @return list + * @return array>> */ - private function idsOf(array $options): array + private function moduleFields(?User $user = null): array { - return array_column($options, 'id'); + return app(HookRegistry::class)->renderMany( + [self::FIELDS_HOOK], + ['user' => $user, 'worker' => $user?->worker], + ); } - /** Shared option lists for the create/edit forms. */ - private function formData(): array + /** + * Shared option lists for the create/edit forms. + * + * Takes the record so module contributions are resolved once, against it — + * resolving them here without it and again in edit() would run a module's + * callback twice, the first time with no record to read. + */ + private function formData(?User $user = null): array { return [ + 'hooks' => $this->moduleFields($user), 'roles' => Role::orderBy('name')->pluck('name'), 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(['id', 'name', 'line_id']) ->map(fn ($w) => ['id' => $w->id, 'name' => $w->line ? "{$w->name} ({$w->line->name})" : $w->name]), @@ -93,26 +104,9 @@ private function formData(): array /** * Store a newly created user */ - public function store(Request $request) + public function store(StoreUserRequest $request) { - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], - 'username' => 'required|string|max:255|unique:users', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => ['required', 'confirmed', Password::defaults()], - 'role' => 'required_if:account_type,user|nullable|exists:roles,name', - 'account_type' => 'required|in:user,workstation', - 'workstation_id' => 'nullable|exists:workstations,id|required_if:account_type,workstation', - 'worker_code' => 'nullable|string|max:50|unique:workers,code', - 'worker_phone' => 'nullable|string|max:50', - 'worker_crew_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->crewOptions()))], - 'worker_wage_group_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->wageGroupOptions()))], - 'skills' => 'nullable|array', - 'skills.*.id' => ['required', Rule::in($this->idsOf($this->workforce()->skillOptions()))], - 'skills.*.level' => 'nullable|integer|min:1|max:5', - ], [ - 'name.regex' => 'Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.', - ]); + $validated = $request->validated(); DB::transaction(function () use ($request, $validated) { $user = User::create([ @@ -152,6 +146,15 @@ public function store(Request $request) $user->update(['worker_id' => $worker->id]); } + + // Inside the transaction on purpose: a module's write belongs to the + // same unit of work as the account it hangs off. + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'store', + 'user' => $user, + 'worker' => $user->worker, + 'input' => $validated, + ]); }); return redirect()->route('admin.users.index') @@ -165,7 +168,7 @@ public function edit(User $user) { $user->load(Worker::hasModuleRelation('skills') ? 'worker.skills' : 'worker'); - return Inertia::render('admin/users/Edit', array_merge($this->formData(), [ + return Inertia::render('admin/users/Edit', array_merge($this->formData($user), [ 'assignments' => [ 'lines' => $user->lines()->get(['lines.id', 'lines.name'])->map(fn ($line) => $line->only('id', 'name')), 'station' => ($user->worker?->workstation ?? $user->workstation)?->only('id', 'name', 'line_id'), @@ -195,26 +198,9 @@ public function edit(User $user) /** * Update the specified user */ - public function update(Request $request, User $user) + public function update(UpdateUserRequest $request, User $user) { - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], - 'username' => 'required|string|max:255|unique:users,username,'.$user->id, - 'email' => 'required|string|email|max:255|unique:users,email,'.$user->id, - 'password' => ['nullable', 'confirmed', Password::defaults()], - 'role' => 'required_if:account_type,user|nullable|exists:roles,name', - 'account_type' => 'required|in:user,workstation', - 'workstation_id' => 'nullable|exists:workstations,id|required_if:account_type,workstation', - 'worker_code' => 'nullable|string|max:50|unique:workers,code,'.($user->worker_id ?? 'NULL'), - 'worker_phone' => 'nullable|string|max:50', - 'worker_crew_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->crewOptions()))], - 'worker_wage_group_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->wageGroupOptions()))], - 'skills' => 'nullable|array', - 'skills.*.id' => ['required', Rule::in($this->idsOf($this->workforce()->skillOptions()))], - 'skills.*.level' => 'nullable|integer|min:1|max:5', - ], [ - 'name.regex' => 'Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.', - ]); + $validated = $request->validated(); $updateData = [ 'name' => $validated['name'], @@ -277,6 +263,13 @@ public function update(Request $request, User $user) ); } } + + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'update', + 'user' => $user, + 'worker' => $user->fresh()->worker, + 'input' => $validated, + ]); }); return redirect()->route('admin.users.index') diff --git a/backend/app/Http/Controllers/Web/Admin/WorkerController.php b/backend/app/Http/Controllers/Web/Admin/WorkerController.php index 19b577c92..78ceb8540 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkerController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkerController.php @@ -2,16 +2,22 @@ namespace App\Http\Controllers\Web\Admin; +use App\Extension\HookRegistry; use App\Http\Controllers\Controller; use App\Http\Requests\StoreWorkerRequest; use App\Http\Requests\UpdateWorkerRequest; use App\Models\Worker; use App\Services\CustomFieldService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; class WorkerController extends Controller { + private const FIELDS_HOOK = 'display.admin.workers.form.fields'; + + private const SAVED_HOOK = 'persist.admin.workers'; + /** * Display a listing of workers. */ @@ -95,12 +101,26 @@ public function show(Worker $worker, CustomFieldService $cf) ]); } + /** + * Fields an installed module contributes to this form. + * + * Empty on a community install, where the prop is `{}` and ModuleFields + * renders nothing. A module ships no JSX of its own — see HookRegistry. + * + * @return array>> + */ + private function moduleFields(?Worker $worker = null): array + { + return app(HookRegistry::class)->renderMany([self::FIELDS_HOOK], ['worker' => $worker]); + } + /** * Show the form for creating a new worker. */ public function create(CustomFieldService $cf) { return Inertia::render('admin/workers/Create', [ + 'hooks' => $this->moduleFields(), 'crews' => $this->workforce()->crewOptions(), 'wageGroups' => $this->workforce()->wageGroupOptions(), 'personnelClasses' => $this->workforce()->personnelClassOptions(), @@ -125,15 +145,25 @@ public function store(StoreWorkerRequest $request, CustomFieldService $cf) $validated['custom_fields'] = $cf->fromRequest($request, 'worker') ?: null; } - $worker = Worker::create($validated); + DB::transaction(function () use ($request, $validated) { + $worker = Worker::create($validated); - // The skills pivot belongs to the optional workforce module; without it - // there is no relation to sync and nothing was submitted anyway. - if (Worker::hasModuleRelation('skills')) { - $worker->skills()->sync( - collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) - ); - } + // The skills pivot belongs to the optional workforce module; without it + // there is no relation to sync and nothing was submitted anyway. + if (Worker::hasModuleRelation('skills')) { + $worker->skills()->sync( + collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) + ); + } + + // Inside the transaction on purpose: a module's write belongs to the + // same unit of work as the record it hangs off. + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'store', + 'worker' => $worker, + 'input' => $validated, + ]); + }); return redirect()->route('admin.workers.index') ->with('success', 'Worker created successfully.'); @@ -153,6 +183,7 @@ public function edit(Worker $worker, CustomFieldService $cf) } return Inertia::render('admin/workers/Edit', [ + 'hooks' => $this->moduleFields($worker), 'worker' => [ 'id' => $worker->id, 'code' => $worker->code, @@ -197,15 +228,23 @@ public function update(UpdateWorkerRequest $request, Worker $worker, CustomField $validated['custom_fields'] = $cf->fromRequest($request, 'worker', $worker->custom_fields) ?: null; } - $worker->update($validated); + DB::transaction(function () use ($request, $worker, $validated) { + $worker->update($validated); - // Preserve certification metadata: update the legacy proficiency level - // without detaching existing rows (which would wipe cert_level etc.). - if (Worker::hasModuleRelation('skills')) { - $worker->skills()->syncWithoutDetaching( - collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) - ); - } + // Preserve certification metadata: update the legacy proficiency level + // without detaching existing rows (which would wipe cert_level etc.). + if (Worker::hasModuleRelation('skills')) { + $worker->skills()->syncWithoutDetaching( + collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) + ); + } + + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'update', + 'worker' => $worker, + 'input' => $validated, + ]); + }); return redirect()->route('admin.workers.index') ->with('success', 'Worker updated successfully.'); diff --git a/backend/app/Http/Controllers/Web/Admin/WorkstationManagementController.php b/backend/app/Http/Controllers/Web/Admin/WorkstationManagementController.php index 65be9d594..d19332672 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkstationManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkstationManagementController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreWorkstationRequest; +use App\Http\Requests\UpdateWorkstationRequest; use App\Models\Line; use App\Models\Worker; use App\Models\Workstation; use App\Services\CustomFieldService; -use Illuminate\Http\Request; +use App\Services\Production\OperatorScreens; use Inertia\Inertia; class WorkstationManagementController extends Controller @@ -15,22 +17,25 @@ class WorkstationManagementController extends Controller /** * Display workstations for a specific line */ - public function index(Line $line, CustomFieldService $cf) + public function index(Line $line, CustomFieldService $cf, OperatorScreens $screens) { $workstations = $line->workstations() ->withCount(['templateSteps', 'workers']) ->orderBy('code') ->get(); + $derived = $screens->derivedFor($workstations); return Inertia::render('admin/workstations/Index', [ 'workers' => $this->workerOptions(), 'customFields' => $cf->clientConfig('workstation'), 'line' => $line->only('id', 'name', 'code'), 'workstations' => $workstations->map(fn ($ws) => array_merge( - $ws->only('id', 'code', 'name', 'workstation_type', 'is_active', 'custom_fields'), + $ws->only('id', 'code', 'name', 'workstation_type', 'operator_screens', 'unit_label_actions', 'is_active', 'custom_fields'), [ 'template_steps_count' => $ws->template_steps_count, 'workers_count' => $ws->workers_count, + // What the routing gives the bench, shown beside the manual choice. + 'derived_screens' => $derived[$ws->id], ] ))->values(), ]); @@ -50,14 +55,16 @@ public function create(Line $line, CustomFieldService $cf) /** * Store a newly created workstation */ - public function store(Request $request, Line $line, CustomFieldService $cf) + public function store(StoreWorkstationRequest $request, Line $line, CustomFieldService $cf) { - $validated = $request->validate(array_merge([ - 'code' => 'required|string|max:50|unique:workstations,code', - 'name' => 'required|string|max:255', - 'workstation_type' => 'nullable|string|max:100', - 'is_active' => 'boolean', - ], $cf->rules('workstation')), [], $cf->attributeNames('workstation')); + $validated = $request->validated(); + // An empty list means "follow the routing". + if (array_key_exists('operator_screens', $validated)) { + $validated['operator_screens'] = $validated['operator_screens'] ?: null; + } + if (array_key_exists('unit_label_actions', $validated)) { + $validated['unit_label_actions'] = $validated['unit_label_actions'] ?: null; + } $validated['line_id'] = $line->id; $validated['is_active'] = $request->boolean('is_active', true); @@ -75,15 +82,15 @@ public function store(Request $request, Line $line, CustomFieldService $cf) /** * Show the form for editing a workstation */ - public function edit(Line $line, Workstation $workstation, CustomFieldService $cf) + public function edit(Line $line, Workstation $workstation, CustomFieldService $cf, OperatorScreens $screens) { if ($workstation->line_id !== $line->id) { abort(404); } return Inertia::render('admin/workstations/Edit', [ - 'line' => $line->only('id', 'name', 'code'), - 'workstation' => $workstation->only('id', 'code', 'name', 'workstation_type', 'is_active', 'custom_fields'), + 'line' => $line->only('id', 'name', 'code'), + 'workstation' => [...$workstation->only('id', 'code', 'name', 'workstation_type', 'operator_screens', 'unit_label_actions', 'is_active', 'custom_fields'), 'derived_screens' => $screens->derived($workstation)], 'customFields' => $cf->clientConfig('workstation'), 'workers' => $this->workerOptions(), ]); @@ -96,33 +103,34 @@ private function workerOptions() ->get(); return $workers->map(fn ($w) => [ - 'id' => $w->id, - 'name' => $w->name, - 'code' => $w->code, - 'workstation_id' => $w->workstation_id, - 'workstation_name' => $w->workstation?->name, - 'crew_name' => Worker::hasModuleRelation('crew') ? $w->crew?->name : null, - ])->values(); + 'id' => $w->id, + 'name' => $w->name, + 'code' => $w->code, + 'workstation_id' => $w->workstation_id, + 'workstation_name' => $w->workstation?->name, + 'crew_name' => Worker::hasModuleRelation('crew') ? $w->crew?->name : null, + ])->values(); } /** * Update the specified workstation */ - public function update(Request $request, Line $line, Workstation $workstation, CustomFieldService $cf) + public function update(UpdateWorkstationRequest $request, Line $line, Workstation $workstation, CustomFieldService $cf) { // Ensure workstation belongs to this line if ($workstation->line_id !== $line->id) { abort(404); } - $validated = $request->validate(array_merge([ - 'code' => 'required|string|max:50|unique:workstations,code,' . $workstation->id, - 'name' => 'required|string|max:255', - 'workstation_type' => 'nullable|string|max:100', - 'is_active' => 'boolean', - 'worker_ids' => 'nullable|array', - 'worker_ids.*' => 'exists:workers,id', - ], $cf->rules('workstation')), [], $cf->attributeNames('workstation')); + $validated = $request->validated(); + // An empty list means "follow the routing"; a request without the key + // (a script editing the name) leaves the bench's choice alone. + if (array_key_exists('operator_screens', $validated)) { + $validated['operator_screens'] = $validated['operator_screens'] ?: null; + } + if (array_key_exists('unit_label_actions', $validated)) { + $validated['unit_label_actions'] = $validated['unit_label_actions'] ?: null; + } $validated['is_active'] = $request->boolean('is_active'); unset($validated['custom_field_files']); diff --git a/backend/app/Http/Controllers/Web/AuthController.php b/backend/app/Http/Controllers/Web/AuthController.php index 9b6223458..8bd604684 100644 --- a/backend/app/Http/Controllers/Web/AuthController.php +++ b/backend/app/Http/Controllers/Web/AuthController.php @@ -203,11 +203,16 @@ protected function redirectToDashboard() return redirect()->route('supervisor.dashboard'); } - // Workstation accounts skip line selection — go straight to queue + // Workstation accounts skip line selection and open on their bench's + // first screen - a packing bench on packing, not on a queue it has no tab for. if ($user->account_type === 'workstation' && $user->workstation_id) { - $lineId = $user->workstation?->line_id; - if ($lineId) { - return redirect()->route('operator.queue', ['line' => $lineId]); + $workstation = $user->workstation; + if ($workstation?->line_id) { + session(['selected_line_id' => $workstation->line_id, 'selected_workstation_id' => $workstation->id]); + $screens = app(\App\Services\Production\OperatorScreens::class); + $route = $screens->landingRoute($screens->for($user, $workstation), $workstation->line?->default_operator_view ?? 'queue'); + + return redirect()->route($route, ['line' => $workstation->line_id]); } } diff --git a/backend/app/Http/Controllers/Web/Operator/BatchController.php b/backend/app/Http/Controllers/Web/Operator/BatchController.php index 82b7f3ef4..5fb5617eb 100644 --- a/backend/app/Http/Controllers/Web/Operator/BatchController.php +++ b/backend/app/Http/Controllers/Web/Operator/BatchController.php @@ -495,7 +495,7 @@ public function store(Request $request) ); return redirect()->route('operator.work-order.detail', $workOrder) - ->with('success', 'Batch created'.($lotNumber ? " (LOT: {$lotNumber})" : '')); + ->with('success', $lotNumber ? __('Batch created (LOT: :lot)', ['lot' => $lotNumber]) : __('Batch created')); } catch (\Exception $e) { return back()->with('error', 'Failed to create batch: '.$e->getMessage())->withInput(); } diff --git a/backend/app/Http/Controllers/Web/Operator/LineController.php b/backend/app/Http/Controllers/Web/Operator/LineController.php index 2657c2f78..8696e6a23 100644 --- a/backend/app/Http/Controllers/Web/Operator/LineController.php +++ b/backend/app/Http/Controllers/Web/Operator/LineController.php @@ -24,9 +24,10 @@ public function index(Request $request) $request->session()->put('selected_line_id', $lineId); $request->session()->put('selected_workstation_id', $workstation->id); $line = Line::find($lineId); - $defaultView = $line?->default_operator_view ?? 'queue'; + $screens = app(\App\Services\Production\OperatorScreens::class); - return redirect()->route($defaultView === 'workstation' ? 'operator.workstation' : 'operator.queue'); + // A packing bench opens on packing, an assembly bench on its queue. + return redirect()->route($screens->landingRoute($screens->for($user, $workstation), $line?->default_operator_view ?? 'queue')); } } @@ -80,9 +81,16 @@ public function select(Request $request) $request->session()->put('selected_workstation_id', $workstationId); $line = Line::find($lineId); - $defaultView = $line?->default_operator_view ?? 'queue'; - $route = $defaultView === 'workstation' ? 'operator.workstation' : 'operator.queue'; + // The bench's first screen: a packing bench opens on packing, an assembly + // bench on the line's default production view. + $screens = app(\App\Services\Production\OperatorScreens::class); + $route = $screens->landingRoute( + $screens->for($request->user(), $workstationId ? \App\Models\Workstation::find($workstationId) : null), + $line?->default_operator_view ?? 'queue', + ); - return redirect()->route($route); + // The choice rides in the address, so the page can be bookmarked or + // shared and opens on this line and bench. + return redirect()->route($route, ['line' => $lineId, 'workstation' => $workstationId ?: 'all']); } } diff --git a/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php b/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php new file mode 100644 index 000000000..0e61cb840 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php @@ -0,0 +1,530 @@ +resolveLine($request); + if (! $lineId) { + return redirect()->route('operator.select-line'); + } + + $line = Line::findOrFail($lineId); + $selectedWorkstation = app(OperatorWorkstationSelection::class)->resolve($request, (int) $lineId); + + $orders = WorkOrder::whereIn('status', ['IN_PROGRESS', 'PENDING', 'ACCEPTED']) + ->where('line_id', $line->id) + ->with(['productType:id,name', 'bomTemplates:id']) + ->orderByDesc('id') + ->limit(100) + ->get(); + $produced = self::producedSerialMaterials($orders); + $workOrders = $orders + ->map(fn (WorkOrder $wo) => [ + 'id' => $wo->id, + 'order_no' => $wo->order_no, + 'product' => $wo->productType?->name ?? '', + 'status' => $wo->status, + // Serial-tracked sub-assemblies this order makes: registered by their own SN here. + 'produces' => ($produced[$wo->id] ?? collect()) + ->map(fn (Material $m) => ['id' => $m->id, 'code' => $m->code, 'name' => $m->name])->values(), + ]); + + // Materials a component can be booked as - the tracked ones first, since + // those are what gets scanned; a plant tracking nothing sees them all. + $materials = Material::query()->where('is_active', true) + ->orderByRaw("CASE WHEN tracking_type = 'none' THEN 1 ELSE 0 END")->orderBy('name') + ->limit(300)->get(['id', 'code', 'name', 'tracking_type']) + ->map(fn (Material $m) => ['id' => $m->id, 'code' => $m->code, 'name' => $m->name]); + + return Inertia::render('operator/unit-labels/Station', [ + 'workOrders' => $workOrders, + 'materials' => $materials, + // Error codes for holding a non-conforming unit: the plant's scrap reasons. + 'holdReasons' => ScrapReason::active()->ordered()->get(['id', 'code', 'name', 'category']) + ->map(fn (ScrapReason $r) => ['id' => $r->id, 'code' => $r->code, 'name' => $r->name])->values(), + 'canUnblock' => $request->user()->hasAnyRole(['Supervisor', 'Admin']), + // What this bench's operator does here (the first bench starts units, the labelling bench binds ...). + 'labelActions' => UnitLabelActions::for($request->user(), $selectedWorkstation), + 'line' => $line->only(['id', 'code', 'name']), + 'selectedWorkstation' => $selectedWorkstation?->only(['id', 'code', 'name']), + ]); + } + + /** + * Bind a scanned serial number to its process serial number - the label + * going onto the unit. The rules (formats, whether the process serial is + * required or unique, who may re-bind) are the plant's settings; the + * service applies them and explains a refusal. + */ + public function apply(BindUnitLabelRequest $request): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::LABEL)) { + return $denied; + } + $data = $request->validated(); + $user = $request->user(); + + // Re-binding a unit that already carries another process serial is a + // supervisor's call; an operator's `force` is ignored, not trusted. + $force = ! empty($data['force']) && $user->hasRole(['Supervisor', 'Admin']); + + try { + $bound = $this->serials->bindProcessSerial($data['serial_no'], $data['psn'] ?? null, $user, [ + 'work_order_id' => $data['work_order_id'] ?? null, + 'workstation_id' => $request->session()->get('selected_workstation_id') ?: $user->workstation_id, + 'force' => $force, + 'reason' => $data['reason'] ?? null, + ]); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage(), 'rebindable' => $e->rebindable], 422); + } + + return response()->json([ + 'created' => $bound['created'], + 'rebound' => $bound['rebound'], + 'unit' => $this->payload($bound['unit']), + 'message' => match (true) { + $bound['serial_assigned'] ?? false => __('Serial number :sn assigned to unit :psn.', ['sn' => $bound['unit']->serial_no, 'psn' => $bound['unit']->psn]), + $bound['created'] => __('Unit registered, process serial bound to serial number.'), + $bound['rebound'] => __('Process serial re-bound.'), + default => __('Unit already registered, label binding confirmed.'), + }, + ]); + } + + /** + * Issue the next process or unit serial from the sequence configured for + * the work order's product (Admin -> LOT sequences, "Numbers"). This is the + * "create and print the process serial" step for lines that do not receive + * pre-printed labels; the number comes back to the field the way a scan + * would, and is validated the same way when the binding is applied. + */ + public function issue(IssueUnitIdentifierRequest $request, LotService $lots): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::ISSUE)) { + return $denied; + } + $data = $request->validated(); + $workOrder = isset($data['work_order_id']) ? WorkOrder::find($data['work_order_id']) : null; + $productType = $workOrder?->productType; + + try { + $identifier = $lots->generate($productType, $data['purpose']); + } catch (\RuntimeException) { + $kind = $data['purpose'] === LotSequence::PURPOSE_PROCESS_SERIAL ? __('process serial') : __('unit serial'); + + return response()->json([ + // Without a work order there is no product, so only a global + // sequence could answer - say which of the two is missing. + 'message' => match (true) { + $productType !== null => __('Product :product has no :kind sequence. Add one under LOT sequences ("Numbers").', ['product' => $productType->name, 'kind' => $kind]), + $workOrder !== null => __('Order :order has no product type, so no :kind sequence applies. Give the order a product, or add a sequence without a product type to serve every order.', ['order' => $workOrder->order_no, 'kind' => $kind]), + default => __('Select a work order first - the :kind sequence belongs to its product. Or add a sequence without a product type to serve every order.', ['kind' => $kind]), + }, + ], 422); + } + + return response()->json(['identifier' => $identifier, 'purpose' => $data['purpose']]); + } + + /** + * Start a unit on its process serial - the line's first station, before + * the product label exists. The PSN comes from a scanned label or, left + * empty, from the product's sequence; the unit is registered on the order + * and its PSN label is ready to print. + */ + public function start(StartUnitRequest $request, LotService $lots): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::START)) { + return $denied; + } + $data = $request->validated(); + $workOrder = isset($data['work_order_id']) ? WorkOrder::find($data['work_order_id']) : null; + $psn = $data['psn'] ?? null; + if ($psn === null) { + try { + $psn = $lots->generate($workOrder?->productType, LotSequence::PURPOSE_PROCESS_SERIAL); + } catch (\RuntimeException) { + return response()->json(['message' => __('Product :product has no :kind sequence. Add one under LOT sequences ("Numbers").', ['product' => $workOrder?->productType?->name ?? $workOrder?->order_no, 'kind' => __('process serial')])], 422); + } + } + + try { + $unit = $this->serials->startUnit($psn, $request->user(), [ + 'work_order_id' => $workOrder?->id, + 'workstation_id' => $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ]); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'unit' => $this->payload($unit->load('workOrder:id,order_no')), + 'label_pdf' => route('packaging.labels.serial-unit.pdf', $unit), + 'label_zpl' => route('packaging.labels.serial-unit.zpl', $unit), + 'message' => __('Unit :psn started - print its label.', ['psn' => $unit->psn]), + ], 201); + } + + /** + * Register a sub-assembly the order makes by its own serial number, so the + * product it goes into is later linked to it (and its history). + */ + public function subassembly(RegisterSubassemblyRequest $request): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::SUBASSEMBLY)) { + return $denied; + } + $workOrder = WorkOrder::findOrFail($request->integer('work_order_id')); + $material = Material::findOrFail($request->integer('material_id')); + if (! (self::producedSerialMaterials(new \Illuminate\Database\Eloquent\Collection([$workOrder]))[$workOrder->id] ?? collect())->contains('id', $material->id)) { + return response()->json(['message' => __('Order :order does not make :material.', ['order' => $workOrder->order_no, 'material' => $material->name])], 422); + } + + try { + $unit = $this->serials->registerSubassembly( + $request->validated('serial_no'), $material, $workOrder, $request->user(), + $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'unit' => $this->payload($unit->load('workOrder:id,order_no')), + 'message' => __('Sub-assembly :sn registered as :material.', ['sn' => $unit->serial_no, 'material' => $material->name]), + ], 201); + } + + /** + * The serial-tracked materials each order makes: those whose producing + * routing is one the order runs (its BOM selection, else its product's + * active routings). One query for the lot. + * + * @param \Illuminate\Database\Eloquent\Collection $orders + * @return array> order id => materials + */ + private static function producedSerialMaterials(\Illuminate\Database\Eloquent\Collection $orders): array + { + $orders->loadMissing('bomTemplates:id'); + $fallback = \App\Models\ProcessTemplate::whereIn('product_type_id', $orders->filter(fn ($wo) => $wo->bomTemplates->isEmpty())->pluck('product_type_id')->filter()->unique()) + ->where('is_active', true)->get(['id', 'product_type_id'])->groupBy('product_type_id'); + $templatesOf = $orders->mapWithKeys(fn (WorkOrder $wo) => [$wo->id => $wo->bomTemplates->isNotEmpty() + ? $wo->bomTemplates->pluck('id') + : ($fallback[$wo->product_type_id] ?? collect())->pluck('id')]); + + $materials = Material::query() + ->whereIn('producing_process_template_id', $templatesOf->flatten()->unique()->values()) + ->where('tracking_type', 'serial')->where('is_active', true) + ->orderBy('name')->get(['id', 'code', 'name', 'producing_process_template_id']); + + return $templatesOf->map(fn ($ids) => $materials->whereIn('producing_process_template_id', $ids->all())->values())->all(); + } + + /** Hold a non-conforming unit with an error code; a supervisor releases it. */ + public function block(BlockUnitRequest $request, SerialUnit $serialUnit): JsonResponse + { + try { + $unit = $this->serials->blockUnit( + $serialUnit, + $request->user(), + ScrapReason::findOrFail($request->validated('scrap_reason_id')), + $request->validated('note'), + $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['unit' => $this->payload($unit), 'message' => __('Unit :sn blocked.', ['sn' => $unit->display_id])]); + } + + /** Release a held unit - supervisors and admins, with the reason. */ + public function unblock(UnblockUnitRequest $request, SerialUnit $serialUnit): JsonResponse + { + try { + $unit = $this->serials->unblockUnit( + $serialUnit, + $request->user(), + $request->validated('note'), + $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['unit' => $this->payload($unit), 'message' => __('Unit :sn released.', ['sn' => $unit->display_id])]); + } + + /** Scrap a unit from the station - with a reason, recorded in its history. */ + public function scrap(ScrapUnitRequest $request, SerialUnit $serialUnit): JsonResponse + { + try { + $unit = $this->serials->scrapUnit( + $serialUnit, + $request->user(), + $request->validated('reason'), + $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['unit' => $this->payload($unit), 'message' => __('Unit :sn scrapped.', ['sn' => $unit->display_id])]); + } + + /** + * Scan a component onto a unit: the parent's serial, then the component's + * own label. What the component turns out to be (a sub-assembly serial, a + * lot, a bare vendor serial) is the service's job to work out. + */ + public function component(BindUnitComponentRequest $request): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::COMPONENTS)) { + return $denied; + } + $data = $request->validated(); + // The unit by its SN, or by its PSN while the SN is not on it yet. + $unit = SerialUnit::findByIdentifier($data['serial_no']); + if (! $unit) { + return response()->json(['message' => __('Unknown unit :sn - start it or apply its label first.', ['sn' => $data['serial_no']])], 404); + } + + try { + $component = $this->serials->bindComponent($unit, $data['identifier'], $request->user(), [ + 'material_id' => $data['material_id'] ?? null, + 'quantity' => $data['quantity'] ?? 1, + 'batch_step_id' => $data['batch_step_id'] ?? null, + 'workstation_id' => $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + ]); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'component' => $this->componentPayload($component->load('material:id,code,name')), + 'unit' => $this->payload($unit->fresh()), + 'message' => __('Component :id bound to unit :sn.', ['id' => $component->identifier, 'sn' => $unit->display_id]), + ]); + } + + /** Take a component out of a unit (a repair, a wrong scan) - the reason stays on the unit's history. */ + public function unbindComponent(UnbindUnitComponentRequest $request, SerialUnitComponent $component): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::COMPONENTS)) { + return $denied; + } + try { + $component = $this->serials->unbindComponent($component, $request->user(), $request->validated('reason'), + $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + $unit = $component->unit; + + return response()->json([ + 'unit' => $this->payload($unit), + 'components' => $unit->components()->installed()->with('material:id,code,name')->orderByDesc('bound_at')->get()->map(fn ($c) => $this->componentPayload($c)), + 'message' => __('Component :id removed from unit :sn.', ['id' => $component->identifier, 'sn' => $unit->display_id]), + ]); + } + + /** + * Issue a batch of numbers ahead of production - the units are registered + * on the order with their serial (and process serial, when the product has + * that sequence) so the labels can be printed now and applied at the line + * by scanning them. + */ + public function issueBatch(IssueUnitBatchRequest $request, LotService $lots): JsonResponse + { + if ($denied = $this->unlessAllowed($request, UnitLabelActions::ISSUE)) { + return $denied; + } + $data = $request->validated(); + $workOrder = WorkOrder::findOrFail($data['work_order_id']); + $productType = $workOrder->productType; + $settings = app(UnitSerialisation::class); + + // Without a unit-serial sequence there is nothing to print; a missing + // process-serial sequence only matters where the plant requires PSNs. + // PSN only: the units start on their process serial, the SN comes later + // with the product label. + $psnOnly = (bool) ($data['psn_only'] ?? false); + $withPsn = $lots->previewNext($productType, LotSequence::PURPOSE_PROCESS_SERIAL) !== null; + $missing = match (true) { + $psnOnly && ! $withPsn => __('process serial'), + ! $psnOnly && $lots->previewNext($productType, LotSequence::PURPOSE_UNIT_SERIAL) === null => __('unit serial'), + ! $withPsn && $settings->get('unit_psn_required') => __('process serial'), + default => null, + }; + if ($missing !== null) { + return response()->json(['message' => __('Product :product has no :kind sequence. Add one under LOT sequences ("Numbers").', ['product' => $productType?->name ?? $workOrder->order_no, 'kind' => $missing])], 422); + } + + $workstationId = $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id; + try { + $units = DB::transaction(function () use ($data, $workOrder, $productType, $lots, $withPsn, $psnOnly, $request, $workstationId, $settings) { + $made = []; + for ($i = 0; $i < $data['quantity']; $i++) { + if ($psnOnly) { + $made[] = $this->serials->startUnit($lots->generate($productType, LotSequence::PURPOSE_PROCESS_SERIAL), $request->user(), [ + 'work_order_id' => $workOrder->id, + 'workstation_id' => $workstationId, + ]); + + continue; + } + // Stored the way a scan of the printed label will arrive (the + // pattern may space its groups for the eye; the scan drops them). + $serialNo = $settings->normalize($lots->generate($productType, LotSequence::PURPOSE_UNIT_SERIAL)); + // A sequence that hands out a number already on a product + // (a reset counter, a pattern without a date) must not quietly + // re-label that product: the whole batch is refused. + if (SerialUnit::where('serial_no', $serialNo)->exists()) { + throw new BindingException(__('Serial :sn already exists - check the sequence before issuing more numbers.', ['sn' => $serialNo])); + } + $unit = $this->serials->registerUnit($serialNo, [ + 'psn' => $withPsn ? $settings->normalize($lots->generate($productType, LotSequence::PURPOSE_PROCESS_SERIAL)) : null, + 'work_order_id' => $workOrder->id, + ]); + $this->serials->recordStep($unit, $request->user(), null, [ + 'workstation_id' => $workstationId, + 'parameters' => array_filter(['event' => 'issued', 'psn' => $unit->psn, 'batch_of' => $data['quantity']]), + ]); + $made[] = $unit; + } + + return $made; + }); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $ids = implode(',', array_map(fn (SerialUnit $u) => $u->id, $units)); + + return response()->json([ + 'units' => array_map(fn (SerialUnit $u) => ['id' => $u->id, 'serial_no' => $u->serial_no, 'psn' => $u->psn], $units), + 'count' => count($units), + 'label_pdf' => route('packaging.labels.serial-units.pdf', ['ids' => $ids]), + 'label_zpl' => route('packaging.labels.serial-units.zpl', ['ids' => $ids]), + 'message' => __(':count numbers issued for :order - print the labels.', ['count' => count($units), 'order' => $workOrder->order_no]), + ], 201); + } + + /** Components currently in a unit, for the station's list. */ + public function components(Request $request): JsonResponse + { + $unit = SerialUnit::findByIdentifier(app(\App\Support\UnitSerialisation::class)->normalize($request->query('serial_no'))); + + return response()->json([ + 'unit' => $unit ? $this->payload($unit) : null, + 'components' => $unit + ? $unit->components()->installed()->with('material:id,code,name')->orderByDesc('bound_at')->get()->map(fn ($c) => $this->componentPayload($c)) + : [], + ]); + } + + private function componentPayload(SerialUnitComponent $c): array + { + return [ + 'id' => $c->id, + 'identifier' => $c->identifier, + 'material' => $c->material ? ['id' => $c->material->id, 'code' => $c->material->code, 'name' => $c->material->name] : null, + 'kind' => $c->component_serial_unit_id ? 'serial_unit' : ($c->material_lot_id ? 'material_lot' : 'identifier'), + 'quantity' => (float) $c->quantity, + 'bound_at' => $c->bound_at?->toIso8601String(), + ]; + } + + /** + * Recent units for the station screen, optionally filtered by work order. + */ + public function units(Request $request): JsonResponse + { + $workOrderId = $request->integer('work_order_id') ?: null; + + $units = SerialUnit::query() + ->when($workOrderId, fn ($q) => $q->where('work_order_id', $workOrderId)) + ->with('workOrder:id,order_no') + ->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn (SerialUnit $u) => $this->payload($u)); + + return response()->json(['units' => $units]); + } + + private function payload(SerialUnit $unit): array + { + return [ + 'id' => $unit->id, + 'serial_no' => $unit->serial_no, + 'psn' => $unit->psn, + 'display_id' => $unit->display_id, + // Why the unit is held, when it is: the error code or the failed tests. + 'hold' => $unit->status === SerialUnit::STATUS_BLOCKED ? ($unit->extra_data['hold'] ?? ['source' => 'test']) : null, + 'status' => $unit->status, + 'work_order' => $unit->workOrder?->order_no, + 'applied_at' => $unit->extra_data['label_applied_at'] ?? null, + ]; + } + + /** + * The bench's label-station actions hold on the server too, not only in + * the screen: a bench pinned to "components" cannot start units by a + * hand-made request. Supervisors and admins keep every action. + */ + private function unlessAllowed(Request $request, string $action): ?JsonResponse + { + $benchId = $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id; + if (in_array($action, UnitLabelActions::for($request->user(), $benchId ? Workstation::find($benchId) : null), true)) { + return null; + } + + return response()->json(['message' => __('This action is not available at this workstation.')], 403); + } +} diff --git a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php index 472a47da2..aa51f10a7 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php @@ -27,21 +27,12 @@ public function __construct( */ public function queue(Request $request) { - $lineId = $request->session()->get('selected_line_id') - ?? $request->query('line'); - - // Workstation accounts auto-select their assigned line - if (! $lineId && auth()->user()->account_type === 'workstation') { - $lineId = auth()->user()->workstation?->line_id; - } + $lineId = app(OperatorWorkstationSelection::class)->resolveLine($request); if (! $lineId) { return redirect()->route('operator.select-line'); } - // Persist in session for subsequent requests - $request->session()->put('selected_line_id', $lineId); - // Get active and completed work orders for this line $activeWorkOrders = WorkOrder::availableForProduction()->where('line_id', $lineId) ->whereIn('status', WorkOrder::ACTIVE_STATUSES) @@ -108,7 +99,14 @@ public function queue(Request $request) ->latest('started_at') ->first(); + // The operator's number inputs, when a module replaces them (a keypad). + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + ['display.operator.quantity_field'], + ['lineId' => (int) $lineId, 'workstationId' => $selectedWorkstation?->id], + ); + return Inertia::render('operator/Queue', compact( + 'hooks', 'activeWorkOrders', 'completedWorkOrders', 'line', 'selectedWorkstation', 'lineStatuses', 'issueTypes', 'workflowMode', 'doneStatusIds', 'trackingMode', 'workstationQueue', 'workstationNotStarted', 'lineWorkstations', @@ -214,7 +212,7 @@ public function check(Request $request) */ public function show(Request $request, WorkOrder $workOrder) { - $lineId = $request->session()->get('selected_line_id'); + $lineId = app(OperatorWorkstationSelection::class)->resolveLine($request); // Verify work order belongs to selected line if ($workOrder->line_id != $lineId) { @@ -408,6 +406,57 @@ public function show(Request $request, WorkOrder $workOrder) ->resolve($request, (int) $workOrder->line_id, allowOtherLines: (bool) json_decode(\Illuminate\Support\Facades\DB::table('system_settings')->where('key', 'workstation_routing_enabled')->value('value') ?? 'false', true)); $selectedWorkstation = $selectedWorkstation?->only(['id', 'name', 'code']); - return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'stepOutputs', 'issueCustomFields', 'engineeringDocuments', 'materialShortages', 'flowMode', 'selectedWorkstation')); + // Regions an installed module may contribute to: extra sections after the + // BOM, and a replacement for the operator's number inputs. `{}` when none. + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + ['display.operator.work_order.sections', 'display.operator.quantity_field'], + ['workOrderId' => $workOrder->id, 'workstationId' => $selectedWorkstation['id'] ?? null], + ); + + // The order's serialised units - PSN, serial number, status and where each + // was last seen - so the order shows which pieces it is made of, not only counts. + $serialUnits = $this->serialUnitsOf($workOrder); + + return Inertia::render('operator/WorkOrderDetail', compact('hooks', 'workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'stepOutputs', 'issueCustomFields', 'engineeringDocuments', 'materialShortages', 'flowMode', 'selectedWorkstation', 'serialUnits')); + } + + /** + * The order's serial units, newest first (at most 500), each with its last + * history row: two queries whatever the count. + * + * @return array{total: int, units: array>} + */ + private function serialUnitsOf(WorkOrder $workOrder): array + { + $query = \App\Models\SerialUnit::where('work_order_id', $workOrder->id); + $total = (clone $query)->count(); + if ($total === 0) { + return ['total' => 0, 'units' => []]; + } + $units = $query->with(['carton:id,carton_no', 'pallet:id,pallet_no', 'material:id,code,name']) + ->orderByDesc('id')->limit(500)->get(); + $lastIds = \App\Models\UnitStepHistory::whereIn('serial_unit_id', $units->pluck('id')) + ->selectRaw('MAX(id) as id')->groupBy('serial_unit_id')->pluck('id'); + $last = \App\Models\UnitStepHistory::whereIn('id', $lastIds)->with('workstation:id,code,name')->get()->keyBy('serial_unit_id'); + + return ['total' => $total, 'units' => $units->map(function (\App\Models\SerialUnit $u) use ($last) { + $h = $last->get($u->id); + + return [ + 'id' => $u->id, + 'serial_no' => $u->serial_no, + 'psn' => $u->psn, + 'status' => $u->status, + 'material' => $u->material?->name, + 'carton' => $u->carton?->carton_no, + 'pallet' => $u->pallet?->pallet_no, + 'last' => $h ? [ + 'parameters' => $h->parameters, + 'result' => $h->result, + 'workstation' => $h->workstation?->name, + 'at' => $h->processed_at?->toIso8601String(), + ] : null, + ]; + })->values()->all()]; } } diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index 53390fbdf..36c41e818 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\SetWorkstationStateRequest; +use App\Models\Batch; use App\Models\IssueType; use App\Models\Line; use App\Models\Shift; @@ -18,24 +19,26 @@ class WorkstationController extends Controller { + /** Where a module may contribute to the operator's station screen. */ + private const STATION_HOOK = 'display.operator.workstation.actor'; + + /** Replaces a whole shift cell (quantity input + correction link). */ + private const SHIFT_CELL_HOOK = 'display.operator.workstation.shift_cell'; + + /** Replaces every operator number input on the page. */ + private const QUANTITY_FIELD_HOOK = 'display.operator.quantity_field'; + /** * Workstation production view — flat table with inline quantity entry. */ public function index(Request $request) { - $lineId = $request->session()->get('selected_line_id') - ?? $request->query('line'); - - if (! $lineId && auth()->user()->account_type === 'workstation') { - $lineId = auth()->user()->workstation?->line_id; - } + $lineId = app(OperatorWorkstationSelection::class)->resolveLine($request); if (! $lineId) { return redirect()->route('operator.select-line'); } - $request->session()->put('selected_line_id', $lineId); - $line = Line::with(['viewColumns', 'viewTemplate'])->findOrFail($lineId); $query = WorkOrder::availableForProduction()->where('line_id', $lineId) @@ -92,9 +95,22 @@ public function index(Request $request) } } - $workOrders->loadMissing('batches.steps'); + $workOrders->loadMissing('batches.steps.workstation'); foreach ($workOrders as $order) { $targets = collect(); + // The routing graph of the batch being worked (the first one not + // finished, else the last): step status per node, for the row's + // fold-out graph. + $current = $order->batches->first(fn ($b) => $b->status !== Batch::STATUS_DONE) ?? $order->batches->last(); + $order->setAttribute('routing', $order->uses_step_ledger && $current ? $current->steps->sortBy('step_number')->values()->map(fn ($step) => [ + 'id' => $step->id, + 'batch_id' => $current->id, + 'step_number' => $step->step_number, + 'name' => $step->name, + 'status' => $step->status, + 'workstation_id' => $step->workstation_id, + 'workstation' => $step->workstation ? ['id' => $step->workstation->id, 'name' => $step->workstation->name] : null, + ])->all() : []); if ($order->uses_step_ledger && $order->counting_source !== 'machine') { foreach ($order->batches as $batch) { $batch->setRelation('workOrder', $order); @@ -142,7 +158,25 @@ public function index(Request $request) $machineStates = $this->machineStatesForLine((int) $lineId, $selectedWorkstation?->id); $machineStateOptions = WorkstationState::STATES; + // A region an installed module may contribute to — the station is where + // a module identifying the person at the machine has something to say, + // and a module cannot ship its own React into a released install. + // Empty on a community install, where the prop is `{}`. + // + // The shift cell and quantity field points REPLACE a core control when a + // module contributes; the page checks hasHook() and otherwise draws its own. + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + [self::STATION_HOOK, self::SHIFT_CELL_HOOK, self::QUANTITY_FIELD_HOOK], + [ + 'line' => $line, + 'workstation' => $selectedWorkstation, + 'lineId' => (int) $lineId, + 'workstationId' => $selectedWorkstation?->id, + ], + ); + return Inertia::render('operator/Workstation', compact( + 'hooks', 'workOrders', 'line', 'availableWeeks', 'weekFilter', 'search', 'issueTypes', 'allColumns', 'shifts', 'shiftEntries', 'today', 'trackingMode', 'qtyEditPolicy', 'qtyEditWindowMinutes', 'labelTemplates', diff --git a/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php b/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php index 80f17a515..299fdddb2 100644 --- a/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php +++ b/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php @@ -7,9 +7,13 @@ use App\Models\Batch; use App\Models\BatchStep; use App\Models\LabelTemplate; +use App\Models\MaterialLot; use App\Models\Pallet; +use App\Models\SerialUnit; +use App\Models\UnitCarton; use App\Models\WorkOrder; use App\Services\Packaging\LabelGenerator; +use App\Support\DownloadName; use Illuminate\Http\Request; class LabelPrintController extends Controller @@ -21,7 +25,7 @@ public function workOrderPdf(Request $request, WorkOrder $workOrder) $template = $this->resolveTemplate($request, LabelTemplate::TYPE_WORK_ORDER); $pdf = $this->generator->pdfForWorkOrders(collect([$workOrder]), $template); - return $pdf->stream("label-wo-{$workOrder->order_no}.pdf"); + return $pdf->stream('label-wo-'.DownloadName::safe($workOrder->order_no).'.pdf'); } public function workOrderZpl(Request $request, WorkOrder $workOrder) @@ -31,7 +35,7 @@ public function workOrderZpl(Request $request, WorkOrder $workOrder) return response($zpl, 200, [ 'Content-Type' => 'application/zpl', - 'Content-Disposition' => "attachment; filename=label-wo-{$workOrder->order_no}.zpl", + 'Content-Disposition' => 'attachment; filename=label-wo-'.DownloadName::safe($workOrder->order_no).'.zpl', ]); } @@ -39,7 +43,7 @@ public function finishedGoodsPdf(Request $request, Batch $batch) { $template = $this->resolveTemplate($request, LabelTemplate::TYPE_FINISHED_GOODS); $pdf = $this->generator->pdfForFinishedGoods(collect([$batch]), $template); - $label = $batch->lot_number ?: 'batch-'.$batch->id; + $label = DownloadName::safe($batch->lot_number ?: 'batch-'.$batch->id); return $pdf->stream("label-fg-{$label}.pdf"); } @@ -48,7 +52,7 @@ public function finishedGoodsZpl(Request $request, Batch $batch) { $template = $this->resolveTemplate($request, LabelTemplate::TYPE_FINISHED_GOODS); $zpl = $this->generator->zplForFinishedGoods(collect([$batch]), $template); - $label = $batch->lot_number ?: 'batch-'.$batch->id; + $label = DownloadName::safe($batch->lot_number ?: 'batch-'.$batch->id); return response($zpl, 200, [ 'Content-Type' => 'application/zpl', @@ -80,7 +84,15 @@ public function palletPdf(Request $request, Pallet $pallet) $template = $this->resolveTemplate($request, LabelTemplate::TYPE_PALLET); $pdf = $this->generator->pdfForPallets(collect([$pallet]), $template); - return $pdf->stream("label-pallet-{$pallet->pallet_no}.pdf"); + return $pdf->stream('label-pallet-'.DownloadName::safe($pallet->pallet_no).'.pdf'); + } + + public function cartonPdf(Request $request, UnitCarton $carton) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_CARTON); + $pdf = $this->generator->pdfForCartons(collect([$carton]), $template); + + return $pdf->stream('label-carton-'.DownloadName::safe($carton->carton_no).'.pdf'); } public function palletZpl(Request $request, Pallet $pallet) @@ -90,7 +102,93 @@ public function palletZpl(Request $request, Pallet $pallet) return response($zpl, 200, [ 'Content-Type' => 'application/zpl', - 'Content-Disposition' => "attachment; filename=label-pallet-{$pallet->pallet_no}.zpl", + 'Content-Disposition' => 'attachment; filename=label-pallet-'.DownloadName::safe($pallet->pallet_no).'.zpl', + ]); + } + + public function serialUnitPdf(Request $request, SerialUnit $serialUnit) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + $pdf = $this->generator->pdfForSerialUnits(collect([$serialUnit]), $template); + + return $pdf->stream('label-unit-'.$this->unitFileName($serialUnit).'.pdf'); + } + + /** A set of unit labels in one file - the batch just issued ahead of production. */ + public function serialUnitsPdf(Request $request) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + + return $this->generator->pdfForSerialUnits($this->unitsFromIds($request), $template)->stream('labels-serial-units-'.date('Ymd-His').'.pdf'); + } + + public function serialUnitsZpl(Request $request) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + + return response($this->generator->zplForSerialUnits($this->unitsFromIds($request), $template), 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => 'attachment; filename=labels-serial-units-'.date('Ymd-His').'.zpl', + ]); + } + + /** `ids=1,2,3` (or ids[]=…), at most 500, kept in the order given. */ + private function unitsFromIds(Request $request) + { + $ids = collect(is_array($request->query('ids')) ? $request->query('ids') : explode(',', (string) $request->query('ids', ''))) + ->map(fn ($v) => (int) trim((string) $v))->filter(fn ($v) => $v > 0)->unique()->take(500)->values(); + abort_if($ids->isEmpty(), 422, __('No units given.')); + $units = SerialUnit::whereIn('id', $ids)->get()->keyBy('id'); + + return $ids->map(fn ($id) => $units->get($id))->filter()->values(); + } + + public function serialUnitZpl(Request $request, SerialUnit $serialUnit) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + $zpl = $this->generator->zplForSerialUnits(collect([$serialUnit]), $template); + + return response($zpl, 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => 'attachment; filename=label-unit-'.$this->unitFileName($serialUnit).'.zpl', + ]); + } + + public function materialLotPdf(Request $request, MaterialLot $materialLot) + { + $template = $this->materialLotTemplate($request); + + return $this->generator->pdfForMaterialLots(collect([$materialLot]), $template)->stream('label-iqc-'.DownloadName::safe($materialLot->lot_number).'.pdf'); + } + + public function materialLotZpl(Request $request, MaterialLot $materialLot) + { + $template = $this->materialLotTemplate($request); + + return response($this->generator->zplForMaterialLots(collect([$materialLot]), $template), 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => 'attachment; filename=label-iqc-'.DownloadName::safe($materialLot->lot_number).'.zpl', + ]); + } + + /** + * The plant's IQC template, else the built-in layout - an install that + * predates the label type prints without first configuring one. + */ + private function materialLotTemplate(Request $request): LabelTemplate + { + $chosen = $request->integer('template') ? LabelTemplate::find($request->integer('template')) : null; + if ($chosen && $chosen->type === LabelTemplate::TYPE_MATERIAL_LOT) { + return $chosen; + } + + return LabelTemplate::defaultFor(LabelTemplate::TYPE_MATERIAL_LOT) ?? new LabelTemplate([ + 'name' => 'Material lot (IQC)', + 'type' => LabelTemplate::TYPE_MATERIAL_LOT, + 'size' => '100x50', + 'fields_config' => LabelTemplate::defaultFieldsFor(LabelTemplate::TYPE_MATERIAL_LOT), + 'barcode_format' => 'code128', + 'is_active' => true, ]); } @@ -109,6 +207,7 @@ public function printMultiple(PrintMultipleLabelsRequest $request) LabelTemplate::TYPE_FINISHED_GOODS => $this->multiFinishedGoods($validated['ids'], $template, $validated['format']), LabelTemplate::TYPE_WORKSTATION_STEP => $this->multiBatchSteps($validated['ids'], $template, $validated['format']), LabelTemplate::TYPE_PALLET => $this->multiPallets($validated['ids'], $template, $validated['format']), + LabelTemplate::TYPE_SERIAL_UNIT => $this->multiSerialUnits($validated['ids'], $template, $validated['format']), }; } @@ -127,6 +226,21 @@ private function multiPallets(array $ids, LabelTemplate $template, string $forma return $this->generator->pdfForPallets($pallets, $template)->stream("{$filename}.pdf"); } + private function multiSerialUnits(array $ids, LabelTemplate $template, string $format) + { + $units = SerialUnit::whereIn('id', $ids)->get(); + $filename = 'labels-serial-units-'.date('Ymd-His'); + + if ($format === 'zpl') { + return response($this->generator->zplForSerialUnits($units, $template), 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => "attachment; filename={$filename}.zpl", + ]); + } + + return $this->generator->pdfForSerialUnits($units, $template)->stream("{$filename}.pdf"); + } + private function multiWorkOrders(array $ids, LabelTemplate $template, string $format) { $workOrders = WorkOrder::whereIn('id', $ids)->get(); @@ -172,6 +286,11 @@ private function multiBatchSteps(array $ids, LabelTemplate $template, string $fo return $this->generator->pdfForBatchSteps($steps, $template)->stream("{$filename}.pdf"); } + private function unitFileName(SerialUnit $unit): string + { + return DownloadName::safe($unit->display_id, 'unit'); + } + private function resolveTemplate(Request $request, string $type): LabelTemplate { if ($id = $request->integer('template')) { diff --git a/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php b/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php index ea397e308..36a0b787d 100644 --- a/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php +++ b/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php @@ -2,37 +2,49 @@ namespace App\Http\Controllers\Web\Packaging; +use App\Http\Controllers\Concerns\StaysOnList; use App\Http\Controllers\Controller; use App\Models\LabelTemplate; use Illuminate\Http\Request; use Illuminate\Validation\Rule; use Inertia\Inertia; +/** Label templates: a list that creates and edits in a drawer, like the other admin lists. */ class LabelTemplateController extends Controller { - public function index() + use StaysOnList; + + public function index(Request $request) { + // /create and /{id}/edit land here as ?create=1 / ?edit={id}: the page + // opens its drawer the same way a row's Edit or "New template" does. + $editTemplate = $request->integer('edit') + ? LabelTemplate::find($request->integer('edit'))?->only('id', 'name', 'type', 'size', 'barcode_format', 'fields_config', 'is_default', 'is_active') + : null; + return Inertia::render('packaging/label-templates/Index', [ 'typeLabels' => LabelTemplate::TYPES, + 'editTemplate' => $editTemplate, + 'openCreate' => $request->boolean('create'), + // The drawer's option lists, loaded on its first opening (partial + // reload) rather than with every render of the list. + 'types' => Inertia::optional(fn () => LabelTemplate::TYPES), + 'sizes' => Inertia::optional(fn () => LabelTemplate::SIZES), + 'barcodeFormats' => Inertia::optional(fn () => LabelTemplate::BARCODE_FORMATS), + 'availableFields' => Inertia::optional(fn () => LabelTemplate::AVAILABLE_FIELDS), + 'fieldsByType' => Inertia::optional(fn () => collect(array_keys(LabelTemplate::TYPES)) + ->mapWithKeys(fn ($type) => [$type => LabelTemplate::fieldsForType($type)]) + ->all()), + // Which fields a fresh template of each type starts with. + 'defaultFieldsByType' => Inertia::optional(fn () => collect(array_keys(LabelTemplate::TYPES)) + ->mapWithKeys(fn ($type) => [$type => LabelTemplate::defaultFieldsFor($type)]) + ->all()), ]); } - /** Option maps shared by the create/edit forms. */ - private function formData(): array - { - return [ - 'types' => LabelTemplate::TYPES, - 'sizes' => LabelTemplate::SIZES, - 'barcodeFormats' => LabelTemplate::BARCODE_FORMATS, - 'availableFields' => LabelTemplate::AVAILABLE_FIELDS, - ]; - } - public function create() { - return Inertia::render('packaging/label-templates/Create', array_merge($this->formData(), [ - 'defaultFields' => LabelTemplate::defaultFieldsFor(LabelTemplate::TYPE_WORK_ORDER), - ])); + return redirect()->route('packaging.label-templates.index', ['create' => 1]); } public function store(Request $request) @@ -45,15 +57,12 @@ public function store(Request $request) $this->ensureSingleDefault($template); } - return redirect()->route('packaging.label-templates.index') - ->with('success', __('Label template created.')); + return $this->saved($request, redirect()->route('packaging.label-templates.index'), __('Label template created.')); } public function edit(LabelTemplate $labelTemplate) { - return Inertia::render('packaging/label-templates/Edit', array_merge($this->formData(), [ - 'template' => $labelTemplate->only('id', 'name', 'type', 'size', 'barcode_format', 'fields_config', 'is_default', 'is_active'), - ])); + return redirect()->route('packaging.label-templates.index', ['edit' => $labelTemplate->id]); } public function update(Request $request, LabelTemplate $labelTemplate) @@ -66,8 +75,7 @@ public function update(Request $request, LabelTemplate $labelTemplate) $this->ensureSingleDefault($labelTemplate); } - return redirect()->route('packaging.label-templates.index') - ->with('success', __('Label template updated.')); + return $this->saved($request, redirect()->route('packaging.label-templates.index'), __('Label template updated.')); } public function destroy(LabelTemplate $labelTemplate) diff --git a/backend/app/Http/Controllers/Web/Packaging/LabelTemplatePreviewController.php b/backend/app/Http/Controllers/Web/Packaging/LabelTemplatePreviewController.php new file mode 100644 index 000000000..1d8c7728d --- /dev/null +++ b/backend/app/Http/Controllers/Web/Packaging/LabelTemplatePreviewController.php @@ -0,0 +1,42 @@ +generator->pdfPreview($labelTemplate)->stream("label-preview-{$labelTemplate->id}.pdf"); + } + + /** The form's current values, rendered without saving them. */ + public function draft(PreviewLabelTemplateRequest $request) + { + $data = $request->validated(); + $fields = []; + foreach (array_keys(LabelTemplate::AVAILABLE_FIELDS) as $key) { + $fields[$key] = filter_var($data['fields'][$key] ?? false, FILTER_VALIDATE_BOOLEAN); + } + $template = new LabelTemplate([ + 'name' => $data['name'] ?? __('Preview'), + 'type' => $data['type'], + 'size' => $data['size'], + 'barcode_format' => $data['barcode_format'], + 'fields_config' => $fields, + ]); + + return $this->generator->pdfPreview($template)->stream('label-preview.pdf'); + } +} diff --git a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php index 80c6ee4e5..3bef6c733 100644 --- a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php +++ b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php @@ -6,11 +6,22 @@ use App\Http\Controllers\Controller; use App\Http\Requests\CreatePalletStationRequest; use App\Http\Requests\PackagingScanRequest; +use App\Http\Requests\ScanUnitRequest; +use App\Http\Requests\StartPackingStepRequest; +use App\Models\BatchStep; +use App\Models\Line; use App\Models\PackagingScanLog; use App\Models\Pallet; +use App\Models\SerialUnit; +use App\Models\TemplateStep; +use App\Models\UnitCarton; use App\Models\WorkOrder; use App\Models\WorkOrderEan; +use App\Services\Material\MaterialAllocationService; +use App\Services\Packaging\UnitPackingService; +use App\Services\Production\OperatorWorkstationSelection; use App\Services\Production\PalletBackflushService; +use App\Services\Traceability\BindingException; use App\Support\ShiftWindow; use Carbon\Carbon; use Illuminate\Http\Request; @@ -19,10 +30,30 @@ class PackagingController extends Controller { + /** One packing service per request: its config cache then serves every pallet in a list. */ + private ?UnitPackingService $packingService = null; + + private function packing(): UnitPackingService + { + return $this->packingService ??= app(UnitPackingService::class); + } + + /** The bench this operator picked for the session, else their account's bench. */ + private static function benchId(Request $request): ?int + { + return ($request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id) ?: null; + } + // ── Views ───────────────────────────────────────────────────────────────── - public function station() + public function station(Request $request) { + // One screen, two addresses: operators live under /operator (with the + // line and bench in the query), supervisors and admins under /packaging. + if ($request->routeIs('packaging.station') && ! $request->user()->hasAnyRole(['Admin', 'Supervisor'])) { + return redirect()->route('operator.packaging', $request->query()); + } + // scannerMode (HID vs serial) merged from develop — passed as a prop so // the React Station page can read it. $scannerMode = json_decode( @@ -36,7 +67,18 @@ public function station() $currentShift = $this->currentShiftPayload(); - return Inertia::render('packaging/Station', compact('scannerMode', 'labelTemplates', 'currentShift')); + // The operator layout names the line in its header and shows its nav + // only with one; admins reach this page from the sidebar and need neither. + $selection = app(OperatorWorkstationSelection::class); + $lineId = $selection->resolveLine($request) ?: $request->user()->workstation?->line_id; + $line = $lineId ? Line::find($lineId)?->only(['id', 'code', 'name']) : null; + $selectedWorkstation = $lineId + ? $selection->resolve($request, (int) $lineId, allowOtherLines: true)?->only(['id', 'code', 'name']) + : null; + + $packingSteps = $this->packing()->openPackingSteps($selectedWorkstation['id'] ?? null); + + return Inertia::render('packaging/Station', compact('scannerMode', 'labelTemplates', 'currentShift', 'line', 'selectedWorkstation', 'packingSteps')); } public function adminOverview() @@ -49,9 +91,31 @@ public function adminOverview() // ── JSON API (polling) ──────────────────────────────────────────────────── + /** The packing steps this station works on (polled with the rest of the station's data). */ + public function packingSteps(Request $request, UnitPackingService $packing) + { + $workstationId = $request->session()->get('selected_workstation_id') ?: null; + + return response()->json(['steps' => $packing->openPackingSteps($workstationId)]); + } + public function items() { - return response()->json(['items' => $this->buildItemList()]); + return response()->json([ + 'items' => $this->buildItemList(), + // Every packable order, EAN or not: serialised goods are packed by + // process serial, so their orders never carry an EAN yet still need a pallet. + 'pallet_orders' => WorkOrder::packable() + ->with('productType', 'batches:id,work_order_id,batch_number,lot_number') + ->orderByDesc('priority')->orderByDesc('id') + ->get() + ->map(fn ($wo) => [ + 'id' => $wo->id, + 'order_no' => $wo->order_no, + 'product' => $this->productLabel($wo), + 'batches' => $wo->batches->map(fn ($b) => ['id' => $b->id, 'label' => $b->displayLabel()])->values(), + ])->values(), + ]); } public function scan(PackagingScanRequest $request) @@ -128,12 +192,176 @@ public function scan(PackagingScanRequest $request) ]); } + /** + * Carton label by process serial number: the operator scans the unit's + * process serial label and gets the unit's serial number back with + * ready-to-print label URLs. + */ + public function scanUnit(ScanUnitRequest $request, UnitPackingService $packing) + { + // The label carries the process serial, the unit serial, or both - a unit + // with no process serial (the product has no PSN sequence) is packed by + // its serial number. Newest first: with unique process serials there is + // one; without, the most recently bound unit is the one on the bench. + $scanned = $request->validated('psn'); + $identifier = app(\App\Support\UnitSerialisation::class)->normalize($scanned) ?? $scanned; + $unit = SerialUnit::where(fn ($q) => $q->whereIn('psn', [$scanned, $identifier])->orWhereIn('serial_no', [$scanned, $identifier])) + ->orderByDesc('id')->first(); + + if (! $unit) { + return response()->json(['message' => __('Unknown process serial or serial number')], 404); + } + + // Packaging comes from the BOM: with lot tracking on, the carton's lot is + // picked before the first unit goes in, so the box is traceable too. + $packingStep = $packing->packingStepFor($unit->work_order_id, $unit->batch_id); + if ($packingStep && ($gate = $packing->packingMaterialsGate($packingStep))) { + return response()->json(['message' => $gate, 'needs_picks' => true, 'packing_step_id' => $packingStep->id], 422); + } + + // A unit the line took off is refused before the bench does anything with it. + try { + $packing->assertPackable($unit); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + // Weight check (the packing step's expected weight and tolerance): the + // unit is weighed before it goes into the box. Without a weight the + // station asks for one; outside the tolerance the scan is refused and + // the unit stays on the bench, the reading kept on its history. + $config = $packing->packingConfigFor($unit->work_order_id); + $measured = []; + // A unit already in its box is not weighed again: a rescan changes nothing. + $alreadyPlaced = $unit->packed_at !== null && ($unit->carton_id || $unit->pallet_id); + if (isset($config['weight_expected_g']) && ! $alreadyPlaced) { + $expected = (float) $config['weight_expected_g']; + $tolerance = (float) ($config['weight_tolerance_g'] ?? 0); + $weight = $request->validated('weight_g'); + $range = ['expected' => rtrim(rtrim(number_format($expected, 2, '.', ''), '0'), '.'), 'tolerance' => rtrim(rtrim(number_format($tolerance, 2, '.', ''), '0'), '.')]; + if ($weight === null) { + return response()->json([ + 'message' => __('Weigh unit :sn: :expected g ± :tolerance g.', ['sn' => $unit->display_id] + $range), + 'needs_weight' => true, 'expected_g' => $expected, 'tolerance_g' => $tolerance, + ], 422); + } + $weight = round((float) $weight, 2); + if (abs($weight - $expected) > $tolerance + 1e-9) { + app(\App\Services\Traceability\SerialTraceService::class)->recordStep($unit, $request->user(), null, [ + 'workstation_id' => self::benchId($request), + 'parameters' => ['event' => 'weight_check', 'verdict' => 'fail', 'weight_g' => $weight, 'expected_g' => $expected, 'tolerance_g' => $tolerance], + ]); + + return response()->json([ + 'message' => __('Weight :weight g is outside :expected g ± :tolerance g - check unit :sn.', ['weight' => $weight, 'sn' => $unit->display_id] + $range), + 'needs_weight' => true, 'weight_out_of_tolerance' => true, 'expected_g' => $expected, 'tolerance_g' => $tolerance, + ], 422); + } + $measured = ['weight_g' => $weight]; + } + + $carton = $request->validated('carton_id') ? UnitCarton::find($request->validated('carton_id')) : null; + $pallet = $request->validated('pallet_id') ? Pallet::find($request->validated('pallet_id')) : null; + $auto = $request->boolean('auto_carton'); + // On "auto" the bench's box and pallet follow the unit: a box or pallet + // left open for another order is not where this unit goes, so it is set + // aside rather than refusing the scan. A box picked by hand still is checked. + $otherOrder = fn ($container) => $container && $unit->work_order_id && $container->work_order_id && $container->work_order_id !== $unit->work_order_id; + // A full box is set aside the same way: the next unit starts a new one. + $capacity = $packing->cartonCapacityFor($unit->work_order_id); + if ($auto && ($otherOrder($carton) || ($carton && $capacity && $carton->qty >= $capacity && $unit->carton_id !== $carton->id))) { + $carton = null; + } + if ($auto && $otherOrder($pallet)) { + $pallet = null; + } + // On "auto" a unit that is already packed stays where it is: the bench's + // box is only a guess, and aiming the rescan at it would refuse the unit + // (or open an empty carton) instead of saying it is already packed. + if ($auto && $alreadyPlaced) { + $carton = $pallet = null; + } + // Whether to print the unit's label here: by default every scan prints + // one (a box label read off the unit's serial). A packing step can turn + // that off, and then only a unit that has no label yet gets one. + $labelNeeded = ($config['unit_label'] ?? true) !== false || empty($unit->extra_data['label_applied_at']); + + // The service refuses a unit the line has taken off (scrapped, blocked + // after tests, already shipped) - that is how a bad unit leaves. + // A second read of a packed unit changes nothing; the bench says so rather + // than confirm it as if the unit had just gone in. + $placedBefore = $unit->packed_at !== null ? [$unit->carton_id, $unit->pallet_id] : null; + // The box opened for this scan and the packing stand or fall together: a + // refused unit leaves no empty carton behind on the bench. + try { + [$unit, $carton] = DB::transaction(function () use ($request, $packing, $unit, $carton, $pallet, $auto, $alreadyPlaced, $config, $capacity, $measured) { + // The bench had no box open (or only one for another order): when the + // order packs into cartons, the scan takes the operator's open carton of + // this order again, else opens one, so nobody has to remember "New + // carton" between boxes or orders and no unit lands loose by mistake. + if (! $carton && $auto && ! $alreadyPlaced && $config !== [] && ($config['unit'] ?? 'carton') === 'carton') { + $user = $request->user(); + // Mine: on my bench, or opened by me and left on nobody's - never the + // box another packer has taken over, and never a full one. + $carton = UnitCarton::where('status', UnitCarton::STATUS_OPEN)->where('work_order_id', $unit->work_order_id) + ->where(fn ($q) => $q->where('active_by_id', $user->id)->orWhere(fn ($q2) => $q2->where('created_by_id', $user->id)->whereNull('active_by_id'))) + ->when($capacity, fn ($q) => $q->where('qty', '<', $capacity)) + ->latest('id')->first(); + if ($carton && (int) $carton->active_by_id !== (int) $user->id) { + // Back to this order's box: it becomes the bench's box again. + UnitCarton::where('active_by_id', $user->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $carton->update(['active_by_id' => $user->id, 'active_workstation_id' => self::benchId($request), 'activated_at' => now()]); + } + $carton ??= $packing->openCarton($user, $unit->workOrder, $pallet, self::benchId($request)); + } + + return [$packing->packUnit($unit, $request->user(), $carton, $pallet, self::benchId($request), $measured), $carton]; + }); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $unit->loadMissing(['workOrder.productType', 'carton:id,carton_no,qty,status', 'pallet']); + $wo = $unit->workOrder; + $palletClosed = $packing->closePalletIfFull($unit->pallet) ? $unit->pallet->fresh() : null; + $template = $packing->labelTemplateIdFor($unit->work_order_id, 'unit'); + $alreadyPacked = $placedBefore === [$unit->carton_id, $unit->pallet_id]; + + return response()->json([ + 'unit' => [ + 'id' => $unit->id, + 'serial_no' => $unit->serial_no, + 'psn' => $unit->psn, + 'status' => $unit->status, + 'work_order_id' => $unit->work_order_id, + 'work_order' => $wo ? [ + 'order_no' => $wo->order_no, + 'product' => $this->productLabel($wo), + ] : null, + 'carton' => $unit->carton ? ['id' => $unit->carton->id, 'carton_no' => $unit->carton->carton_no, 'qty' => (int) $unit->carton->qty] : null, + 'pallet_no' => $unit->pallet?->pallet_no, + 'packed_at' => $unit->packed_at?->format('H:i'), + ], + 'already_packed' => $alreadyPacked, + // The box reached the packing step's size: the station closes it and prints its list. + 'carton_full' => (bool) ($capacity && $unit->carton?->isOpen() && $unit->carton->qty >= $capacity), + 'label_pdf' => route('packaging.labels.serial-unit.pdf', array_filter(['serialUnit' => $unit, 'template' => $template])), + 'label_zpl' => route('packaging.labels.serial-unit.zpl', array_filter(['serialUnit' => $unit, 'template' => $template])), + 'label_needed' => $labelNeeded, + 'carton_opened' => $carton && $carton->wasRecentlyCreated ? $carton->carton_no : null, + 'pallet_closed' => $palletClosed ? $this->closedPalletPayload($palletClosed, $packing) : null, + 'message' => $alreadyPacked + ? __('Unit :sn is already packed - nothing changed.', ['sn' => $unit->display_id]) + : __('Unit :sn found - print the carton label.', ['sn' => $unit->display_id]), + ]); + } + // ── Pallets (packing station) ─────────────────────────────────────────────── public function openPallets(Request $request) { $query = Pallet::where('status', PalletStatus::Open->value) - ->with(['workOrder:id,order_no,line_id', 'workOrder.line:id,name', 'batch:id,batch_number,lot_number']) + ->with(['workOrder:id,order_no,line_id', 'workOrder.line:id,name', 'batch:id,batch_number,lot_number', 'activeBy:id,name', 'activeWorkstation:id,name']) ->orderByDesc('updated_at'); if ($workOrderId = $request->integer('work_order_id')) { @@ -170,12 +398,16 @@ public function createPallet(CreatePalletStationRequest $request, PalletBackflus } } + Pallet::where('active_by_id', $request->user()->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); $pallet = Pallet::create([ 'work_order_id' => $workOrder->id, 'batch_id' => $batchId, 'status' => PalletStatus::Open->value, 'location' => $request->input('location'), 'qty' => 0, + 'active_by_id' => $request->user()->id, + 'active_workstation_id' => $request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id, + 'activated_at' => now(), ]); // Milestone backflush: when enabled, declare the BOM consumption implied @@ -188,18 +420,22 @@ public function createPallet(CreatePalletStationRequest $request, PalletBackflus } return response()->json([ - 'pallet' => $this->palletPayload($pallet->fresh(['workOrder.line', 'batch'])), + 'pallet' => $this->palletPayload($pallet->fresh(['workOrder.line', 'batch', 'activeBy', 'activeWorkstation'])), 'message' => __('Pallet :no created', ['no' => $pallet->pallet_no]), ], 201); } public function closePallet(Pallet $pallet) { - if (! $pallet->isOpen()) { + // Only the request that actually closes it books the pallet's packaging: + // a double click or a second station must not close it twice. + $closed = Pallet::whereKey($pallet->id)->where('status', PalletStatus::Open->value) + ->update(['status' => PalletStatus::Closed->value, 'active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + if ($closed === 0) { return response()->json(['message' => __('Pallet is not open')], 422); } - - $pallet->update(['status' => PalletStatus::Closed->value]); + $pallet->refresh(); + $this->packing()->palletClosed($pallet); return response()->json([ 'pallet' => $this->palletPayload($pallet->fresh(['workOrder.line', 'batch'])), @@ -207,6 +443,79 @@ public function closePallet(Pallet $pallet) ]); } + /** + * Take a pallet onto this bench. One pallet per operator: whatever they had + * before is released, and taking over someone else's is allowed but visible + * (the list names who had it) - a shift change, not a mystery. + */ + public function activatePallet(Request $request, Pallet $pallet) + { + if (! $pallet->isOpen()) { + return response()->json(['message' => __('Pallet is not open')], 422); + } + $user = $request->user(); + Pallet::where('active_by_id', $user->id)->whereKeyNot($pallet->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $pallet->update([ + 'active_by_id' => $user->id, + 'active_workstation_id' => $request->session()->get('selected_workstation_id') ?: $user->workstation_id, + 'activated_at' => now(), + ]); + + return response()->json(['pallet' => $this->palletPayload($pallet->fresh(['workOrder.line', 'batch', 'activeBy', 'activeWorkstation']))]); + } + + /** Put the pallet down without closing it - it stays open for anyone. */ + public function releasePallet(Request $request, Pallet $pallet) + { + if ($pallet->active_by_id === $request->user()->id) { + $pallet->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + } + + return response()->json(['pallet' => $this->palletPayload($pallet->fresh(['workOrder.line', 'batch']))]); + } + + /** The packaging a packing step takes from the BOM, with the lots to pick from when lot tracking is on. */ + public function packingStepMaterials(BatchStep $batchStep, UnitPackingService $packing, MaterialAllocationService $allocations) + { + abort_unless($batchStep->kind === TemplateStep::KIND_PACKING, 404); + + return response()->json([ + 'materials' => $packing->packingMaterials($batchStep), + 'candidates' => $batchStep->status === BatchStep::STATUS_READY ? $allocations->pickPreviewForStep($batchStep) : [], + ]); + } + + /** Start the packing step from the station with the picked packaging lots. */ + public function startPackingStep(StartPackingStepRequest $request, BatchStep $batchStep, UnitPackingService $packing) + { + abort_unless($batchStep->kind === TemplateStep::KIND_PACKING, 404); + try { + $step = $packing->startPackingStep($batchStep, $request->user(), $request->validated('picks') ?? []); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'materials' => $packing->packingMaterials($step), + 'message' => __('Packing step :name started - packaging reserved.', ['name' => $step->name]), + ]); + } + + /** A pallet that just closed itself: what the station needs to print it and say so. */ + public static function closedPalletPayload(Pallet $pallet, UnitPackingService $packing): array + { + $template = $packing->labelTemplateIdFor($pallet->work_order_id, 'pallet'); + + return [ + 'id' => $pallet->id, + 'pallet_no' => $pallet->pallet_no, + 'qty' => (int) $pallet->qty, + 'label_pdf' => route('packaging.labels.pallet.pdf', array_filter(['pallet' => $pallet, 'template' => $template])), + 'label_zpl' => route('packaging.labels.pallet.zpl', array_filter(['pallet' => $pallet, 'template' => $template])), + 'message' => __('Pallet :no is full and was closed', ['no' => $pallet->pallet_no]), + ]; + } + private function palletPayload(Pallet $pallet): array { return [ @@ -222,7 +531,12 @@ private function palletPayload(Pallet $pallet): array 'qty' => (int) $pallet->qty, 'status' => $pallet->status instanceof PalletStatus ? $pallet->status->value : $pallet->status, 'location' => $pallet->location, + 'active_by_id' => $pallet->active_by_id, + 'active_by' => $pallet->activeBy?->name, + 'active_workstation' => $pallet->activeWorkstation?->name, 'updated_at' => $pallet->updated_at?->toIso8601String(), + // The packing step may name the pallet label to print; the station's print button prefers it. + 'label_template_id' => $this->packing()->labelTemplateIdFor($pallet->work_order_id, 'pallet'), ]; } @@ -310,15 +624,17 @@ private function buildItemList(): array private function buildStats(): array { $shiftStart = $this->currentShiftStart(); - $todayPacked = PackagingScanLog::where('scanned_at', '>=', $shiftStart)->count(); + // Pieces packed this shift: EAN scans plus serialised units packed by process serial. + $todayPacked = PackagingScanLog::where('scanned_at', '>=', $shiftStart)->count() + + SerialUnit::where('packed_at', '>=', $shiftStart)->count(); - $plan = WorkOrder::packable() - ->whereHas('eans') - ->sum('planned_qty'); + // Orders on this station: the EAN-scanned ones and the serialised ones. + $eanOrders = WorkOrder::packable()->whereHas('eans'); + $serialOrders = WorkOrder::packable()->whereDoesntHave('eans')->whereHas('serialUnits'); - $totalPacked = WorkOrder::packable() - ->whereHas('eans') - ->sum('packed_qty'); + $plan = (clone $eanOrders)->sum('planned_qty') + (clone $serialOrders)->sum('planned_qty'); + $totalPacked = (clone $eanOrders)->sum('packed_qty') + + SerialUnit::whereNotNull('packed_at')->whereIn('work_order_id', (clone $serialOrders)->select('id'))->count(); $backlog = max(0, (int) $plan - (int) $totalPacked); $shift = $this->currentShiftPayload(); diff --git a/backend/app/Http/Controllers/Web/Packaging/PackingListController.php b/backend/app/Http/Controllers/Web/Packaging/PackingListController.php new file mode 100644 index 000000000..9e983344c --- /dev/null +++ b/backend/app/Http/Controllers/Web/Packaging/PackingListController.php @@ -0,0 +1,26 @@ + $list, + 'qrPng' => $this->labels->qrPng($pallet->pallet_no, 200), + 'generatedAt' => now()->format('Y-m-d H:i'), + ])->setPaper('a4')->stream('packing-list-'.\App\Support\DownloadName::safe($pallet->pallet_no).'.pdf'); + } +} diff --git a/backend/app/Http/Controllers/Web/Packaging/UnitCartonController.php b/backend/app/Http/Controllers/Web/Packaging/UnitCartonController.php new file mode 100644 index 000000000..aa6d9f202 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Packaging/UnitCartonController.php @@ -0,0 +1,154 @@ +with(['workOrder:id,order_no', 'pallet:id,pallet_no', 'activeBy:id,name', 'activeWorkstation:id,name']) + ->when($request->integer('work_order_id'), fn ($q, $id) => $q->where('work_order_id', $id)) + ->when($request->integer('pallet_id'), fn ($q, $id) => $q->where('pallet_id', $id)) + ->orderByDesc('id') + ->limit(50) + ->get(); + + return response()->json(['cartons' => $cartons->map(fn ($c) => $this->payload($c))]); + } + + public function store(OpenCartonRequest $request): JsonResponse + { + $data = $request->validated(); + try { + $carton = $this->packing->openCarton( + $request->user(), + isset($data['work_order_id']) ? WorkOrder::find($data['work_order_id']) : null, + isset($data['pallet_id']) ? Pallet::find($data['pallet_id']) : null, + ($request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id) ?: null, + ); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'carton' => $this->payload($carton->load(['workOrder:id,order_no', 'pallet:id,pallet_no'])), + 'message' => __('Carton :no opened', ['no' => $carton->carton_no]), + ], 201); + } + + /** The carton with the units in it - for the station's list and the label. */ + public function show(UnitCarton $carton): JsonResponse + { + $carton->load(['workOrder:id,order_no', 'pallet:id,pallet_no']); + + return response()->json([ + 'carton' => $this->payload($carton), + 'units' => $carton->units()->orderBy('id')->get(['id', 'serial_no', 'psn', 'status', 'packed_at']) + ->map(fn (SerialUnit $u) => ['id' => $u->id, 'serial_no' => $u->serial_no, 'psn' => $u->psn, 'status' => $u->status, 'packed_at' => $u->packed_at?->toIso8601String()]), + ]); + } + + public function close(Request $request, UnitCarton $carton): JsonResponse + { + try { + $carton = $this->packing->closeCarton($carton, $request->user()); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + // The box that fills the pallet closes the pallet too. + $palletClosed = $this->packing->closePalletIfFull($carton->pallet) ? $carton->pallet->fresh() : null; + + return response()->json([ + 'carton' => $this->payload($carton->load(['workOrder:id,order_no', 'pallet:id,pallet_no'])), + 'label_pdf' => $this->labelUrl($carton), + 'pallet_closed' => $palletClosed ? PackagingController::closedPalletPayload($palletClosed, $this->packing) : null, + 'message' => __('Carton :no closed with :count units', ['no' => $carton->carton_no, 'count' => $carton->qty]), + ]); + } + + /** Take a carton onto this bench (one per operator); closing or releasing lets it go. */ + public function activate(Request $request, UnitCarton $carton): JsonResponse + { + if (! $carton->isOpen()) { + return response()->json(['message' => __('Carton :no is closed.', ['no' => $carton->carton_no])], 422); + } + $user = $request->user(); + UnitCarton::where('active_by_id', $user->id)->whereKeyNot($carton->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $carton->update([ + 'active_by_id' => $user->id, + 'active_workstation_id' => $request->session()->get('selected_workstation_id') ?: $user->workstation_id, + 'activated_at' => now(), + ]); + + return response()->json(['carton' => $this->payload($carton->fresh(['workOrder:id,order_no', 'pallet:id,pallet_no', 'activeBy:id,name', 'activeWorkstation:id,name']))]); + } + + public function release(Request $request, UnitCarton $carton): JsonResponse + { + if ($carton->active_by_id === $request->user()->id) { + $carton->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + } + + return response()->json(['carton' => $this->payload($carton->fresh(['workOrder:id,order_no', 'pallet:id,pallet_no']))]); + } + + public function assignPallet(AssignCartonPalletRequest $request, UnitCarton $carton): JsonResponse + { + $pallet = Pallet::findOrFail($request->validated('pallet_id')); + try { + $carton = $this->packing->assignCartonToPallet($carton, $pallet, $request->user(), ($request->session()->get('selected_workstation_id') ?: $request->user()->workstation_id) ?: null); + } catch (BindingException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json([ + 'carton' => $this->payload($carton->load(['workOrder:id,order_no', 'pallet:id,pallet_no'])), + 'message' => __('Carton :no put on pallet :pallet', ['no' => $carton->carton_no, 'pallet' => $pallet->pallet_no]), + ]); + } + + private function payload(UnitCarton $c): array + { + return [ + 'id' => $c->id, + 'carton_no' => $c->carton_no, + 'status' => $c->status, + 'qty' => (int) $c->qty, + 'work_order_id' => $c->work_order_id, + 'order_no' => $c->workOrder?->order_no, + 'pallet_id' => $c->pallet_id, + 'pallet_no' => $c->pallet?->pallet_no, + 'closed_at' => $c->closed_at?->toIso8601String(), + 'active_by_id' => $c->active_by_id, + 'active_by' => $c->activeBy?->name, + 'active_workstation' => $c->activeWorkstation?->name, + 'label_pdf' => $this->labelUrl($c), + ]; + } + + /** The carton label, on the template the order's packing step names when it does. */ + private function labelUrl(UnitCarton $c): string + { + return route('packaging.labels.carton.pdf', array_filter([ + 'carton' => $c, + 'template' => $this->packing->labelTemplateIdFor($c->work_order_id, 'carton'), + ])); + } +} diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index bfc539cff..ee4a03b5d 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -3,10 +3,15 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Http\Requests\ImportSettingsRequest; use App\Http\Requests\UpdateRoleTabAccessRequest; use App\Http\Requests\UpdateSystemSettingsRequest; +use App\Services\Settings\ConfigurationImporter; +use App\Services\Settings\ProductionScenarioImporter; +use App\Services\Settings\ScenarioException; use App\Support\TabRegistry; use App\Support\TimezoneRegistry; +use App\Sync\CollectionBroadcaster; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; @@ -124,6 +129,10 @@ public function showSystemSettings() 'force_sequential_steps' => json_decode($rows['force_sequential_steps']?->value ?? 'true', true) ?? true, 'workstation_routing_enabled' => json_decode($rows['workstation_routing_enabled']?->value ?? 'false', true) ?? false, 'backflush_on_pallet_creation' => json_decode($rows['backflush_on_pallet_creation']?->value ?? 'false', true) ?? false, + 'hold_on_material_shortage' => json_decode($rows['hold_on_material_shortage']?->value ?? 'false', true) ?? false, + 'pallet_stock_documents' => \App\Services\Warehouse\PalletStockDocumentService::mode(), + 'lot_tracking_enabled' => json_decode($rows['lot_tracking_enabled']?->value ?? 'false', true) ?? false, + 'lot_picking_strategy' => json_decode($rows['lot_picking_strategy']?->value ?? '"fefo"', true) ?? 'fefo', 'workflow_mode' => json_decode($rows['workflow_mode']?->value ?? '"status"', true) ?? 'status', 'pin_login_enabled' => json_decode($rows['pin_login_enabled']?->value ?? 'false', true) ?? false, // The currently effective locale (session override applied by SetLocale, @@ -142,6 +151,7 @@ public function showSystemSettings() 'production_qty_edit_policy' => json_decode($rows['production_qty_edit_policy']?->value ?? '"none"', true) ?? 'none', 'production_qty_edit_window_minutes' => json_decode($rows['production_qty_edit_window_minutes']?->value ?? '1', true) ?? 1, 'scanner_mode' => json_decode($rows['scanner_mode']?->value ?? '"hid"', true) ?? 'hid', + ...app(\App\Support\UnitSerialisation::class)->all(), 'standard_weekly_hours' => json_decode($rows['standard_weekly_hours']?->value ?? '40', true) ?? 40, 'default_currency' => json_decode($rows['default_currency']?->value ?? '"PLN"', true) ?? 'PLN', 'default_pay_type' => json_decode($rows['default_pay_type']?->value ?? '"hourly"', true) ?? 'hourly', @@ -193,6 +203,9 @@ public function showSystemSettings() DB::table('system_settings')->where('key', 'sample_data_loaded')->value('value') ?? 'null', true, ), + // Tabs a module adds to this page: {slot, title, component} each. + // The module's component saves through its own route. `{}` when none. + 'hooks' => app(\App\Extension\HookRegistry::class)->renderMany(['display.settings.system.tabs']), ]); } @@ -542,6 +555,10 @@ public function updateSystemSettings(UpdateSystemSettingsRequest $request) 'force_sequential_steps' => (bool) ($validated['force_sequential_steps'] ?? false), 'workstation_routing_enabled' => (bool) ($validated['workstation_routing_enabled'] ?? false), 'backflush_on_pallet_creation' => (bool) ($validated['backflush_on_pallet_creation'] ?? false), + 'hold_on_material_shortage' => (bool) ($validated['hold_on_material_shortage'] ?? false), + 'pallet_stock_documents' => $validated['pallet_stock_documents'] ?? \App\Services\Warehouse\PalletStockDocumentService::mode(), + 'lot_tracking_enabled' => (bool) ($validated['lot_tracking_enabled'] ?? false), + 'lot_picking_strategy' => $validated['lot_picking_strategy'] ?? 'fefo', 'workflow_mode' => $validated['workflow_mode'], 'pin_login_enabled' => (bool) ($validated['pin_login_enabled'] ?? false), 'language' => $validated['language'] ?? 'en', @@ -559,6 +576,14 @@ public function updateSystemSettings(UpdateSystemSettingsRequest $request) 'production_qty_edit_policy' => $validated['production_qty_edit_policy'], 'production_qty_edit_window_minutes' => (int) ($validated['production_qty_edit_window_minutes'] ?? 1), 'scanner_mode' => $validated['scanner_mode'], + 'unit_identifier_normalize' => (bool) ($validated['unit_identifier_normalize'] ?? false), + 'unit_serial_pattern' => trim($validated['unit_serial_pattern'] ?? ''), + 'unit_psn_pattern' => trim($validated['unit_psn_pattern'] ?? ''), + 'unit_psn_required' => (bool) ($validated['unit_psn_required'] ?? false), + 'unit_psn_unique' => (bool) ($validated['unit_psn_unique'] ?? false), + 'unit_test_fail_policy' => $validated['unit_test_fail_policy'] ?? \App\Support\UnitSerialisation::FAIL_BLOCK, + 'unit_test_max_attempts' => max(1, (int) ($validated['unit_test_max_attempts'] ?? 1)), + 'unit_test_attempts_scope' => $validated['unit_test_attempts_scope'] ?? \App\Support\UnitSerialisation::ATTEMPTS_UNIT, 'standard_weekly_hours' => (float) ($validated['standard_weekly_hours'] ?? 40), 'default_currency' => strtoupper($validated['default_currency'] ?? 'PLN'), 'default_pay_type' => $validated['default_pay_type'] ?? 'hourly', @@ -581,6 +606,7 @@ public function updateSystemSettings(UpdateSystemSettingsRequest $request) // The flow mode is cached per request; this save bypasses ProductionFlow::set(). \App\Support\ProductionFlow::forget(); + app(\App\Support\UnitSerialisation::class)->forget(); // Plant timezone — only when submitted, and applied immediately so the // redirect that follows already renders in the new zone. @@ -654,32 +680,18 @@ public function exportSettings() 'system_settings' => DB::table('system_settings')->pluck('value', 'key')->toArray(), ]; - $tables = [ - 'lines', 'workstations', 'product_types', 'process_templates', - 'template_steps', 'material_types', 'materials', 'bom_items', - 'issue_types', 'shifts', 'line_statuses', 'dashboard_widgets', - 'maintenance_schedules', 'sites', 'areas', 'skills', - 'personnel_classes', 'process_segments', - ]; + // Everything the import reads back (its table list), so a file round-trips. + $tables = array_keys(ConfigurationImporter::TABLES); foreach ($tables as $table) { - try { - $export[$table] = DB::table($table)->get()->map(fn ($r) => (array) $r)->toArray(); - } catch (\Exception $e) { - // table may not exist yet + if (! Schema::hasTable($table)) { + continue; } - } - - // Add optional tables only if they exist - $optionalTables = ['inspection_plans', 'view_templates', 'label_templates']; - foreach ($optionalTables as $table) { - try { - if (Schema::hasTable($table)) { - $export[$table] = DB::table($table)->get()->map(fn ($r) => (array) $r)->toArray(); - } - } catch (\Exception $e) { - // table may not exist yet + $query = DB::table($table); + if (Schema::hasColumn($table, 'deleted_at')) { + $query->whereNull('deleted_at'); // the trash stays here } + $export[$table] = $query->get()->map(fn ($r) => (array) $r)->toArray(); } return response()->json($export, 200, [ @@ -688,151 +700,45 @@ public function exportSettings() } /** - * Import system configuration from JSON file + * Import a JSON file: configuration, and/or a production scenario (the + * `scenario` section) replayed on top of it. One transaction - a scenario + * event that fails leaves nothing from the file behind. */ - public function importSettings(Request $request) + public function importSettings(ImportSettingsRequest $request, ConfigurationImporter $importer, ProductionScenarioImporter $scenario) { - $request->validate([ - 'settings_file' => 'required|file|mimes:json,txt|max:10240', - ]); + $data = json_decode((string) file_get_contents($request->file('settings_file')->getRealPath()), true); + if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) { + return back()->with('error', __('Invalid JSON file.')); + } try { - $content = file_get_contents($request->file('settings_file')->getRealPath()); - $data = json_decode($content, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - return back()->with('error', __('Invalid JSON file.')); - } - - // Backward compat: old format with just 'settings' key - if (isset($data['settings']) && ! isset($data['system_settings'])) { - $data['system_settings'] = $data['settings']; - } - - $allowedTables = [ - 'system_settings', 'lines', 'workstations', 'product_types', - 'process_templates', 'template_steps', 'material_types', 'materials', - 'bom_items', 'issue_types', 'shifts', 'line_statuses', - 'dashboard_widgets', 'maintenance_schedules', - 'sites', 'areas', 'skills', 'personnel_classes', 'process_segments', - 'inspection_plans', 'view_templates', 'label_templates', - ]; - - $skipColumns = ['id', 'created_at', 'updated_at', 'tenant_id']; - - // Forbidden system_settings keys - $forbiddenSettings = [ - 'app_key', 'app_debug', 'app_env', - 'db_host', 'db_port', 'db_database', 'db_username', 'db_password', 'db_connection', - 'mail_host', 'mail_port', 'mail_username', 'mail_password', - 'cors_allowed_origins', 'cors_allowed_methods', - 'modules_enabled', - 'production_flow_mode', - ]; - - $imported = 0; - - DB::beginTransaction(); - - foreach ($data as $tableName => $rows) { - if (! in_array($tableName, $allowedTables, true)) { - continue; - } - if (! is_array($rows)) { - continue; - } - if (! Schema::hasTable($tableName)) { - continue; - } - - if ($tableName === 'system_settings') { - // Special handling: key-value update, not replace - $existingKeys = DB::table('system_settings')->pluck('key')->toArray(); - - foreach ($rows as $key => $value) { - if (in_array(strtolower($key), $forbiddenSettings, true)) { - continue; - } - if (! is_string($value) && ! is_numeric($value)) { - continue; - } - if (strlen((string) $value) > 1000) { - continue; - } - if (! in_array($key, $existingKeys, true)) { - continue; - } - - DB::table('system_settings')->where('key', $key)->update(['value' => (string) $value]); - $imported++; - } - - continue; - } - - // For all other tables: upsert by unique key (code or name) - if (empty($rows)) { - continue; - } - - // Determine unique key for upsert - $uniqueKey = match ($tableName) { - 'lines', 'workstations', 'product_types', 'material_types', - 'materials', 'issue_types', 'shifts', 'skills', - 'personnel_classes', 'process_segments', 'sites', 'areas' => 'code', - 'line_statuses', 'process_templates', 'maintenance_schedules', - 'inspection_plans', 'label_templates' => 'name', - 'dashboard_widgets' => 'widget_id', - default => null, - }; - - foreach ($rows as $row) { - if (! is_array($row)) { - continue; - } - - $originalId = $row['id'] ?? null; - - // Remove auto-generated columns - foreach ($skipColumns as $col) { - unset($row[$col]); - } - // Remove null values for columns that might not accept null - $row = array_filter($row, fn ($v) => $v !== null); - - if (empty($row)) { - continue; - } - - try { - DB::statement('SAVEPOINT row_insert'); - if ($uniqueKey && isset($row[$uniqueKey])) { - DB::table($tableName)->updateOrInsert( - [$uniqueKey => $row[$uniqueKey]], - $row - ); - } else { - DB::table($tableName)->insert($row); - } - DB::statement('RELEASE SAVEPOINT row_insert'); - $imported++; - } catch (\Exception $e) { - DB::statement('ROLLBACK TO SAVEPOINT row_insert'); - - continue; - } - } - } - - DB::commit(); + // Without live deltas: a plant file and its scenario touch a thousand + // rows or more, and each delta is a synchronous call to the websocket + // server made after the commit - on a slower host enough of them to + // run the request past its time limit (an empty 500, the data saved). + // The admin's page reloads after the import; other screens refresh + // from their snapshot, as after the data import (which mutes too). + [$imported, $events] = CollectionBroadcaster::muted(fn () => DB::transaction(fn () => [ + $importer->import($data), + is_array($data['scenario'] ?? null) ? $scenario->import($data['scenario'], $request->user()) : null, + ])); Cache::flush(); - return back()->with('success', __(':count configuration items imported successfully.', ['count' => $imported])); - } catch (\Exception $e) { - DB::rollBack(); - report($e); - - return back()->with('error', __('Failed to import settings. Please check the file and try again.')); + return back()->with('success', $events === null + ? __(':count configuration items imported successfully.', ['count' => $imported]) + : __('Imported :count configuration items and replayed :events production events.', ['count' => $imported, 'events' => $events])); + } catch (ScenarioException $e) { + return back()->with('error', __('The production scenario was not imported - nothing from the file was saved. :message', ['message' => $e->getMessage()])); + } catch (\Throwable $e) { + // Logged when the log can be written; a broken log must not turn the + // answer into an empty 500 that hides why nothing was imported. + rescue(fn () => report($e), report: false); + + // Admins only reach this screen: the reason is theirs to see, so a + // failed import on a server can be read without its logs. + return back()->with('error', __('Failed to import settings - nothing from the file was saved. :message', [ + 'message' => class_basename($e).': '.\Illuminate\Support\Str::limit($e->getMessage(), 300), + ])); } } diff --git a/backend/app/Http/Middleware/HandleInertiaRequests.php b/backend/app/Http/Middleware/HandleInertiaRequests.php index 9eec10d18..2974825f3 100644 --- a/backend/app/Http/Middleware/HandleInertiaRequests.php +++ b/backend/app/Http/Middleware/HandleInertiaRequests.php @@ -60,7 +60,30 @@ public function share(Request $request): array 'moduleNav' => [ 'items' => fn () => app(\App\Services\MenuRegistry::class)->getAllItems(), 'groups' => fn () => app(\App\Services\MenuRegistry::class)->getGroups(), + // operator: [{label,url,order,prefix}] extra tabs on the operator + // panel's top bar (OperatorLayout), Inertia links. + 'operator' => fn () => app(\App\Services\MenuRegistry::class)->getOperatorItems(), ], + // Compiled frontends of modules installed after this app was built. + // Their pages are not in the bundle — nothing rebuilds it on a + // running system — so the browser fetches each module's own file and + // the module registers its pages before the first render. + // Plain closure, not Inertia::lazy: app.jsx reads this off the initial + // payload on a cold load, which a lazy prop would omit. + 'moduleAssets' => fn () => app(\App\Services\ModuleManager::class)->frontendAssets(), + // Operator chrome (OperatorLayout). Each is a seam a module can bend + // without core knowing which module, or why: + // operatorTabs: the top bar's own tabs, through filter `operator.tabs` + // operatorCanLogout: whether the logout button shows, filter `operator.can_logout` + // (UX only — a module that forbids logout refuses the POST itself) + // operatorHooks: display hook `display.operator.layout`, rendered at the + // top of every operator screen + // With no module listening these are the defaults, `true` and `{}`. + 'operatorTabs' => fn () => $this->operatorTabs($user, $request), + 'operatorCanLogout' => fn () => (bool) app(\App\Extension\FilterRegistry::class) + ->filter('operator.can_logout', true, ['user' => $user]), + 'operatorHooks' => fn () => app(\App\Extension\HookRegistry::class) + ->renderMany(['display.operator.layout'], ['user' => $user]), 'csrf_token' => fn () => csrf_token(), 'appVersion' => fn () => config('version.current'), // i18n: the active locale + the switcher's options. The frontend @@ -114,6 +137,32 @@ private function accessibleTabLinks($user): array ); } + /** + * The operator top bar's core tabs, as data a module may filter. Labels are + * English keys; the frontend translates them. A tab is active while the path + * starts with any of its `prefixes`. + * + * @return list}> + */ + private function operatorTabs($user, Request $request): array + { + $tabs = [ + ['key' => 'queue', 'label' => 'Queue', 'url' => '/operator/queue', 'prefixes' => ['/operator/queue', '/operator/work-order']], + ['key' => 'workstation', 'label' => 'Workstation', 'url' => '/operator/workstation', 'prefixes' => ['/operator/workstation']], + ['key' => 'unit_labels', 'label' => 'SN labels', 'url' => '/operator/unit-labels/station', 'prefixes' => ['/operator/unit-labels']], + ['key' => 'packing', 'label' => 'Packing', 'url' => '/operator/packaging', 'prefixes' => ['/operator/packaging']], + ]; + // Only the screens this bench's step needs (a packing bench sees Packing + // only); staff and the whole-line view keep every tab. Worked out on the + // operator shell only - other pages skip the queries. + if ($user && $request->routeIs('operator.*')) { + $screens = app(\App\Services\Production\OperatorScreens::class)->forRequest($request); + $tabs = array_values(array_filter($tabs, fn ($tab) => in_array($tab['key'], $screens, true))); + } + + return array_values(app(\App\Extension\FilterRegistry::class)->filter('operator.tabs', $tabs, ['user' => $user])); + } + private function alertCount($user): int { if (! $user || ! $user->hasAnyRole(['Admin', 'Supervisor'])) { diff --git a/backend/app/Http/Requests/Api/V1/BindUnitComponentApiRequest.php b/backend/app/Http/Requests/Api/V1/BindUnitComponentApiRequest.php new file mode 100644 index 000000000..fd090f14f --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/BindUnitComponentApiRequest.php @@ -0,0 +1,32 @@ +merge(['identifier' => app(UnitSerialisation::class)->normalize($this->input('identifier'))]); + } + + public function rules(): array + { + return [ + 'identifier' => ['required', 'string', 'max:100'], + 'material_id' => ['nullable', 'integer', Rule::exists('materials', 'id')->whereNull('deleted_at')], + 'quantity' => ['nullable', 'numeric', 'gt:0', 'max:999999'], + 'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/IngestTestRunRequest.php b/backend/app/Http/Requests/Api/V1/IngestTestRunRequest.php new file mode 100644 index 000000000..50d283340 --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/IngestTestRunRequest.php @@ -0,0 +1,55 @@ + ['nullable', 'array', 'min:1'], + 'records.*' => ['array'], + 'work_order' => ['nullable', 'string', 'max:100', 'exists:work_orders,order_no'], + 'workstation' => ['nullable', 'string', 'max:50', 'exists:workstations,code'], + 'source' => ['nullable', 'string', 'max:120'], + ]; + } + + /** The decoded records, whichever way the run was sent. */ + public function records(): array + { + if ($this->isJson()) { + $body = $this->json()->all(); + if (isset($body['records']) && is_array($body['records'])) { + return array_values(array_filter($body['records'], 'is_array')); + } + + return array_is_list($body) ? array_values(array_filter($body, 'is_array')) : [$body]; + } + + $records = []; + foreach (preg_split('/\r?\n/', (string) $this->getContent()) as $line) { + $row = json_decode(trim($line), true); + if (is_array($row)) { + $records[] = $row; + } + } + + return $records; + } +} diff --git a/backend/app/Http/Requests/Api/V1/RecordSerialUnitStepRequest.php b/backend/app/Http/Requests/Api/V1/RecordSerialUnitStepRequest.php new file mode 100644 index 000000000..23983cba5 --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/RecordSerialUnitStepRequest.php @@ -0,0 +1,26 @@ + ['nullable', 'integer', 'exists:batch_steps,id'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + 'parameters' => ['nullable', 'array'], + 'result' => ['nullable', Rule::in(['pass', 'fail', 'rework'])], + 'notes' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/StoreProcessTemplateRequest.php b/backend/app/Http/Requests/Api/V1/StoreProcessTemplateRequest.php index 0863d023e..623432de9 100644 --- a/backend/app/Http/Requests/Api/V1/StoreProcessTemplateRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreProcessTemplateRequest.php @@ -6,7 +6,10 @@ class StoreProcessTemplateRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/StoreProductTypeRequest.php b/backend/app/Http/Requests/Api/V1/StoreProductTypeRequest.php index 5820fab8e..4092cf0b1 100644 --- a/backend/app/Http/Requests/Api/V1/StoreProductTypeRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreProductTypeRequest.php @@ -6,7 +6,10 @@ class StoreProductTypeRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/StoreSerialUnitRequest.php b/backend/app/Http/Requests/Api/V1/StoreSerialUnitRequest.php new file mode 100644 index 000000000..10cfd52dc --- /dev/null +++ b/backend/app/Http/Requests/Api/V1/StoreSerialUnitRequest.php @@ -0,0 +1,41 @@ +merge([ + 'serial_no' => $settings->normalize($this->input('serial_no')), + 'psn' => $settings->normalize($this->input('psn')), + ]); + } + + public function rules(): array + { + $settings = app(UnitSerialisation::class); + + return [ + // A unit may be registered on its process serial alone; the SN follows. + 'serial_no' => ['required_without:psn', 'nullable', ...array_slice($settings->serialRules(), 1)], + 'psn' => array_merge($settings->psnRules(), $settings->get('unit_psn_unique') ? [Rule::unique('serial_units', 'psn')] : []), + 'work_order_id' => ['nullable', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + 'batch_id' => ['nullable', 'integer', 'exists:batches,id'], + 'material_id' => ['nullable', 'integer', Rule::exists('materials', 'id')->whereNull('deleted_at')], + 'status' => ['nullable', Rule::in(SerialUnit::STATUSES)], + ]; + } +} diff --git a/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php b/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php index e9dce6b80..969409b61 100644 --- a/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreTemplateStepRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Api\V1; use App\Http\Requests\Concerns\ValidatesEquipmentParameters; +use App\Models\TemplateStep; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -20,6 +21,16 @@ public function rules(): array return [ 'step_number' => ['nullable', 'integer', 'min:1'], 'name' => ['required', 'string', 'max:255'], + 'kind' => ['nullable', Rule::in(TemplateStep::KINDS)], + // Kind-specific configuration. Packing: what the station fills and how much fits. + 'config' => ['nullable', 'array'], + 'config.unit' => ['nullable', Rule::in(TemplateStep::PACKING_UNITS)], + 'config.carton_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.pallet_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.label_template_id' => ['nullable', 'integer', 'exists:label_templates,id'], + 'config.unit_label' => ['nullable', 'boolean'], + 'config.weight_expected_g' => ['nullable', 'numeric', 'gt:0', 'max:1000000'], + 'config.weight_tolerance_g' => ['nullable', 'numeric', 'min:0', 'max:1000000'], 'instruction' => ['nullable', 'string'], 'estimated_duration_minutes' => ['nullable', 'integer', 'min:0'], 'setup_time_minutes' => ['nullable', 'integer', 'min:0'], diff --git a/backend/app/Http/Requests/Api/V1/StoreWorkstationRequest.php b/backend/app/Http/Requests/Api/V1/StoreWorkstationRequest.php index 39badd218..06b808cfa 100644 --- a/backend/app/Http/Requests/Api/V1/StoreWorkstationRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreWorkstationRequest.php @@ -6,7 +6,10 @@ class StoreWorkstationRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/StoreWorkstationTypeRequest.php b/backend/app/Http/Requests/Api/V1/StoreWorkstationTypeRequest.php index 08a15d6ff..7cdbffbdf 100644 --- a/backend/app/Http/Requests/Api/V1/StoreWorkstationTypeRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreWorkstationTypeRequest.php @@ -6,7 +6,10 @@ class StoreWorkstationTypeRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/UpdateProcessTemplateRequest.php b/backend/app/Http/Requests/Api/V1/UpdateProcessTemplateRequest.php index 276d312f2..54f610f98 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateProcessTemplateRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateProcessTemplateRequest.php @@ -6,7 +6,10 @@ class UpdateProcessTemplateRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/UpdateProductTypeRequest.php b/backend/app/Http/Requests/Api/V1/UpdateProductTypeRequest.php index afe3befc2..ef02e7cd5 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateProductTypeRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateProductTypeRequest.php @@ -7,11 +7,15 @@ class UpdateProductTypeRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { $id = $this->route('product_type')?->id; + return [ 'code' => ['sometimes', 'required', 'string', 'max:50', Rule::unique('product_types', 'code')->ignore($id)], 'name' => ['sometimes', 'required', 'string', 'max:255'], diff --git a/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php b/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php index 8d1d68672..4b1de6f3c 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateTemplateStepRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Api\V1; use App\Http\Requests\Concerns\ValidatesEquipmentParameters; +use App\Models\TemplateStep; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -19,6 +20,16 @@ public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'kind' => ['sometimes', 'nullable', Rule::in(TemplateStep::KINDS)], + // Kind-specific configuration. Packing: what the station fills and how much fits. + 'config' => ['sometimes', 'nullable', 'array'], + 'config.unit' => ['nullable', Rule::in(TemplateStep::PACKING_UNITS)], + 'config.carton_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.pallet_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.label_template_id' => ['nullable', 'integer', 'exists:label_templates,id'], + 'config.unit_label' => ['nullable', 'boolean'], + 'config.weight_expected_g' => ['nullable', 'numeric', 'gt:0', 'max:1000000'], + 'config.weight_tolerance_g' => ['nullable', 'numeric', 'min:0', 'max:1000000'], 'instruction' => ['sometimes', 'nullable', 'string'], 'estimated_duration_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], 'setup_time_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], diff --git a/backend/app/Http/Requests/Api/V1/UpdateWorkstationRequest.php b/backend/app/Http/Requests/Api/V1/UpdateWorkstationRequest.php index b1686aa7f..6c492c1a4 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateWorkstationRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateWorkstationRequest.php @@ -7,7 +7,10 @@ class UpdateWorkstationRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { diff --git a/backend/app/Http/Requests/Api/V1/UpdateWorkstationTypeRequest.php b/backend/app/Http/Requests/Api/V1/UpdateWorkstationTypeRequest.php index 1b37353ed..4537b0755 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateWorkstationTypeRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateWorkstationTypeRequest.php @@ -7,11 +7,15 @@ class UpdateWorkstationTypeRequest extends FormRequest { - public function authorize(): bool { return true; } + public function authorize(): bool + { + return true; + } public function rules(): array { $id = $this->route('workstation_type')?->id; + return [ 'code' => ['sometimes', 'required', 'string', 'max:50', Rule::unique('workstation_types', 'code')->ignore($id)], 'name' => ['sometimes', 'required', 'string', 'max:255'], diff --git a/backend/app/Http/Requests/AssignCartonPalletRequest.php b/backend/app/Http/Requests/AssignCartonPalletRequest.php new file mode 100644 index 000000000..aeeff1cb4 --- /dev/null +++ b/backend/app/Http/Requests/AssignCartonPalletRequest.php @@ -0,0 +1,19 @@ + ['required', 'integer', 'exists:pallets,id']]; + } +} diff --git a/backend/app/Http/Requests/BindUnitComponentRequest.php b/backend/app/Http/Requests/BindUnitComponentRequest.php new file mode 100644 index 000000000..efdf09a17 --- /dev/null +++ b/backend/app/Http/Requests/BindUnitComponentRequest.php @@ -0,0 +1,47 @@ +merge([ + 'serial_no' => $settings->normalize($this->input('serial_no')), + 'identifier' => $settings->normalize($this->input('identifier')), + ]); + } + + public function rules(): array + { + return [ + // The unit by its SN or its PSN (a lookup, not a registration: the + // formats were checked when the numbers were given). + 'serial_no' => ['required', 'string', 'max:100'], + 'identifier' => ['required', 'string', 'max:100'], + 'material_id' => ['nullable', 'integer', Rule::exists('materials', 'id')->whereNull('deleted_at')], + 'quantity' => ['nullable', 'numeric', 'gt:0', 'max:999999'], + 'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'], + ]; + } + + public function messages(): array + { + return ['serial_no.regex' => __('The serial number does not match the configured format.')]; + } +} diff --git a/backend/app/Http/Requests/BindUnitLabelRequest.php b/backend/app/Http/Requests/BindUnitLabelRequest.php new file mode 100644 index 000000000..b54b894db --- /dev/null +++ b/backend/app/Http/Requests/BindUnitLabelRequest.php @@ -0,0 +1,53 @@ +merge([ + 'serial_no' => $settings->normalize($this->input('serial_no')), + 'psn' => $settings->normalize($this->input('psn')), + ]); + } + + public function rules(): array + { + $settings = app(UnitSerialisation::class); + + return [ + 'serial_no' => $settings->serialRules(), + 'psn' => $settings->psnRules(), + 'work_order_id' => ['nullable', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + // Re-binding a unit that already carries another process serial is a + // supervisor's decision, with a reason for the unit's history. + 'force' => ['nullable', 'boolean'], + 'reason' => ['required_if:force,true', 'nullable', 'string', 'max:500'], + ]; + } + + public function messages(): array + { + return [ + 'serial_no.regex' => __('The serial number does not match the configured format.'), + 'psn.regex' => __('The process serial number does not match the configured format.'), + 'psn.required' => __('A process serial number is required.'), + ]; + } +} diff --git a/backend/app/Http/Requests/BlockUnitRequest.php b/backend/app/Http/Requests/BlockUnitRequest.php new file mode 100644 index 000000000..6700c7240 --- /dev/null +++ b/backend/app/Http/Requests/BlockUnitRequest.php @@ -0,0 +1,26 @@ + ['required', 'integer', Rule::exists('scrap_reasons', 'id')->whereNull('deleted_at')->where('is_active', true)], + 'note' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/backend/app/Http/Requests/Concerns/AllowsModuleFields.php b/backend/app/Http/Requests/Concerns/AllowsModuleFields.php new file mode 100644 index 000000000..45b3c641c --- /dev/null +++ b/backend/app/Http/Requests/Concerns/AllowsModuleFields.php @@ -0,0 +1,76 @@ +_`, e.g. `module_example_code`. + * The rule set is one flat array shared with core; an unprefixed key will + * eventually collide with a core field, and the collision silently replaces + * the core rule. + * - **Add, do not subtract.** The filter receives the complete rule set and can + * rewrite any of it, including weakening core rules — dropping `confirmed` + * from a password, say. FilterRegistry has no notion of "append only" and this + * is not policed. It is a real hole in a third-party module; keep it in mind + * before accepting one. + */ +trait AllowsModuleFields +{ + /** + * The core rule set, plus whatever the installed modules add to it. + * + * With no module listening FilterRegistry returns the array untouched, so + * the community path costs nothing. + * + * @param array $rules + * @return array + */ + protected function withModuleFields(array $rules): array + { + return app(FilterRegistry::class)->filter( + $this->moduleFieldFilter(), + $rules, + $this->moduleFieldContext(), + ); + } + + /** The filter name, e.g. `validation.admin.users`. */ + abstract protected function moduleFieldFilter(): string; + + /** + * What the module needs in order to build its rules — at minimum the record + * being edited, so it can write its own `unique ... ignore`, and the action, + * so a create and an edit can differ. + * + * @return array + */ + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction()]; + } + + /** + * Whether this request creates or edits. A trait method cannot be reached + * through `parent::`, so overrides call this rather than the default above. + */ + protected function moduleFieldAction(): string + { + return $this->isMethod('POST') ? 'store' : 'update'; + } +} diff --git a/backend/app/Http/Requests/ImportSettingsRequest.php b/backend/app/Http/Requests/ImportSettingsRequest.php new file mode 100644 index 000000000..6e03bf269 --- /dev/null +++ b/backend/app/Http/Requests/ImportSettingsRequest.php @@ -0,0 +1,19 @@ + ['required', 'file', 'mimes:json,txt', 'max:10240']]; + } +} diff --git a/backend/app/Http/Requests/IssueUnitBatchRequest.php b/backend/app/Http/Requests/IssueUnitBatchRequest.php new file mode 100644 index 000000000..5a261f0ec --- /dev/null +++ b/backend/app/Http/Requests/IssueUnitBatchRequest.php @@ -0,0 +1,31 @@ + ['required', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + 'quantity' => ['required', 'integer', 'min:1', 'max:'.self::MAX], + // Number the units by their process serial only; the SN comes later. + 'psn_only' => ['nullable', 'boolean'], + ]; + } +} diff --git a/backend/app/Http/Requests/IssueUnitIdentifierRequest.php b/backend/app/Http/Requests/IssueUnitIdentifierRequest.php new file mode 100644 index 000000000..8c490543e --- /dev/null +++ b/backend/app/Http/Requests/IssueUnitIdentifierRequest.php @@ -0,0 +1,24 @@ + ['required', Rule::in([LotSequence::PURPOSE_PROCESS_SERIAL, LotSequence::PURPOSE_UNIT_SERIAL])], + 'work_order_id' => ['nullable', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + ]; + } +} diff --git a/backend/app/Http/Requests/LotSequenceRequest.php b/backend/app/Http/Requests/LotSequenceRequest.php index b0848d2f2..6ac18aebc 100644 --- a/backend/app/Http/Requests/LotSequenceRequest.php +++ b/backend/app/Http/Requests/LotSequenceRequest.php @@ -25,10 +25,15 @@ public function rules(): array return [ 'name' => ['required', 'string', 'max:50'], + 'purpose' => ['nullable', Rule::in(LotSequence::PURPOSES)], + // One sequence per (product, purpose): a product may number its LOTs, + // its process serials and its unit serials with three sequences. 'product_type_id' => [ 'nullable', 'exists:product_types,id', - Rule::unique('lot_sequences', 'product_type_id')->ignore($sequenceId), + Rule::unique('lot_sequences', 'product_type_id') + ->where('purpose', $this->input('purpose') ?: LotSequence::PURPOSE_LOT) + ->ignore($sequenceId), ], 'pattern' => ['nullable', 'string', 'max:100', new ValidLotPattern], 'prefix' => ['required_without:pattern', 'nullable', 'string', 'max:20'], @@ -39,6 +44,13 @@ public function rules(): array ]; } + public function messages(): array + { + return [ + 'product_type_id.unique' => __('This product already has a sequence of this kind. Edit that one, or pick another kind under "Numbers".'), + ]; + } + /** * Normalized payload with defaults applied. */ @@ -49,6 +61,7 @@ public function payload(): array $validated['year_prefix'] = $this->boolean('year_prefix'); $validated['pad_size'] = $validated['pad_size'] ?? 4; $validated['reset_period'] = $validated['reset_period'] ?? 'none'; + $validated['purpose'] = $validated['purpose'] ?? LotSequence::PURPOSE_LOT; // Legacy column is NOT NULL; pattern mode doesn't use it. $validated['prefix'] = $validated['prefix'] ?? ''; diff --git a/backend/app/Http/Requests/OpenCartonRequest.php b/backend/app/Http/Requests/OpenCartonRequest.php new file mode 100644 index 000000000..31d5ffb42 --- /dev/null +++ b/backend/app/Http/Requests/OpenCartonRequest.php @@ -0,0 +1,23 @@ + ['nullable', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + 'pallet_id' => ['nullable', 'integer', 'exists:pallets,id'], + ]; + } +} diff --git a/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php b/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php index 5fd471552..f5290eb69 100644 --- a/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php +++ b/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Operator; +use App\Http\Requests\Concerns\AllowsModuleFields; use Illuminate\Foundation\Http\FormRequest; /** @@ -11,17 +12,31 @@ */ class CompleteBatchStepRequest extends FormRequest { + use AllowsModuleFields; + public function authorize(): bool { return true; } + protected function moduleFieldFilter(): string + { + return 'validation.operator.step'; + } + + protected function moduleFieldContext(): array + { + // 'start' / 'complete' rather than the trait's create/edit wording: + // both are POSTs, and which one it is, is the whole distinction here. + return ['action' => 'complete', 'step' => $this->route('batchStep')]; + } + public function rules(): array { - return [ + return $this->withModuleFields([ 'actual_elapsed_minutes' => ['nullable', 'integer', 'min:0'], 'actual_setup_minutes' => ['nullable', 'integer', 'min:0'], 'actual_run_minutes' => ['nullable', 'integer', 'min:0'], - ]; + ]); } } diff --git a/backend/app/Http/Requests/Operator/StartStepRequest.php b/backend/app/Http/Requests/Operator/StartStepRequest.php index 170612858..16d63bec0 100644 --- a/backend/app/Http/Requests/Operator/StartStepRequest.php +++ b/backend/app/Http/Requests/Operator/StartStepRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Operator; +use App\Http\Requests\Concerns\AllowsModuleFields; use Illuminate\Foundation\Http\FormRequest; /** @@ -14,19 +15,33 @@ */ class StartStepRequest extends FormRequest { + use AllowsModuleFields; + public function authorize(): bool { return true; } + protected function moduleFieldFilter(): string + { + return 'validation.operator.step'; + } + + protected function moduleFieldContext(): array + { + // 'start' / 'complete' rather than the trait's create/edit wording: + // both are POSTs, and which one it is, is the whole distinction here. + return ['action' => 'start', 'step' => $this->route('batchStep')]; + } + public function rules(): array { - return [ + return $this->withModuleFields([ 'picks' => ['nullable', 'array'], 'picks.*.material_id' => ['required', 'integer', 'exists:materials,id'], 'picks.*.lots' => ['required', 'array', 'min:1'], 'picks.*.lots.*.material_lot_id' => ['required', 'integer', 'exists:material_lots,id'], 'picks.*.lots.*.picked_qty' => ['required', 'numeric', 'gt:0'], - ]; + ]); } } diff --git a/backend/app/Http/Requests/PreviewLabelTemplateRequest.php b/backend/app/Http/Requests/PreviewLabelTemplateRequest.php new file mode 100644 index 000000000..67c6c5842 --- /dev/null +++ b/backend/app/Http/Requests/PreviewLabelTemplateRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'string', 'max:255'], + 'type' => ['required', Rule::in(array_keys(LabelTemplate::TYPES))], + 'size' => ['required', Rule::in(array_keys(LabelTemplate::SIZES))], + 'barcode_format' => ['required', Rule::in(array_keys(LabelTemplate::BARCODE_FORMATS))], + 'fields' => ['nullable', 'array'], + ]; + } +} diff --git a/backend/app/Http/Requests/RegisterSubassemblyRequest.php b/backend/app/Http/Requests/RegisterSubassemblyRequest.php new file mode 100644 index 000000000..7c48b317e --- /dev/null +++ b/backend/app/Http/Requests/RegisterSubassemblyRequest.php @@ -0,0 +1,24 @@ + ['required', 'integer', 'exists:work_orders,id'], + 'material_id' => ['required', 'integer', 'exists:materials,id'], + // The sub-assembly's own format - not the product's serial pattern. + 'serial_no' => ['required', 'string', 'max:100'], + ]; + } +} diff --git a/backend/app/Http/Requests/ScanUnitRequest.php b/backend/app/Http/Requests/ScanUnitRequest.php new file mode 100644 index 000000000..4906603b0 --- /dev/null +++ b/backend/app/Http/Requests/ScanUnitRequest.php @@ -0,0 +1,35 @@ +merge(['psn' => app(UnitSerialisation::class)->normalize($this->input('psn'))]); + } + + public function rules(): array + { + return [ + 'psn' => ['required', 'string', 'max:100'], + // Where the unit goes: the carton being filled and/or the pallet it lands on. + 'carton_id' => ['nullable', 'integer', 'exists:unit_cartons,id'], + 'pallet_id' => ['nullable', 'integer', 'exists:pallets,id'], + // No carton given but the order packs into cartons: open one for the + // operator (or reuse their open one) instead of dropping the unit loose. + 'auto_carton' => ['nullable', 'boolean'], + // The unit's weight off the scale, when the packing step checks it. + 'weight_g' => ['nullable', 'numeric', 'min:0', 'max:1000000'], + ]; + } +} diff --git a/backend/app/Http/Requests/ScrapUnitRequest.php b/backend/app/Http/Requests/ScrapUnitRequest.php new file mode 100644 index 000000000..7d64f5da5 --- /dev/null +++ b/backend/app/Http/Requests/ScrapUnitRequest.php @@ -0,0 +1,19 @@ + ['required', 'string', 'max:500']]; + } +} diff --git a/backend/app/Http/Requests/StartPackingStepRequest.php b/backend/app/Http/Requests/StartPackingStepRequest.php new file mode 100644 index 000000000..1c6e7614f --- /dev/null +++ b/backend/app/Http/Requests/StartPackingStepRequest.php @@ -0,0 +1,24 @@ + ['nullable', 'array'], + 'picks.*' => ['array'], + 'picks.*.*.material_lot_id' => ['required', 'integer', 'exists:material_lots,id'], + 'picks.*.*.picked_qty' => ['required', 'numeric', 'gt:0'], + ]; + } +} diff --git a/backend/app/Http/Requests/StartUnitRequest.php b/backend/app/Http/Requests/StartUnitRequest.php new file mode 100644 index 000000000..353fe5efc --- /dev/null +++ b/backend/app/Http/Requests/StartUnitRequest.php @@ -0,0 +1,36 @@ +merge(['psn' => app(UnitSerialisation::class)->normalize($this->input('psn'))]); + } + + public function rules(): array + { + $psnRules = app(UnitSerialisation::class)->psnRules(); + $psnRules[0] = 'nullable'; // absent = issue the next number + + return [ + 'psn' => $psnRules, + 'work_order_id' => ['required_without:psn', 'nullable', 'integer', Rule::exists('work_orders', 'id')->whereNull('deleted_at')], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreUserRequest.php b/backend/app/Http/Requests/StoreUserRequest.php new file mode 100644 index 000000000..b00e27e68 --- /dev/null +++ b/backend/app/Http/Requests/StoreUserRequest.php @@ -0,0 +1,101 @@ +withModuleFields([ + 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], + 'username' => ['required', 'string', 'max:255', $this->usernameUniqueness()], + 'email' => ['required', 'string', 'email', 'max:255', $this->emailUniqueness()], + 'password' => $this->passwordRules(), + 'role' => ['required_if:account_type,user', 'nullable', 'exists:roles,name'], + 'account_type' => ['required', 'in:user,workstation'], + 'workstation_id' => ['nullable', 'exists:workstations,id', 'required_if:account_type,workstation'], + 'worker_code' => ['nullable', 'string', 'max:50', $this->workerCodeUniqueness()], + 'worker_phone' => ['nullable', 'string', 'max:50'], + 'worker_crew_id' => ['nullable', 'integer', Rule::in($this->offeredWorkforceIds('crewOptions', $this->currentWorkerValue('crew_id')))], + 'worker_wage_group_id' => ['nullable', 'integer', Rule::in($this->offeredWorkforceIds('wageGroupOptions', $this->currentWorkerValue('wage_group_id')))], + 'skills' => ['nullable', 'array'], + 'skills.*.id' => ['required', Rule::in($this->offeredWorkforceIds('skillOptions'))], + 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], + ]); + } + + public function messages(): array + { + return [ + 'name.regex' => __('Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.'), + ]; + } + + protected function moduleFieldFilter(): string + { + return 'validation.admin.users'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'user' => null]; + } + + protected function usernameUniqueness(): mixed + { + return Rule::unique('users', 'username'); + } + + protected function emailUniqueness(): mixed + { + return Rule::unique('users', 'email'); + } + + protected function workerCodeUniqueness(): mixed + { + return Rule::unique('workers', 'code'); + } + + /** A new account has no password yet, so one must be set. */ + protected function passwordRules(): array + { + return ['required', 'confirmed', Password::defaults()]; + } + + /** + * The worker value this account already holds, if any. + * + * Option lists can be narrower than the table — crewOptions() serves only + * active crews — so an edit of somebody in a since-deactivated crew has to + * keep validating. There is no such record on a create, hence null. + */ + protected function currentWorkerValue(string $attribute): ?int + { + return null; + } +} diff --git a/backend/app/Http/Requests/StoreWorkerRequest.php b/backend/app/Http/Requests/StoreWorkerRequest.php index 000805a7f..48b024a2b 100644 --- a/backend/app/Http/Requests/StoreWorkerRequest.php +++ b/backend/app/Http/Requests/StoreWorkerRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\AllowsModuleFields; use App\Http\Requests\Concerns\MergesCustomFieldRules; use App\Http\Requests\Concerns\ValidatesWorkforceIds; use App\Models\Worker; @@ -10,6 +11,7 @@ class StoreWorkerRequest extends FormRequest { + use AllowsModuleFields; use MergesCustomFieldRules; use ValidatesWorkforceIds; @@ -24,6 +26,16 @@ protected function customFieldEntityType(): string return 'worker'; } + protected function moduleFieldFilter(): string + { + return 'validation.admin.workers'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'worker' => null]; + } + public function rules(): array { // The record being edited may hold a value the pickers no longer @@ -31,7 +43,7 @@ public function rules(): array // value valid stops an unrelated edit from failing on it. $current = null; - return array_merge([ + return $this->withModuleFields(array_merge([ 'code' => ['required', 'string', 'max:50', 'unique:workers,code'], 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'email', 'max:255'], @@ -47,6 +59,6 @@ public function rules(): array 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'integer', Rule::in($this->offeredWorkforceIds('skillOptions'))], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], - ], $this->customFieldRules()); + ], $this->customFieldRules())); } } diff --git a/backend/app/Http/Requests/StoreWorkstationRequest.php b/backend/app/Http/Requests/StoreWorkstationRequest.php new file mode 100644 index 000000000..130384c65 --- /dev/null +++ b/backend/app/Http/Requests/StoreWorkstationRequest.php @@ -0,0 +1,44 @@ + ['required', 'string', 'max:50', 'unique:workstations,code'], + 'name' => ['required', 'string', 'max:255'], + 'workstation_type' => ['nullable', 'string', 'max:100'], + 'is_active' => ['boolean'], + ...self::operatorScreenRules(), + ], app(CustomFieldService::class)->rules('workstation')); + } + + public function attributes(): array + { + return app(CustomFieldService::class)->attributeNames('workstation'); + } + + /** Null or an empty list = screens follow the routing; a list pins them. */ + public static function operatorScreenRules(): array + { + return [ + 'operator_screens' => ['nullable', 'array'], + 'operator_screens.*' => ['string', Rule::in(OperatorScreens::ALL)], + 'unit_label_actions' => ['nullable', 'array'], + 'unit_label_actions.*' => ['string', Rule::in(\App\Support\UnitLabelActions::ALL)], + ]; + } +} diff --git a/backend/app/Http/Requests/UnbindUnitComponentRequest.php b/backend/app/Http/Requests/UnbindUnitComponentRequest.php new file mode 100644 index 000000000..5bb068e73 --- /dev/null +++ b/backend/app/Http/Requests/UnbindUnitComponentRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Requests/UnblockUnitRequest.php b/backend/app/Http/Requests/UnblockUnitRequest.php new file mode 100644 index 000000000..e6b6a787f --- /dev/null +++ b/backend/app/Http/Requests/UnblockUnitRequest.php @@ -0,0 +1,21 @@ +user()?->hasAnyRole(['Supervisor', 'Admin']); + } + + public function rules(): array + { + return [ + 'note' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateIssueRequest.php b/backend/app/Http/Requests/UpdateIssueRequest.php index fd8b1d86d..f8c010303 100644 --- a/backend/app/Http/Requests/UpdateIssueRequest.php +++ b/backend/app/Http/Requests/UpdateIssueRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -use App\Models\Issue; class UpdateIssueRequest extends FormRequest { diff --git a/backend/app/Http/Requests/UpdateSystemSettingsRequest.php b/backend/app/Http/Requests/UpdateSystemSettingsRequest.php index f34fb298a..8701078f6 100644 --- a/backend/app/Http/Requests/UpdateSystemSettingsRequest.php +++ b/backend/app/Http/Requests/UpdateSystemSettingsRequest.php @@ -49,6 +49,10 @@ public function rules(): array 'force_sequential_steps' => 'nullable|boolean', 'workstation_routing_enabled' => 'nullable|boolean', 'backflush_on_pallet_creation' => 'nullable|boolean', + 'hold_on_material_shortage' => 'nullable|boolean', + 'pallet_stock_documents' => ['nullable', Rule::in(\App\Services\Warehouse\PalletStockDocumentService::MODES)], + 'lot_tracking_enabled' => 'nullable|boolean', + 'lot_picking_strategy' => 'nullable|in:fefo,fifo,lifo,manual', 'workflow_mode' => 'required|in:status,board_status', 'pin_login_enabled' => 'nullable|boolean', // Single source of truth — the language switcher's configured locales. @@ -69,6 +73,15 @@ public function rules(): array 'production_qty_edit_policy' => 'required|in:none,timed,full', 'production_qty_edit_window_minutes' => 'required_if:production_qty_edit_policy,timed|integer|min:1|max:60', 'scanner_mode' => 'required|in:hid,manual', + // Serialised units (UnitSerialisation) - patterns are bare regexes. + 'unit_identifier_normalize' => 'nullable|boolean', + 'unit_serial_pattern' => ['nullable', 'string', 'max:200', $this->validRegex()], + 'unit_psn_pattern' => ['nullable', 'string', 'max:200', $this->validRegex()], + 'unit_psn_required' => 'nullable|boolean', + 'unit_psn_unique' => 'nullable|boolean', + 'unit_test_fail_policy' => ['nullable', Rule::in(\App\Support\UnitSerialisation::FAIL_POLICIES)], + 'unit_test_max_attempts' => 'nullable|integer|min:1|max:99', + 'unit_test_attempts_scope' => ['nullable', Rule::in(\App\Support\UnitSerialisation::ATTEMPT_SCOPES)], 'standard_weekly_hours' => 'nullable|numeric|min:1|max:168', 'default_currency' => 'nullable|string|size:3', 'default_pay_type' => 'nullable|in:hourly,weekly,piece_rate', @@ -78,4 +91,14 @@ public function rules(): array 'enabled_modules.*' => ['string', Rule::in(ModuleRegistry::optionalKeys())], ]; } + + /** A pattern the settings page accepts is one PCRE can compile. */ + private function validRegex(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if (! \App\Support\UnitSerialisation::validPattern((string) $value)) { + $fail(__('The :attribute is not a valid regular expression.', ['attribute' => $attribute])); + } + }; + } } diff --git a/backend/app/Http/Requests/UpdateUserRequest.php b/backend/app/Http/Requests/UpdateUserRequest.php new file mode 100644 index 000000000..4041e2b91 --- /dev/null +++ b/backend/app/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,55 @@ + $this->moduleFieldAction(), 'user' => $this->currentUser()]; + } + + protected function usernameUniqueness(): mixed + { + return Rule::unique('users', 'username')->ignore($this->currentUser()?->id); + } + + protected function emailUniqueness(): mixed + { + return Rule::unique('users', 'email')->ignore($this->currentUser()?->id); + } + + protected function workerCodeUniqueness(): mixed + { + // Ignores the worker row this account points at, not the account itself. + return Rule::unique('workers', 'code')->ignore($this->currentUser()?->worker_id); + } + + /** Left blank, the existing password stands. */ + protected function passwordRules(): array + { + return ['nullable', 'confirmed', Password::defaults()]; + } + + protected function currentWorkerValue(string $attribute): ?int + { + $value = $this->currentUser()?->worker?->{$attribute}; + + return $value === null ? null : (int) $value; + } + + private function currentUser(): ?\App\Models\User + { + $user = $this->route('user'); + + return $user instanceof \App\Models\User ? $user : null; + } +} diff --git a/backend/app/Http/Requests/UpdateWorkerRequest.php b/backend/app/Http/Requests/UpdateWorkerRequest.php index f956c427f..b48076086 100644 --- a/backend/app/Http/Requests/UpdateWorkerRequest.php +++ b/backend/app/Http/Requests/UpdateWorkerRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\AllowsModuleFields; use App\Http\Requests\Concerns\MergesCustomFieldRules; use App\Http\Requests\Concerns\ValidatesWorkforceIds; use App\Models\Worker; @@ -10,6 +11,7 @@ class UpdateWorkerRequest extends FormRequest { + use AllowsModuleFields; use MergesCustomFieldRules; use ValidatesWorkforceIds; @@ -24,12 +26,22 @@ protected function customFieldEntityType(): string return 'worker'; } + protected function moduleFieldFilter(): string + { + return 'validation.admin.workers'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'worker' => $this->route('worker')]; + } + public function rules(): array { $current = $this->route('worker'); $workerId = $current?->id; - return array_merge([ + return $this->withModuleFields(array_merge([ 'code' => ['required', 'string', 'max:50', Rule::unique('workers', 'code')->ignore($workerId)], 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'email', 'max:255'], @@ -45,6 +57,6 @@ public function rules(): array 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'integer', Rule::in($this->offeredWorkforceIds('skillOptions'))], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], - ], $this->customFieldRules()); + ], $this->customFieldRules())); } } diff --git a/backend/app/Http/Requests/UpdateWorkstationRequest.php b/backend/app/Http/Requests/UpdateWorkstationRequest.php new file mode 100644 index 000000000..722f31d25 --- /dev/null +++ b/backend/app/Http/Requests/UpdateWorkstationRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', 'max:50', 'unique:workstations,code,'.$this->route('workstation')->id], + 'name' => ['required', 'string', 'max:255'], + 'workstation_type' => ['nullable', 'string', 'max:100'], + 'is_active' => ['boolean'], + 'worker_ids' => ['nullable', 'array'], + 'worker_ids.*' => ['exists:workers,id'], + ...StoreWorkstationRequest::operatorScreenRules(), + ], app(CustomFieldService::class)->rules('workstation')); + } + + public function attributes(): array + { + return app(CustomFieldService::class)->attributeNames('workstation'); + } +} diff --git a/backend/app/Http/Requests/Web/Admin/StoreBomItemRequest.php b/backend/app/Http/Requests/Web/Admin/StoreBomItemRequest.php new file mode 100644 index 000000000..b1f0bb37e --- /dev/null +++ b/backend/app/Http/Requests/Web/Admin/StoreBomItemRequest.php @@ -0,0 +1,62 @@ +route('process_template'); + + return [ + 'material_id' => [ + 'required_without:product_type_id', + 'prohibits:product_type_id', + 'nullable', + 'exists:materials,id', + Rule::unique('bom_items', 'material_id') + ->where('process_template_id', $template->id) + ->whereNull('deleted_at'), + ], + 'product_type_id' => [ + 'required_without:material_id', + 'nullable', + 'exists:product_types,id', + // A product can't be a component of itself. + Rule::notIn([$template->product_type_id]), + Rule::unique('bom_items', 'product_type_id') + ->where('process_template_id', $template->id) + ->whereNull('deleted_at'), + ], + 'template_step_id' => 'nullable|exists:template_steps,id', + 'quantity_per_unit' => 'required|numeric|gt:0', + 'per' => 'nullable|in:unit,carton,pallet', + 'scrap_percentage' => 'nullable|numeric|min:0|max:100', + 'consumed_at' => 'nullable|in:start,during,end', + 'notes' => 'nullable|string', + ]; + } + + public function messages(): array + { + return [ + 'material_id.unique' => __('This material is already in the BOM for this template.'), + 'product_type_id.unique' => __('This product type is already in the BOM for this template.'), + 'product_type_id.not_in' => __('A product type cannot be a component of itself.'), + ]; + } +} diff --git a/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php b/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php index 2303584aa..3f4161cef 100644 --- a/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php +++ b/backend/app/Http/Requests/Web/Admin/TemplateStepRequest.php @@ -24,6 +24,17 @@ public function rules(): array { return [ 'name' => 'required|string|max:255', + 'kind' => ['nullable', Rule::in(TemplateStep::KINDS)], + // Kind-specific configuration. Packing: what the station fills and how much fits. + 'config' => ['nullable', 'array'], + 'config.unit' => ['nullable', Rule::in(TemplateStep::PACKING_UNITS)], + 'config.carton_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.pallet_capacity' => ['nullable', 'integer', 'min:1', 'max:100000'], + 'config.label_template_id' => ['nullable', 'integer', 'exists:label_templates,id'], + // Same as the API: a typo must not switch the scale check on at 0 g. + 'config.unit_label' => ['nullable', 'boolean'], + 'config.weight_expected_g' => ['nullable', 'numeric', 'gt:0', 'max:1000000'], + 'config.weight_tolerance_g' => ['nullable', 'numeric', 'min:0', 'max:1000000'], 'instruction' => 'nullable|string', 'requires_confirmation' => 'boolean', 'estimated_duration_minutes' => 'nullable|integer|min:0', diff --git a/backend/app/Http/Requests/Web/Admin/UpdateBomItemRequest.php b/backend/app/Http/Requests/Web/Admin/UpdateBomItemRequest.php new file mode 100644 index 000000000..23fc5b728 --- /dev/null +++ b/backend/app/Http/Requests/Web/Admin/UpdateBomItemRequest.php @@ -0,0 +1,26 @@ + 'nullable|exists:template_steps,id', + 'quantity_per_unit' => 'required|numeric|gt:0', + 'per' => 'nullable|in:unit,carton,pallet', + 'scrap_percentage' => 'nullable|numeric|min:0|max:100', + 'consumed_at' => 'nullable|in:start,during,end', + 'notes' => 'nullable|string', + ]; + } +} diff --git a/backend/app/Import/ImportRegistry.php b/backend/app/Import/ImportRegistry.php index f4f466c46..de529819c 100644 --- a/backend/app/Import/ImportRegistry.php +++ b/backend/app/Import/ImportRegistry.php @@ -35,13 +35,35 @@ class ImportRegistry /** @var array|null key => instance */ private ?array $instances = null; + /** The class list the cached instances were built from. */ + private array $instancesFor = []; + + /** + * Every importer, including any an installed module registered. + * + * The constant stays the definition; this is the one seam through which a + * module adds an entity to the Import screen, from its provider's boot(): + * app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]); + * + * @return list> + */ + public static function entities(): array + { + return app(\App\Extension\FilterRegistry::class)->filter('import.entities', self::ENTITIES); + } + /** @return array key => importer */ public function all(): array { - if ($this->instances === null) { + // Rebuilt whenever the entity list changes — a module registered after + // the first call (tests, a late-booting provider) must still appear. + $entities = self::entities(); + + if ($this->instances === null || $this->instancesFor !== $entities) { $this->instances = []; + $this->instancesFor = $entities; - foreach (self::ENTITIES as $class) { + foreach ($entities as $class) { $importer = app($class); $this->instances[$importer->key()] = $importer; } diff --git a/backend/app/Models/BatchStep.php b/backend/app/Models/BatchStep.php index 3d03990fb..5a78d23a3 100644 --- a/backend/app/Models/BatchStep.php +++ b/backend/app/Models/BatchStep.php @@ -32,6 +32,8 @@ class BatchStep extends Model 'batch_id', 'step_number', 'name', + 'kind', + 'config', 'instruction', 'requires_confirmation', 'passed_qty', @@ -69,6 +71,7 @@ protected function casts(): array 'setup_time_minutes' => 'integer', 'run_time_per_unit_minutes' => 'decimal:2', 'is_optional' => 'boolean', + 'config' => 'array', 'requires_confirmation' => 'boolean', 'started_at' => 'datetime', 'completed_at' => 'datetime', @@ -288,6 +291,11 @@ public function markReadConfirmed(User $user): void * off, or a step whose immediate predecessor is DONE/SKIPPED. Does NOT factor * in work-order blocking — that's re-checked at start time. */ + public function isPacking(): bool + { + return $this->kind === TemplateStep::KIND_PACKING; + } + public function prerequisitesMet(): bool { if (! config('openmmes.force_sequential_steps', true)) { diff --git a/backend/app/Models/BomItem.php b/backend/app/Models/BomItem.php index 5f2742c46..4bae4ad62 100644 --- a/backend/app/Models/BomItem.php +++ b/backend/app/Models/BomItem.php @@ -12,12 +12,22 @@ class BomItem extends Model use HasFactory; use SoftDeletesWithAudit; + /** What `quantity_per_unit` is counted per: a finished unit, or a carton / pallet of a packing step. */ + public const PER_UNIT = 'unit'; + + public const PER_CARTON = 'carton'; + + public const PER_PALLET = 'pallet'; + + public const PER = [self::PER_UNIT, self::PER_CARTON, self::PER_PALLET]; + protected $fillable = [ 'process_template_id', 'template_step_id', 'material_id', 'product_type_id', 'quantity_per_unit', + 'per', 'scrap_percentage', 'consumed_at', 'sort_order', @@ -67,12 +77,42 @@ public function getComponentKindAttribute(): string return $this->product_type_id ? 'product_type' : 'material'; } + /** + * How many finished units one "per" unit of this line covers: 1 for a + * per-unit line, the carton size for a per-carton line, the carton size + * times the cartons per pallet for a per-pallet line - read from the + * packing step the line is attached to. A step without the capacity set + * cannot convert, so the quantity is taken as per unit. + */ + public function basisDivisor(): float + { + if ($this->per === self::PER_UNIT || $this->per === null) { + return 1.0; + } + $config = $this->templateStep?->config ?? []; + $carton = (float) ($config['carton_capacity'] ?? 0); + $pallet = (float) ($config['pallet_capacity'] ?? 0); + if ($this->per === self::PER_CARTON) { + return $carton > 0 ? $carton : 1.0; + } + // Per pallet: cartons of units, or loose units straight onto the pallet. + $unitsPerPallet = ($config['unit'] ?? 'carton') === 'pallet' ? $pallet : $carton * $pallet; + + return $unitsPerPallet > 0 ? $unitsPerPallet : 1.0; + } + + /** The line's quantity expressed per finished unit - what planning, allocation and backflush work with. */ + public function perUnitQuantity(): float + { + return round((float) $this->quantity_per_unit / $this->basisDivisor(), 6); + } + /** * Calculate required quantity including scrap for given production quantity. */ public function calculateRequiredQuantity(float $productionQty): float { - $base = $this->quantity_per_unit * $productionQty; + $base = $this->perUnitQuantity() * $productionQty; $scrap = $base * ($this->scrap_percentage / 100); return round($base + $scrap, 4); diff --git a/backend/app/Models/LabelTemplate.php b/backend/app/Models/LabelTemplate.php index a78d61fd1..fcc644ded 100644 --- a/backend/app/Models/LabelTemplate.php +++ b/backend/app/Models/LabelTemplate.php @@ -20,11 +20,21 @@ class LabelTemplate extends Model const TYPE_PALLET = 'pallet'; + const TYPE_SERIAL_UNIT = 'serial_unit'; + + const TYPE_CARTON = 'carton'; + + /** A received material lot after incoming inspection (the IQC label). */ + const TYPE_MATERIAL_LOT = 'material_lot'; + const TYPES = [ self::TYPE_WORK_ORDER => 'Work Order', self::TYPE_FINISHED_GOODS => 'Finished Goods', self::TYPE_WORKSTATION_STEP => 'Workstation Step', self::TYPE_PALLET => 'Pallet', + self::TYPE_SERIAL_UNIT => 'Serial Unit (SN)', + self::TYPE_CARTON => 'Carton (units list)', + self::TYPE_MATERIAL_LOT => 'Material lot (IQC)', ]; const SIZES = [ @@ -44,12 +54,17 @@ class LabelTemplate extends Model const AVAILABLE_FIELDS = [ 'wo_number' => 'Work order number', 'pallet_no' => 'Pallet number', + 'carton_no' => 'Carton number', + 'serial_no' => 'Serial number (SN)', + 'psn' => 'Process serial (PSN)', 'product' => 'Product name', 'quantity' => 'Quantity', 'barcode' => 'Barcode (1D)', 'qr' => 'QR code', - 'logo' => 'Logo', 'lot' => 'Lot number', + 'material' => 'Material', + 'status' => 'Inspection status', + 'supplier_lot' => 'Supplier lot', 'location' => 'Location', 'prod_date' => 'Production date', ]; @@ -76,6 +91,20 @@ protected function casts(): array ]; } + /** + * The fields a label of this type can show, in the form's order: what its + * layout actually prints, so a template does not offer a pallet number on + * a unit label. + * + * @return array + */ + public static function fieldsForType(string $type): array + { + $known = array_keys(self::defaultFieldsFor($type)); + + return array_values(array_filter(array_keys(self::AVAILABLE_FIELDS), fn ($k) => in_array($k, $known, true))); + } + public static function defaultFieldsFor(string $type): array { return match ($type) { @@ -85,7 +114,6 @@ public static function defaultFieldsFor(string $type): array 'quantity' => true, 'barcode' => true, 'qr' => false, - 'logo' => false, 'lot' => false, 'prod_date' => false, ], @@ -95,7 +123,6 @@ public static function defaultFieldsFor(string $type): array 'quantity' => true, 'barcode' => true, 'qr' => true, - 'logo' => false, 'lot' => true, 'prod_date' => true, ], @@ -105,7 +132,6 @@ public static function defaultFieldsFor(string $type): array 'quantity' => false, 'barcode' => true, 'qr' => false, - 'logo' => false, 'lot' => false, 'prod_date' => false, ], @@ -116,11 +142,42 @@ public static function defaultFieldsFor(string $type): array 'quantity' => true, 'barcode' => true, 'qr' => true, - 'logo' => false, 'lot' => false, 'location' => true, 'prod_date' => true, ], + self::TYPE_SERIAL_UNIT => [ + 'serial_no' => true, + 'psn' => true, + 'wo_number' => true, + 'product' => true, + 'quantity' => false, + 'barcode' => true, + 'qr' => true, + 'lot' => false, + 'location' => false, + 'prod_date' => true, + ], + self::TYPE_CARTON => [ + 'carton_no' => true, + 'wo_number' => true, + 'product' => true, + 'quantity' => true, + 'barcode' => true, + 'qr' => true, + 'lot' => false, + 'prod_date' => true, + ], + self::TYPE_MATERIAL_LOT => [ + 'lot' => true, + 'material' => true, + 'quantity' => true, + 'status' => true, + 'supplier_lot' => true, + 'barcode' => true, + 'qr' => true, + 'prod_date' => true, + ], default => array_fill_keys(array_keys(self::AVAILABLE_FIELDS), false), }; } diff --git a/backend/app/Models/LotSequence.php b/backend/app/Models/LotSequence.php index 26c62b1b6..c44c9f081 100644 --- a/backend/app/Models/LotSequence.php +++ b/backend/app/Models/LotSequence.php @@ -17,9 +17,19 @@ class LotSequence extends Model public const RESET_PERIODS = ['none', 'yearly', 'monthly', 'daily', 'hourly']; + /** What the sequence numbers: batch LOTs, or the identifiers a serialised unit carries. */ + public const PURPOSE_LOT = 'lot'; + + public const PURPOSE_PROCESS_SERIAL = 'process_serial'; + + public const PURPOSE_UNIT_SERIAL = 'unit_serial'; + + public const PURPOSES = [self::PURPOSE_LOT, self::PURPOSE_PROCESS_SERIAL, self::PURPOSE_UNIT_SERIAL]; + protected $fillable = [ 'name', 'product_type_id', + 'purpose', 'prefix', 'suffix', 'pattern', diff --git a/backend/app/Models/Pallet.php b/backend/app/Models/Pallet.php index 9fa9324d4..6a632957f 100644 --- a/backend/app/Models/Pallet.php +++ b/backend/app/Models/Pallet.php @@ -37,11 +37,15 @@ class Pallet extends Model 'arrived_at', 'erp_reference', 'tenant_id', + 'active_by_id', + 'active_workstation_id', + 'activated_at', ]; protected function casts(): array { return [ + 'activated_at' => 'datetime', 'status' => PalletStatus::class, 'qty' => 'integer', 'shipped_at' => 'datetime', @@ -77,6 +81,18 @@ protected static function booted(): void $pallet->shipped_at = now(); } }); + + // Whatever screen ships the pallet, the serialised units on it leave with it. + static::saved(function (self $pallet): void { + if ($pallet->wasChanged('status') && $pallet->status === PalletStatus::Shipped) { + app(\App\Services\Packaging\UnitPackingService::class)->markPalletShipped($pallet, auth()->user()); + } + // Finished goods in and out of the warehouse, pallet by pallet (when the plant books them so) - + // also for a pallet recorded straight as closed or shipped. + if (($pallet->wasChanged('status') || $pallet->wasRecentlyCreated) && in_array($pallet->status, [PalletStatus::Closed, PalletStatus::Shipped], true)) { + app(\App\Services\Warehouse\PalletStockDocumentService::class)->statusChanged($pallet, auth()->user()); + } + }); } /** @@ -86,10 +102,16 @@ protected static function booted(): void public function recomputeQualityStatus(): void { $checks = $this->qualityChecks()->get(['all_passed']); + $units = $this->unitVerdicts(); + // Serialised goods are tested one by one: the pallet is as good as its + // worst unit. A pallet-level check (a failed one) still overrides. $status = match (true) { - $checks->isEmpty() => self::QUALITY_PENDING, $checks->contains(fn ($c) => ! $c->all_passed) => self::QUALITY_FAIL, + $units !== null && in_array(self::QUALITY_FAIL, $units, true) => self::QUALITY_FAIL, + $units !== null && in_array(self::QUALITY_PENDING, $units, true) => self::QUALITY_PENDING, + $units !== null => self::QUALITY_PASS, + $checks->isEmpty() => self::QUALITY_PENDING, default => self::QUALITY_PASS, }; @@ -107,6 +129,39 @@ public function recomputeQualityStatus(): void * tests) the sequence does not exist, so we derive the next number from the * highest existing pallet_no — sufficient for the single-threaded test path. */ + /** + * One verdict per serialised unit on the pallet (scrap excluded): its most + * recent test result, `pending` when it was never tested; null when the + * pallet carries no serialised units at all. + * + * @return array|null + */ + private function unitVerdicts(): ?array + { + $units = SerialUnit::where('pallet_id', $this->id) + ->where('status', '!=', SerialUnit::STATUS_SCRAPPED) + // The relation orders history oldest-first; drop that so the latest verdict comes first. + ->with(['history' => fn ($q) => $q->whereNotNull('result')->reorder()->orderByDesc('processed_at')->orderByDesc('id')]) + ->get(); + + if ($units->isEmpty()) { + return null; + } + + return $units->map(function (SerialUnit $unit) { + if ($unit->status === SerialUnit::STATUS_BLOCKED) { + return self::QUALITY_FAIL; + } + $latest = $unit->history->first(fn ($h) => ($h->parameters['event'] ?? null) !== 'scrapped'); + + return match ($latest?->result) { + 'pass' => self::QUALITY_PASS, + 'fail' => self::QUALITY_FAIL, + default => self::QUALITY_PENDING, + }; + })->values()->all(); + } + public static function nextPalletNo(): string { if (DB::connection()->getDriverName() === 'pgsql') { @@ -156,6 +211,16 @@ public function stockMovements(): HasMany } /** Append-only trail of physical moves and destination changes (#101, #103). */ + public function cartons(): HasMany + { + return $this->hasMany(UnitCarton::class); + } + + public function units(): HasMany + { + return $this->hasMany(SerialUnit::class); + } + public function movements(): HasMany { return $this->hasMany(PalletMovement::class); @@ -175,4 +240,15 @@ public function isInTransit(): bool { return $this->destination !== null; } + + /** The operator and bench currently filling this one - shared state, so every packing screen agrees. */ + public function activeBy(): BelongsTo + { + return $this->belongsTo(User::class, 'active_by_id'); + } + + public function activeWorkstation(): BelongsTo + { + return $this->belongsTo(Workstation::class, 'active_workstation_id'); + } } diff --git a/backend/app/Models/ProcessTemplate.php b/backend/app/Models/ProcessTemplate.php index f476d170c..3f8759210 100644 --- a/backend/app/Models/ProcessTemplate.php +++ b/backend/app/Models/ProcessTemplate.php @@ -101,6 +101,8 @@ public function toSnapshot(): array return [ 'step_number' => $step->step_number, 'name' => $step->name, + 'kind' => $step->kind ?? TemplateStep::KIND_PRODUCTION, + 'config' => $step->config, 'instruction' => $step->instruction, 'requires_confirmation' => (bool) $step->requires_confirmation, 'estimated_duration_minutes' => $step->estimated_duration_minutes, @@ -146,7 +148,11 @@ public function toSnapshot(): array 'material_type' => $item->material->materialType?->code, 'tracking_type' => $item->material->tracking_type, 'unit_of_measure' => $item->material->unit_of_measure, - 'quantity_per_unit' => (float) $item->quantity_per_unit, + // Per finished unit whatever the line was typed per: the packing + // step's capacities are folded in here, once, at freeze time. + 'quantity_per_unit' => $item->perUnitQuantity(), + 'per' => $item->per ?? BomItem::PER_UNIT, + 'quantity_per_basis' => (float) $item->quantity_per_unit, 'scrap_percentage' => (float) $item->scrap_percentage, 'consumed_at' => $item->consumed_at, 'step_number' => $item->templateStep?->step_number, diff --git a/backend/app/Models/SerialUnit.php b/backend/app/Models/SerialUnit.php index a4626de8f..2b338f7f6 100644 --- a/backend/app/Models/SerialUnit.php +++ b/backend/app/Models/SerialUnit.php @@ -9,9 +9,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; /** - * A uniquely identified physical unit (serial number). Its full process - * history — every workstation it passed through, with operator and parameter - * snapshots — lives in UnitStepHistory. + * A uniquely identified physical unit. It is known by its serial number (SN) + * and/or its process serial (PSN): a line may number the unit with a PSN at + * its first station and give it the SN later, with the product label. Its full + * process history — every workstation it passed through, with operator and + * parameter snapshots — lives in UnitStepHistory. */ class SerialUnit extends Model { @@ -26,20 +28,32 @@ class SerialUnit extends Model public const STATUS_SHIPPED = 'shipped'; + /** Held after repeated failed tests - not scrap yet, but nothing downstream may take it. */ + public const STATUS_BLOCKED = 'blocked'; + public const STATUSES = [ self::STATUS_IN_PRODUCTION, self::STATUS_COMPLETED, self::STATUS_SCRAPPED, self::STATUS_SHIPPED, + self::STATUS_BLOCKED, ]; + /** Statuses in which a unit is off the line: no further binding, testing or packing. */ + public const TERMINAL_STATUSES = [self::STATUS_SCRAPPED, self::STATUS_SHIPPED]; + protected $fillable = [ 'serial_no', + 'psn', 'work_order_id', 'batch_id', + 'carton_id', + 'pallet_id', 'material_id', 'status', 'produced_at', + 'packed_at', + 'shipped_at', 'tenant_id', 'extra_data', ]; @@ -48,10 +62,45 @@ protected function casts(): array { return [ 'produced_at' => 'datetime', + 'packed_at' => 'datetime', + 'shipped_at' => 'datetime', 'extra_data' => 'array', ]; } + /** The status as people read it (the same words the screens use). */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_IN_PRODUCTION => __('In production'), + self::STATUS_COMPLETED => __('Unit completed'), + self::STATUS_SCRAPPED => __('Scrapped'), + self::STATUS_SHIPPED => __('Shipped'), + self::STATUS_BLOCKED => __('Blocked'), + default => (string) $this->status, + }; + } + + /** How the unit is named on screens and in messages: its SN, else its PSN. */ + public function getDisplayIdAttribute(): string + { + return (string) ($this->serial_no ?? $this->psn ?? '#'.$this->id); + } + + /** + * The unit a scan names, by SN or PSN (already normalised by the caller). + * An SN match wins; among PSN matches the newest unit is the one on the bench. + */ + public static function findByIdentifier(?string $identifier): ?self + { + if ($identifier === null || $identifier === '') { + return null; + } + + return static::where('serial_no', $identifier)->first() + ?? static::where('psn', $identifier)->orderByDesc('id')->first(); + } + public function workOrder(): BelongsTo { return $this->belongsTo(WorkOrder::class); @@ -67,8 +116,31 @@ public function material(): BelongsTo return $this->belongsTo(Material::class); } + public function carton(): BelongsTo + { + return $this->belongsTo(UnitCarton::class, 'carton_id'); + } + + public function pallet(): BelongsTo + { + return $this->belongsTo(Pallet::class); + } + + /** Components bound into this unit, the removed ones included (closed by unbound_at). */ + public function components(): HasMany + { + return $this->hasMany(SerialUnitComponent::class, 'serial_unit_id'); + } + + /** Where this unit sits as a component of another (a sub-assembly's parents). */ + public function installedIn(): HasMany + { + return $this->hasMany(SerialUnitComponent::class, 'component_serial_unit_id'); + } + public function history(): HasMany { - return $this->hasMany(UnitStepHistory::class)->orderBy('processed_at'); + // By id within one moment: a hold is stamped with the fail that caused it. + return $this->hasMany(UnitStepHistory::class)->orderBy('processed_at')->orderBy('id'); } } diff --git a/backend/app/Models/SerialUnitComponent.php b/backend/app/Models/SerialUnitComponent.php new file mode 100644 index 000000000..ef4da102c --- /dev/null +++ b/backend/app/Models/SerialUnitComponent.php @@ -0,0 +1,80 @@ + 'decimal:4', + 'bound_at' => 'datetime', + 'unbound_at' => 'datetime', + ]; + } + + public function scopeInstalled(Builder $query): Builder + { + return $query->whereNull('unbound_at'); + } + + public function unit(): BelongsTo + { + return $this->belongsTo(SerialUnit::class, 'serial_unit_id'); + } + + /** The component when it is a serialised unit of its own (a sub-assembly). */ + public function componentUnit(): BelongsTo + { + return $this->belongsTo(SerialUnit::class, 'component_serial_unit_id'); + } + + public function materialLot(): BelongsTo + { + return $this->belongsTo(MaterialLot::class); + } + + public function material(): BelongsTo + { + return $this->belongsTo(Material::class); + } + + public function workstation(): BelongsTo + { + return $this->belongsTo(Workstation::class); + } + + public function boundBy(): BelongsTo + { + return $this->belongsTo(User::class, 'bound_by_id'); + } +} diff --git a/backend/app/Models/StockDocument.php b/backend/app/Models/StockDocument.php index 340a180de..ac0e7543e 100644 --- a/backend/app/Models/StockDocument.php +++ b/backend/app/Models/StockDocument.php @@ -63,6 +63,7 @@ class StockDocument extends Model 'warehouse_id', 'work_order_id', 'batch_id', + 'pallet_id', 'notes', 'erp_reference', 'erp_synced_at', @@ -170,4 +171,10 @@ public static function warehouseKindFor(string $type): string ? Warehouse::KIND_RAW_MATERIAL : Warehouse::KIND_FINISHED_GOODS; } + + /** The pallet this document books, when it was booked pallet by pallet. */ + public function pallet(): BelongsTo + { + return $this->belongsTo(Pallet::class); + } } diff --git a/backend/app/Models/TemplateStep.php b/backend/app/Models/TemplateStep.php index e3b5f1910..0b7673006 100644 --- a/backend/app/Models/TemplateStep.php +++ b/backend/app/Models/TemplateStep.php @@ -10,6 +10,52 @@ class TemplateStep extends Model { + /** + * What kind of work the step is. Production is the default and what every + * step was until packing became a step of its own: one that the packing + * station executes, with its configuration (unit / carton / pallet, how many + * per box, which label) instead of one behaviour for the whole plant. + */ + public const KIND_PRODUCTION = 'production'; + + public const KIND_PACKING = 'packing'; + + public const KINDS = [self::KIND_PRODUCTION, self::KIND_PACKING]; + + /** Packing container levels, innermost first. */ + public const PACKING_UNITS = ['unit', 'carton', 'pallet']; + + /** + * A packing step's config as stored: only the keys the step declares, numbers + * as numbers (forms post strings), the container level defaulting to a carton. + * Shared by the web and API step requests so both write the same shape. + * + * @param array $config + * @return array + */ + public static function normalisePackingConfig(array $config): array + { + $int = fn ($v) => ($v === null || $v === '') ? null : (int) $v; + $num = fn ($v) => ($v === null || $v === '') ? null : round((float) $v, 2); + $out = [ + 'unit' => in_array($config['unit'] ?? null, self::PACKING_UNITS, true) ? $config['unit'] : 'carton', + 'carton_capacity' => $int($config['carton_capacity'] ?? null), + 'pallet_capacity' => $int($config['pallet_capacity'] ?? null), + 'label_template_id' => $int($config['label_template_id'] ?? null), + // Print a label for every unit scanned here (a box label read off the + // unit's serial). On by default; off, the label shows only for a unit + // that has none yet. Stored only when switched off. + 'unit_label' => isset($config['unit_label']) && ! filter_var($config['unit_label'], FILTER_VALIDATE_BOOLEAN) ? false : null, + // Weight check: each unit is weighed before it goes into the box; set + // the expected weight to switch it on, the tolerance says how far off + // it may be (grams). + 'weight_expected_g' => $num($config['weight_expected_g'] ?? null), + 'weight_tolerance_g' => isset($config['weight_expected_g']) && $config['weight_expected_g'] !== '' ? ($num($config['weight_tolerance_g'] ?? null) ?? 0.0) : null, + ]; + + return array_filter($out, fn ($v) => $v !== null); + } + use HasFactory; use SoftDeletesWithAudit; @@ -20,6 +66,8 @@ class TemplateStep extends Model 'process_segment_id', 'step_number', 'name', + 'kind', + 'config', 'instruction', 'estimated_duration_minutes', 'required_operators', @@ -43,6 +91,7 @@ protected function casts(): array 'setup_time_minutes' => 'integer', 'run_time_per_unit_minutes' => 'decimal:2', 'parameters' => 'array', + 'config' => 'array', 'required_operators' => 'integer', 'min_duration_minutes' => 'integer', 'requires_confirmation' => 'boolean', diff --git a/backend/app/Models/UnitCarton.php b/backend/app/Models/UnitCarton.php new file mode 100644 index 000000000..3d9b674a4 --- /dev/null +++ b/backend/app/Models/UnitCarton.php @@ -0,0 +1,123 @@ + 'datetime', + 'qty' => 'integer', + 'closed_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $carton): void { + if (empty($carton->carton_no)) { + $carton->carton_no = self::nextCartonNo(); + } + }); + } + + /** + * CTN-000001, CTN-000002… from the highest number in use. Cartons are + * opened by one packing station at a time, so a max+1 under the creating + * transaction is enough; the unique index catches the rest. + */ + /** + * The next carton number. Several benches open cartons at once, so on + * Postgres it comes from a sequence (like pallet numbers) rather than + * MAX+1, which two stations can compute identically; other drivers (sqlite + * in tests) derive it from the table. + */ + public static function nextCartonNo(): string + { + if (DB::connection()->getDriverName() === 'pgsql') { + $next = (int) DB::selectOne("SELECT nextval('unit_cartons_carton_no_seq') AS n")->n; + } else { + $max = static::withoutGlobalScopes() + ->where('carton_no', 'like', 'CTN-%') + ->selectRaw('MAX(CAST(SUBSTR(carton_no, 5) AS INTEGER)) AS n') + ->value('n'); + $next = ((int) $max) + 1; + } + + return 'CTN-'.str_pad((string) $next, 6, '0', STR_PAD_LEFT); + } + + public function scopeOpen(Builder $query): Builder + { + return $query->where('status', self::STATUS_OPEN); + } + + public function isOpen(): bool + { + return $this->status === self::STATUS_OPEN; + } + + public function units(): HasMany + { + return $this->hasMany(SerialUnit::class, 'carton_id'); + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function pallet(): BelongsTo + { + return $this->belongsTo(Pallet::class); + } + + public function closedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by_id'); + } + + /** The operator and bench currently filling this one - shared state, so every packing screen agrees. */ + public function activeBy(): BelongsTo + { + return $this->belongsTo(User::class, 'active_by_id'); + } + + public function activeWorkstation(): BelongsTo + { + return $this->belongsTo(Workstation::class, 'active_workstation_id'); + } +} diff --git a/backend/app/Models/WorkOrder.php b/backend/app/Models/WorkOrder.php index f1e78ef83..ce0e4368d 100644 --- a/backend/app/Models/WorkOrder.php +++ b/backend/app/Models/WorkOrder.php @@ -559,6 +559,12 @@ public function allocations(): HasMany return $this->hasMany(MaterialAllocation::class); } + /** Serialised units built for this order (unit-level tracking). */ + public function serialUnits(): HasMany + { + return $this->hasMany(SerialUnit::class); + } + /** Pallets packed for this work order. */ public function pallets(): HasMany { diff --git a/backend/app/Models/Workstation.php b/backend/app/Models/Workstation.php index a93d4f409..0f79c8d14 100644 --- a/backend/app/Models/Workstation.php +++ b/backend/app/Models/Workstation.php @@ -20,6 +20,8 @@ class Workstation extends Model 'code', 'name', 'workstation_type', + 'operator_screens', + 'unit_label_actions', 'ideal_rate_per_hour', 'is_active', ]; @@ -29,6 +31,8 @@ protected function casts(): array return [ 'is_active' => 'boolean', 'ideal_rate_per_hour' => 'decimal:2', + 'operator_screens' => 'array', + 'unit_label_actions' => 'array', ]; } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 06499d2b9..57a37cefb 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -25,6 +25,10 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ModuleManager::class, fn () => new ModuleManager); + // Request-scoped: reads its settings once per request, so Octane sees a fresh copy each time. + $this->app->scoped(\App\Support\UnitSerialisation::class); + // Request-scoped too: its per-bench cache is shared by the shared prop and the controllers. + $this->app->scoped(\App\Services\Production\OperatorScreens::class); $this->app->singleton(MenuRegistry::class, fn () => new MenuRegistry); $this->app->singleton(WidgetRegistry::class, fn () => new WidgetRegistry); // Singletons, like the two registries above, and for the same Octane diff --git a/backend/app/Rules/ValidLotPattern.php b/backend/app/Rules/ValidLotPattern.php index 7a5ed0b2f..6b90e2df2 100644 --- a/backend/app/Rules/ValidLotPattern.php +++ b/backend/app/Rules/ValidLotPattern.php @@ -11,7 +11,7 @@ class ValidLotPattern implements ValidationRule public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value)) { - $fail('The :attribute must be a string.'); + $fail(__('The :attribute must be a string.')); return; } diff --git a/backend/app/Services/Lot/LotPatternFormatter.php b/backend/app/Services/Lot/LotPatternFormatter.php index 13454d7a1..f229e760d 100644 --- a/backend/app/Services/Lot/LotPatternFormatter.php +++ b/backend/app/Services/Lot/LotPatternFormatter.php @@ -9,21 +9,23 @@ * * Supported tokens: * [seq] sequence number, zero-padded to pad_size - * [date] date as Ymd (e.g. 20260606) - * [date:FMT] date with a custom PHP date format (e.g. [date:y-m-d]) - * [year] 4-digit year - * [month] 2-digit month - * [day] 2-digit day of month + * [year] 4-digit year [year2] 2-digit year [year1] last digit of the year + * [month] 2-digit month [day] 2-digit day of month + * [doy] 3-digit day of the year, 1-based (Jan 1 -> 001) + * [week] 2-digit ISO week [weekday] 1-7, Monday = 1 * [hour] 2-digit hour (24h) * [product] product type code (empty string when no product type) + * [date] date as Ymd (e.g. 20260606) + * [date:FMT] advanced: any PHP date format (e.g. [date:y-m-d]); aliases + * y1 = last digit of the year, o1 = last digit of the ISO year * * Anything outside [] is a literal. A valid pattern contains exactly one [seq]. */ class LotPatternFormatter { - public const TOKENS = ['seq', 'date', 'year', 'month', 'day', 'hour', 'product']; + public const TOKENS = ['seq', 'year', 'year2', 'year1', 'month', 'day', 'doy', 'week', 'weekday', 'hour', 'product', 'date']; - private const TOKEN_REGEX = '/\[([a-z]+)(?::([^\]]+))?\]/'; + private const TOKEN_REGEX = '/\[([a-z][a-z0-9]*)(?::([^\]]+))?\]/'; /** * Render a pattern into a LOT number. @@ -33,10 +35,20 @@ public function format(string $pattern, int $number, int $padSize, ?string $prod return preg_replace_callback(self::TOKEN_REGEX, function (array $m) use ($number, $padSize, $productCode, $now) { return match ($m[1]) { 'seq' => str_pad((string) $number, $padSize, '0', STR_PAD_LEFT), - 'date' => $now->format($m[2] ?? 'Ymd'), + // A plain [date] has no format group at all, so read it defensively. + 'date' => match ($m[2] ?? 'Ymd') { + 'y1' => (string) ($now->year % 10), + 'o1' => (string) ((int) $now->format('o') % 10), + default => $now->format($m[2] ?? 'Ymd'), + }, 'year' => $now->format('Y'), + 'year2' => $now->format('y'), + 'year1' => (string) ($now->year % 10), 'month' => $now->format('m'), 'day' => $now->format('d'), + 'doy' => str_pad((string) ($now->dayOfYear), 3, '0', STR_PAD_LEFT), + 'week' => $now->format('W'), + 'weekday' => $now->format('N'), 'hour' => $now->format('H'), 'product' => $productCode ?? '', default => $m[0], // unreachable for validated patterns @@ -44,6 +56,37 @@ public function format(string $pattern, int $number, int $padSize, ?string $prod }, $pattern); } + /** + * The tokens as the pattern editor lists them: what each means and what it + * gives right now, so nobody has to know a date-format letter to build + * "day of year - year - counter". + * + * @return array + */ + public function describe(CarbonInterface $now, int $padSize = 4): array + { + $labels = [ + 'seq' => __('Counter, zero-padded to the pad size'), + 'year' => __('Year, 4 digits'), + 'year2' => __('Year, 2 digits'), + 'year1' => __('Year, last digit'), + 'month' => __('Month, 2 digits'), + 'day' => __('Day of month, 2 digits'), + 'doy' => __('Day of year, 3 digits'), + 'week' => __('ISO week, 2 digits'), + 'weekday' => __('Weekday, 1 = Monday … 7 = Sunday'), + 'hour' => __('Hour, 24h'), + 'product' => __('Product type code'), + 'date' => __('Date as YYYYMMDD'), + ]; + + return array_map(fn (string $token) => [ + 'token' => $token, + 'label' => $labels[$token], + 'example' => $token === 'product' ? __('e.g. :code', ['code' => 'PT-01']) : $this->format("[{$token}]", 1, $padSize, null, $now), + ], self::TOKENS); + } + /** * Validate a pattern. Returns a list of error messages (empty = valid). * @@ -57,25 +100,25 @@ public function validate(string $pattern): array $unknown = array_diff(array_unique($matches[1]), self::TOKENS); if ($unknown) { - $errors[] = 'Unknown tokens: ['.implode('], [', $unknown).']. Allowed: ['.implode('], [', self::TOKENS).'].'; + $errors[] = __('Unknown tokens: :tokens. Allowed: :allowed.', ['tokens' => '['.implode('], [', $unknown).']', 'allowed' => '['.implode('], [', self::TOKENS).']']); } $seqCount = count(array_keys($matches[1], 'seq', true)); if ($seqCount !== 1) { - $errors[] = 'Pattern must contain exactly one [seq] token.'; + $errors[] = __('Pattern must contain exactly one [seq] token.'); } // A format argument is only meaningful on [date] foreach ($matches[1] as $i => $token) { if ($token !== 'date' && ($matches[2][$i] ?? '') !== '') { - $errors[] = "Token [{$token}] does not accept a format argument."; + $errors[] = __('Token [:token] does not accept a format argument.', ['token' => $token]); } } // Stray brackets left after removing valid token syntax $stripped = preg_replace(self::TOKEN_REGEX, '', $pattern); if (str_contains($stripped, '[') || str_contains($stripped, ']')) { - $errors[] = 'Pattern contains unmatched or malformed brackets.'; + $errors[] = __('Pattern contains unmatched or malformed brackets.'); } return $errors; diff --git a/backend/app/Services/Lot/LotService.php b/backend/app/Services/Lot/LotService.php index b083b2191..fe3f069ae 100644 --- a/backend/app/Services/Lot/LotService.php +++ b/backend/app/Services/Lot/LotService.php @@ -14,11 +14,22 @@ class LotService */ public function generateLot(?ProductType $productType = null): string { - $sequence = $this->findSequence($productType); + return $this->generate($productType, LotSequence::PURPOSE_LOT); + } + + /** + * The next number from the sequence serving this purpose: the product's + * own, else the global one for that purpose. + * + * @throws \RuntimeException when no sequence serves the purpose + */ + public function generate(?ProductType $productType, string $purpose): string + { + $sequence = $this->findSequence($productType, $purpose); if (! $sequence) { throw new \RuntimeException( - 'No LOT sequence configured'.($productType ? " for product type: {$productType->name}" : '') + 'No '.self::purposeLabel($purpose).' sequence configured'.($productType ? " for product type: {$productType->name}" : '') ); } @@ -26,13 +37,20 @@ public function generateLot(?ProductType $productType = null): string } /** - * Preview the next LOT number without incrementing. + * Preview the next number without incrementing. */ - public function previewNext(?ProductType $productType = null): ?string + public function previewNext(?ProductType $productType = null, string $purpose = LotSequence::PURPOSE_LOT): ?string { - $sequence = $this->findSequence($productType); + return $this->findSequence($productType, $purpose)?->previewNext(); + } - return $sequence?->previewNext(); + public static function purposeLabel(string $purpose): string + { + return match ($purpose) { + LotSequence::PURPOSE_PROCESS_SERIAL => 'process serial', + LotSequence::PURPOSE_UNIT_SERIAL => 'unit serial', + default => 'LOT', + }; } /** @@ -72,16 +90,16 @@ public function assignLotOnRelease(Batch $batch, ?ProductType $productType = nul /** * Find the LOT sequence for a product type, falling back to default. */ - private function findSequence(?ProductType $productType): ?LotSequence + private function findSequence(?ProductType $productType, string $purpose = LotSequence::PURPOSE_LOT): ?LotSequence { if ($productType) { - $seq = LotSequence::where('product_type_id', $productType->id)->first(); + $seq = LotSequence::where('product_type_id', $productType->id)->where('purpose', $purpose)->first(); if ($seq) { return $seq; } } - // Fall back to default sequence (null product_type_id) - return LotSequence::whereNull('product_type_id')->first(); + // Fall back to the global sequence for this purpose (null product_type_id) + return LotSequence::whereNull('product_type_id')->where('purpose', $purpose)->first(); } } diff --git a/backend/app/Services/Material/BomExplosionService.php b/backend/app/Services/Material/BomExplosionService.php index 0f2516a8b..a5ccc0da1 100644 --- a/backend/app/Services/Material/BomExplosionService.php +++ b/backend/app/Services/Material/BomExplosionService.php @@ -120,7 +120,8 @@ private function explode(ProcessTemplate $template, float $quantity, int $depth, return $items->map(function (BomItem $item) use ($quantity, $depth, $stack) { $material = $item->material; - $baseQty = round((float) $item->quantity_per_unit * $quantity, 4); + // Per finished unit: a per-carton or per-pallet line divides by the packing step's size. + $baseQty = round($item->perUnitQuantity() * $quantity, 4); $scrapQty = round($baseQty * ((float) $item->scrap_percentage / 100), 4); $requiredQty = round($baseQty + $scrapQty, 4); diff --git a/backend/app/Services/Material/BomService.php b/backend/app/Services/Material/BomService.php index 667b2071f..0818bfc90 100644 --- a/backend/app/Services/Material/BomService.php +++ b/backend/app/Services/Material/BomService.php @@ -111,7 +111,8 @@ public function calculateRequirements(ProcessTemplate $template, float $producti ->values(); return $items->map(function (BomItem $item) use ($productionQty) { - $baseQty = round($item->quantity_per_unit * $productionQty, 4); + // Per finished unit: a per-carton or per-pallet line divides by the packing step's size. + $baseQty = round($item->perUnitQuantity() * $productionQty, 4); $scrapQty = round($baseQty * ($item->scrap_percentage / 100), 4); return [ diff --git a/backend/app/Services/Material/MaterialAllocationService.php b/backend/app/Services/Material/MaterialAllocationService.php index 4831ef006..05058ee71 100644 --- a/backend/app/Services/Material/MaterialAllocationService.php +++ b/backend/app/Services/Material/MaterialAllocationService.php @@ -11,6 +11,7 @@ use App\Models\StockMovement; use App\Models\User; use App\Models\WorkOrder; +use App\Support\StockMovementReason; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -389,7 +390,7 @@ public function consumeForBatch(Batch $batch): void $leftoverToReturn, sourceType: StockMovement::SOURCE_BATCH, sourceId: $batch->id, - reason: 'Batch #'.$batch->id.' completed — leftover returned to stock', + reason: StockMovementReason::make('Batch #:batch completed — leftover returned to stock', ['batch' => $batch->id]), ); } @@ -400,7 +401,7 @@ public function consumeForBatch(Batch $batch): void 0, // scrap is a status change, not a stock delta — already left stock at allocation time sourceType: StockMovement::SOURCE_BATCH, sourceId: $batch->id, - reason: 'Batch #'.$batch->id.' scrap qty recorded', + reason: StockMovementReason::make('Batch #:batch scrap qty recorded', ['batch' => $batch->id]), ); } @@ -443,7 +444,7 @@ public function returnForBatch(Batch $batch): void (float) $allocation->allocated_qty, sourceType: StockMovement::SOURCE_BATCH, sourceId: $allocation->batch_id, - reason: 'Batch #'.$allocation->batch_id.' cancelled — return to stock', + reason: StockMovementReason::make('Batch #:batch cancelled — return to stock', ['batch' => $allocation->batch_id]), ); $this->releaseReservation($allocation->material, (float) $allocation->allocated_qty); } @@ -537,7 +538,7 @@ public function adjustAllocation( user: $user, sourceType: StockMovement::SOURCE_BATCH, sourceId: $allocation->batch_id, - reason: $reason ?? 'Adjustment on batch #'.$allocation->batch_id, + reason: $reason ?? StockMovementReason::make('Adjustment on batch #:batch', ['batch' => $allocation->batch_id]), ); if ($deltaQty > 0) { @@ -607,7 +608,7 @@ public function returnQuantity( user: $user, sourceType: StockMovement::SOURCE_BATCH, sourceId: $allocation->batch_id, - reason: $reason ?? 'Batch #'.$allocation->batch_id.' — unused material returned to stock', + reason: $reason ?? StockMovementReason::make('Batch #:batch — unused material returned to stock', ['batch' => $allocation->batch_id]), ); $this->releaseReservation($material, $qty); @@ -690,7 +691,9 @@ private function allocateMatching( user: $user, sourceType: $stepId ? StockMovement::SOURCE_BATCH_STEP : StockMovement::SOURCE_BATCH, sourceId: $stepId ?: $batch->id, - reason: 'Allocated to batch #'.$batch->id.($stepId ? ' (step '.$stepId.')' : ''), + reason: $stepId + ? StockMovementReason::make('Allocated to batch #:batch (step :step)', ['batch' => $batch->id, 'step' => $stepId]) + : StockMovementReason::make('Allocated to batch #:batch', ['batch' => $batch->id]), ); $material->increment('reserved_quantity', $requiredQty); \App\Sync\CollectionBroadcaster::flush($material); // increment bypasses model events diff --git a/backend/app/Services/Material/NetRequirementsService.php b/backend/app/Services/Material/NetRequirementsService.php index ac15d9975..a4f076e02 100644 --- a/backend/app/Services/Material/NetRequirementsService.php +++ b/backend/app/Services/Material/NetRequirementsService.php @@ -210,14 +210,16 @@ private function directComponents(ProcessTemplate $template): array return $this->componentCache[$templateId]; } + // `per` and the packing step turn a per-carton or per-pallet line into a per-unit need. $lines = $template->bomItems() ->whereNotNull('material_id') + ->with('templateStep:id,config') ->orderBy('sort_order') - ->get(['material_id', 'quantity_per_unit', 'scrap_percentage']) + ->get(['id', 'material_id', 'quantity_per_unit', 'scrap_percentage', 'per', 'template_step_id']) ->map(fn ($item) => [ 'material_id' => (int) $item->material_id, 'required_per_unit' => round( - (float) $item->quantity_per_unit * (1 + ((float) $item->scrap_percentage / 100)), + $item->perUnitQuantity() * (1 + ((float) $item->scrap_percentage / 100)), 6 ), ]) diff --git a/backend/app/Services/MenuRegistry.php b/backend/app/Services/MenuRegistry.php index 15d9a776b..57ba24cf1 100644 --- a/backend/app/Services/MenuRegistry.php +++ b/backend/app/Services/MenuRegistry.php @@ -59,6 +59,9 @@ class MenuRegistry /** Custom top-level dropdown groups. */ private array $groups = []; + /** @var list */ + private array $operatorItems = []; + // ------------------------------------------------------------------------- // Built-in group injection // ------------------------------------------------------------------------- @@ -180,4 +183,53 @@ public function getGroups(): array return $groups; } + + // ------------------------------------------------------------------------- + // Operator (shop-floor panel) tabs + // ------------------------------------------------------------------------- + + /** + * Add a tab to the operator panel's top bar, next to Queue / Workstation. + * + * The operator chrome is a different surface from the admin sidebar: a + * handful of big touch targets on a tablet, not a tree of dropdowns. A + * module that ships an operator screen — a waste register, a team clock-in — + * registers it here and it renders as one more tab, active while the + * browser is anywhere under `$prefix` (defaults to the link's own path). + * + * Module operator pages are React/Inertia pages, so the tab is an Inertia + * link, not a full page load. + * + * @param string $label Tab text + * @param string $url Resolved URL (call route() or url() in your ServiceProvider) + * @param int $order Sort weight — built-in tabs are 10 (Queue) and 20 (Workstation) + * @param string|null $prefix Path prefix that keeps the tab highlighted + */ + public function addOperatorItem(string $label, string $url, int $order = 50, ?string $prefix = null): void + { + $this->operatorItems[] = [ + 'label' => $label, + 'url' => $url, + 'order' => $order, + 'prefix' => $prefix ?? (parse_url($url, PHP_URL_PATH) ?: $url), + ]; + } + + /** + * Operator tabs sorted by order, after filter `operator.module_tabs`. + * + * Registration happens once at boot; the filter runs per request, so a module + * can decide which of the tabs this viewer sees right now (per station, per + * who is signed in) without re-registering anything. + * + * @return list + */ + public function getOperatorItems(): array + { + $items = app(\App\Extension\FilterRegistry::class)->filter('operator.module_tabs', $this->operatorItems); + $items = array_values($items); + usort($items, fn ($a, $b) => $a['order'] <=> $b['order']); + + return $items; + } } diff --git a/backend/app/Services/ModuleManager.php b/backend/app/Services/ModuleManager.php index 129c5d1a4..e2e566f2c 100644 --- a/backend/app/Services/ModuleManager.php +++ b/backend/app/Services/ModuleManager.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Support\CoreVersionConstraint; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -72,11 +73,44 @@ public function enabledNames(): array } } + /** + * The module's manifest, or an empty array when it has none. + * + * @return array + */ + public function manifest(string $name): array + { + $path = "{$this->modulesPath}/{$name}/module.json"; + + if (! is_file($path)) { + return []; + } + + return json_decode((string) file_get_contents($path), true) ?: []; + } + + /** + * Whether this core satisfies a module's `requires_core`. + * + * An unreadable constraint counts as unsatisfied rather than as "no + * constraint": a typo must not quietly widen what a module accepts. + */ + public function coreSatisfies(?string $constraint): bool + { + try { + return CoreVersionConstraint::isSatisfied($constraint); + } catch (\InvalidArgumentException) { + return false; + } + } + /** * Enable a module by name. */ public function enable(string $name): void { + $this->publishAssets($name); + $enabled = $this->enabledNames(); if (! in_array($name, $enabled)) { $enabled[] = $name; @@ -91,6 +125,94 @@ public function disable(string $name): void { $enabled = array_values(array_filter($this->enabledNames(), fn ($n) => $n !== $name)); $this->saveEnabled($enabled); + + $this->unpublishAssets($name); + } + + /** + * Copy a module's compiled frontend into public/, where the browser can + * fetch it. + * + * A module's pages are not in this app's bundle — that was compiled before + * the module existed, and nothing rebuilds it on a running system. The + * module ships its own built file instead, and it has to be reachable over + * HTTP for the browser to load it; modules/ is not web-accessible. + * + * Silently does nothing for a module without a built frontend: plenty of + * modules are backend-only, and those are not broken. + */ + public function publishAssets(string $name): void + { + $source = "{$this->modulesPath}/{$name}/public/{$name}.js"; + + if (! is_file($source)) { + return; + } + + $targetDir = public_path('modules'); + if (! is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + copy($source, "{$targetDir}/{$name}.js"); + + // The sidecar records which runtime contract the module was built + // against; HandleInertiaRequests passes it on so the browser can refuse + // a module built for a core it no longer matches. + $manifest = "{$this->modulesPath}/{$name}/public/{$name}.runtime.json"; + if (is_file($manifest)) { + copy($manifest, "{$targetDir}/{$name}.runtime.json"); + } + } + + /** Remove what publishAssets() put in public/. */ + public function unpublishAssets(string $name): void + { + foreach (["{$name}.js", "{$name}.runtime.json"] as $file) { + $path = public_path("modules/{$file}"); + if (is_file($path)) { + unlink($path); + } + } + } + + /** + * Enabled modules that ship a frontend, as the browser needs them: a URL to + * load and the contract version the file was built against. + * + * @return array + */ + public function frontendAssets(): array + { + $assets = []; + + foreach ($this->enabledNames() as $name) { + $path = public_path("modules/{$name}.js"); + + if (! is_file($path)) { + continue; + } + + $requires = null; + $sidecar = public_path("modules/{$name}.runtime.json"); + if (is_file($sidecar)) { + $decoded = json_decode(file_get_contents($sidecar), true); + $requires = is_array($decoded) ? ($decoded['requires_runtime'] ?? null) : null; + } + + $assets[] = [ + 'name' => $name, + // Cache-busted on the file's contents, not its mtime: publishing + // copies the file, so mtime is "when it was copied" — it changes + // on every enable/disable cycle even when nothing about the + // module did, throwing away the browser's cache for no reason, + // and two publishes inside the same second collide. + 'url' => "/modules/{$name}.js?v=".substr(md5_file($path), 0, 12), + 'requires_runtime' => $requires, + ]; + } + + return $assets; } /** @@ -150,6 +272,21 @@ public function installFromZip(string $zipPath): string throw new \RuntimeException('Invalid provider namespace: must start with "Modules\\".'); } + // The core version the module was built against. Nothing read this + // until now, so a module installed on an older core simply did not + // work: the seams it relies on were absent, and the admin was left + // guessing. Refusing the install says so once, at the only moment + // anybody can act on it. + if (! $this->coreSatisfies($manifest['requires_core'] ?? null)) { + $zip->close(); + throw new \RuntimeException(sprintf( + 'Module "%s" requires OpenMES %s; this installation is %s.', + $manifest['name'], + $manifest['requires_core'], + config('version.current'), + )); + } + // Validate module name (alphanumeric + hyphens only, no path traversal) if (! preg_match('/^[A-Za-z0-9_-]+$/', $manifest['name'])) { $zip->close(); @@ -206,9 +343,22 @@ public function installFromZip(string $zipPath): string /** * Delete an installed module directory. + * + * The module's own uninstall hook runs FIRST, while its classes are still + * on disk and autoloadable — after the directory is gone there is nothing + * left to call. It is the module's only chance to undo what it did outside + * its own tables: permissions it registered, settings it wrote. + * + * A throwing hook aborts the uninstall and leaves the directory in place. + * Deleting anyway would run half an uninstall and lose the other half with + * no way to retry; this way the administrator sees the failure and the + * module is still there to try again. The module's own migrations are NOT + * rolled back — see the note the controller shows. */ public function uninstall(string $name): void { + $this->runInstaller($name, 'uninstall'); + $this->disable($name); $dir = "{$this->modulesPath}/{$name}"; if (is_dir($dir)) { @@ -232,11 +382,36 @@ public function loadEnabled(\Illuminate\Foundation\Application $app): void $provider = $manifest['provider'] ?? null; if ($provider && class_exists($provider)) { - $app->register($provider); + // Wrapped rather than registered directly: an exception from the + // provider's boot() is thrown while Laravel boots every provider, + // long after this method returned, so the caller's try/catch + // cannot see it — and one bad module would 502 the whole app. + // + // Registered with force, because the container deduplicates by + // class name: without it the second module's guard would be + // mistaken for the first one's and silently skipped. + $app->register( + new \App\Support\ModuleProviderGuard($app, new $provider($app), $name), + force: true, + ); } } } + /** + * The module's own migrations directory, or null when it ships none. + * + * Needed because a module's migrations are registered by its service + * provider, which is only loaded at boot — so the process that *enables* a + * module cannot see them and has to be told where they are. + */ + public function migrationsPath(string $name): ?string + { + $path = "{$this->modulesPath}/{$name}/database/migrations"; + + return is_dir($path) ? $path : null; + } + /** * Run a module's optional installer hook. * @@ -252,20 +427,6 @@ public function loadEnabled(\Illuminate\Foundation\Application $app): void * * @param string $method install | uninstall */ - /** - * The module's own migrations directory, or null when it ships none. - * - * Needed because a module's migrations are registered by its service - * provider, which is only loaded at boot — so the process that *enables* a - * module cannot see them and has to be told where they are. - */ - public function migrationsPath(string $name): ?string - { - $path = "{$this->modulesPath}/{$name}/database/migrations"; - - return is_dir($path) ? $path : null; - } - public function runInstaller(string $name, string $method = 'install'): void { $installer = "Modules\\{$name}\\Installer"; diff --git a/backend/app/Services/Packaging/LabelGenerator.php b/backend/app/Services/Packaging/LabelGenerator.php index 65085ed41..baec196e0 100644 --- a/backend/app/Services/Packaging/LabelGenerator.php +++ b/backend/app/Services/Packaging/LabelGenerator.php @@ -5,7 +5,10 @@ use App\Models\Batch; use App\Models\BatchStep; use App\Models\LabelTemplate; +use App\Models\MaterialLot; use App\Models\Pallet; +use App\Models\SerialUnit; +use App\Models\UnitCarton; use App\Models\WorkOrder; use Barryvdh\DomPDF\Facade\Pdf; use Endroid\QrCode\Builder\Builder; @@ -43,6 +46,146 @@ public function pdfForPallets(Collection $pallets, LabelTemplate $template) return $this->renderPdf('packaging.pdf.labels.pallet', $labels, $template); } + public function pdfForSerialUnits(Collection $units, LabelTemplate $template) + { + $labels = $units->map(fn (SerialUnit $unit) => $this->labelDataForSerialUnit($unit, $template))->all(); + + return $this->renderPdf('packaging.pdf.labels.serial-unit', $labels, $template); + } + + /** The IQC label of received material lots: the lot, its material and the inspection verdict. */ + public function pdfForMaterialLots(Collection $lots, LabelTemplate $template) + { + $labels = $lots->map(fn (MaterialLot $lot) => $this->labelDataForMaterialLot($lot, $template))->all(); + + return $this->renderPdf('packaging.pdf.labels.material-lot', $labels, $template); + } + + public function zplForMaterialLots(Collection $lots, LabelTemplate $template): string + { + return $lots + ->map(fn (MaterialLot $lot) => $this->zplLabel($this->labelDataForMaterialLot($lot, $template), $template)) + ->implode("\n"); + } + + /** + * The lot number is what the barcode and QR carry - the next scan (issue to + * production, a component bound into a unit) finds the lot by it. The + * status line is the incoming-inspection verdict the label exists for. + */ + private function labelDataForMaterialLot(MaterialLot $lot, LabelTemplate $template): array + { + $lot->loadMissing('material', 'inspection'); + $status = __(match ($lot->status) { + MaterialLot::STATUS_RELEASED => 'IQC: released', + MaterialLot::STATUS_QUARANTINE => 'IQC: quarantine', + MaterialLot::STATUS_REJECTED => 'IQC: rejected', + default => 'IQC: :status', + }, ['status' => $lot->status]); + $inspected = $lot->released_at ?? $lot->inspection?->updated_at; + + return [ + 'fields' => [ + 'lot' => $lot->lot_number, + 'material' => $lot->material ? trim($lot->material->code.' · '.$lot->material->name) : null, + 'quantity' => $this->formatQty($lot->quantity_received).' '.($lot->unit_of_measure ?? $lot->material?->unit_of_measure ?? ''), + 'status' => $inspected ? $status.' · '.$inspected->format('Y-m-d') : $status, + 'supplier_lot' => $lot->supplier_lot_no ? __('Supplier lot: :lot', ['lot' => $lot->supplier_lot_no]) : null, + 'prod_date' => $lot->received_at?->format('Y-m-d'), + ], + 'barcode_value' => $lot->lot_number, + 'qr_value' => $lot->lot_number, + 'barcode_png' => $template->hasField('barcode') ? $this->barcodePng($lot->lot_number, $template->barcode_format) : null, + 'qr_png' => $template->hasField('qr') ? $this->qrPng($lot->lot_number) : null, + ]; + } + + public function pdfForCartons(Collection $cartons, LabelTemplate $template) + { + $labels = $cartons->map(fn (UnitCarton $carton) => $this->labelDataForCarton($carton, $template))->all(); + + return $this->renderPdf('packaging.pdf.labels.carton', $labels, $template); + } + + /** + * The template on sample data, for checking a layout before anything real + * exists to print. Every field the template can show gets a plausible + * value, so what is ticked is what appears. + */ + public function pdfPreview(LabelTemplate $template) + { + [$view, $label] = $this->sampleLabel($template); + + return $this->renderPdf($view, [$label], $template); + } + + /** @return array{0: string, 1: array} the view and one label's data */ + private function sampleLabel(LabelTemplate $template): array + { + $today = now()->format('Y-m-d'); + [$view, $fields, $code, $qr, $units] = match ($template->type) { + LabelTemplate::TYPE_SERIAL_UNIT => ['packaging.pdf.labels.serial-unit', + ['serial_no' => 'SN-000001', 'psn' => 'PSN-000001', 'wo_number' => 'WO-000001', 'product' => __('Sample product'), 'quantity' => null, 'lot' => null, 'prod_date' => $today], + 'SN-000001', 'SN-000001', []], + LabelTemplate::TYPE_CARTON => ['packaging.pdf.labels.carton', + ['carton_no' => 'CTN-000001', 'wo_number' => 'WO-000001', 'product' => __('Sample product'), 'quantity' => __(':count pcs', ['count' => 6]), 'lot' => null, 'location' => 'PAL-000001', 'prod_date' => $today], + 'CTN-000001', 'CTN-000001', ['SN-000001', 'SN-000002', 'SN-000003', 'SN-000004', 'SN-000005', 'SN-000006']], + LabelTemplate::TYPE_MATERIAL_LOT => ['packaging.pdf.labels.material-lot', + ['lot' => 'LOT-000001', 'material' => 'MAT-001 · '.__('Sample material'), 'quantity' => '500 pcs', 'status' => __('IQC: released').' · '.$today, 'supplier_lot' => __('Supplier lot: :lot', ['lot' => 'SUP-4711']), 'prod_date' => $today], + 'LOT-000001', 'LOT-000001', []], + LabelTemplate::TYPE_PALLET => ['packaging.pdf.labels.pallet', + ['pallet_no' => 'PAL-000001', 'wo_number' => 'WO-000001', 'product' => __('Sample product'), 'quantity' => '48 pcs', 'location' => 'DOCK-1', 'lot' => 'LOT-000001', 'prod_date' => $today], + 'PAL-000034', 'PAL-000034', []], + LabelTemplate::TYPE_FINISHED_GOODS => ['packaging.pdf.labels.finished-goods', + ['wo_number' => 'WO-2026-0142', 'product' => __('Sample product'), 'quantity' => '48 pcs', 'lot' => 'LOT-2026-091', 'prod_date' => $today], + 'LOT-2026-091', 'LOT-2026-091', []], + LabelTemplate::TYPE_WORKSTATION_STEP => ['packaging.pdf.labels.workstation-step', + ['wo_number' => 'WO-2026-0142', 'product' => __('Sample product'), 'quantity' => '48 pcs', 'lot' => 'Step 2: '.__('Assembly'), 'prod_date' => __('Workstation').' 1'], + 'WO-2026-0142-B1-S2', url('/admin/work-orders'), []], + default => ['packaging.pdf.labels.work-order', + ['wo_number' => 'WO-2026-0142', 'product' => __('Sample product'), 'quantity' => '48 pcs', 'lot' => null, 'prod_date' => $today], + 'WO-2026-0142', url('/admin/work-orders'), []], + }; + + return [$view, [ + 'fields' => $fields, + 'units' => $units, + 'barcode_value' => $code, + 'qr_value' => $qr, + 'barcode_png' => $template->hasField('barcode') ? $this->barcodePng($code, $template->barcode_format) : null, + 'qr_png' => $template->hasField('qr') ? $this->qrPng($qr) : null, + ]]; + } + + /** + * The outer-box label: the carton number as barcode/QR, and the serial of + * every unit inside - what a warehouse or a customer reads to know which + * exact units are in the box without opening it. + */ + private function labelDataForCarton(UnitCarton $carton, LabelTemplate $template): array + { + $carton->loadMissing('workOrder.productType', 'pallet:id,pallet_no'); + $units = $carton->units()->orderBy('serial_no')->get(['serial_no', 'psn'])->map(fn ($u) => $u->serial_no ?? $u->psn)->all(); + $wo = $carton->workOrder; + + return [ + 'fields' => [ + 'carton_no' => $carton->carton_no, + 'wo_number' => $wo?->order_no, + 'product' => $wo?->productType?->name, + 'quantity' => __(':count pcs', ['count' => count($units)]), + 'lot' => null, + 'location' => $carton->pallet?->pallet_no, + 'prod_date' => ($carton->closed_at ?? $carton->created_at)?->format('Y-m-d'), + ], + 'units' => $units, + 'barcode_value' => $carton->carton_no, + 'qr_value' => $carton->carton_no, + 'barcode_png' => $template->hasField('barcode') ? $this->barcodePng($carton->carton_no, $template->barcode_format) : null, + 'qr_png' => $template->hasField('qr') ? $this->qrPng($carton->carton_no) : null, + ]; + } + public function zplForPallets(Collection $pallets, LabelTemplate $template): string { return $pallets @@ -71,6 +214,45 @@ public function zplForBatchSteps(Collection $steps, LabelTemplate $template): st ->implode("\n"); } + public function zplForSerialUnits(Collection $units, LabelTemplate $template): string + { + return $units + ->map(fn (SerialUnit $unit) => $this->zplLabel($this->labelDataForSerialUnit($unit, $template), $template)) + ->implode("\n"); + } + + /** + * Per-unit carton label: the serial number is the primary value (barcode + + * QR); the process serial number is also printed so the unit can be + * re-found at packing by process-serial scan. + */ + private function labelDataForSerialUnit(SerialUnit $unit, LabelTemplate $template): array + { + $unit->loadMissing('workOrder.productType'); + $wo = $unit->workOrder; + // Both codes carry the unit's number itself: the label is read by the + // next station's scanner, not by a phone - a URL there would be noise. + // A unit still without its SN is labelled with its PSN. + $barcodeValue = $unit->serial_no ?? $unit->psn; + $qrValue = $barcodeValue; + + return [ + 'fields' => [ + 'serial_no' => $barcodeValue, + 'psn' => $unit->serial_no !== null ? $unit->psn : null, + 'wo_number' => $wo?->order_no, + 'product' => $wo?->productType?->name, + 'quantity' => null, + 'lot' => null, + 'prod_date' => ($unit->produced_at ?? $unit->created_at)?->format('Y-m-d'), + ], + 'barcode_value' => $barcodeValue, + 'qr_value' => $qrValue, + 'barcode_png' => $template->hasField('barcode') ? $this->barcodePng($barcodeValue, $template->barcode_format) : null, + 'qr_png' => $template->hasField('qr') ? $this->qrPng($qrValue) : null, + ]; + } + private function labelDataForWorkOrder(WorkOrder $wo, LabelTemplate $template): array { $wo->loadMissing('productType', 'line'); @@ -191,16 +373,25 @@ private function mmToPt(float $mm): float return $mm * 2.834645669; } - public function barcodePng(string $value, string $format): string + /** + * The 1D barcode as a data URI, or null when the value cannot be encoded. + * EAN-13 only takes 12-13 digits and CODE 39 a limited character set; + * an order number or a serial that does not fit falls back to CODE 128 + * rather than failing the whole print. + */ + public function barcodePng(string $value, string $format): ?string { - $type = match ($format) { - 'code39' => BarcodeGeneratorPNG::TYPE_CODE_39, - 'ean13' => BarcodeGeneratorPNG::TYPE_EAN_13, + $type = match (true) { + $format === 'ean13' && preg_match('/^\d{12,13}$/', $value) === 1 => BarcodeGeneratorPNG::TYPE_EAN_13, + $format === 'code39' && preg_match('/^[0-9A-Z\-. $\/+%]+$/', $value) === 1 => BarcodeGeneratorPNG::TYPE_CODE_39, default => BarcodeGeneratorPNG::TYPE_CODE_128, }; - $generator = new BarcodeGeneratorPNG; - $png = $generator->getBarcode($value, $type, 2, 60); + try { + $png = (new BarcodeGeneratorPNG)->getBarcode($value, $type, 2, 60); + } catch (\Throwable) { + return null; + } return 'data:image/png;base64,'.base64_encode($png); } @@ -241,6 +432,14 @@ private function zplLabel(array $data, LabelTemplate $template): string $zpl .= "^FO20,{$y}^A0N,32,32^FD".$this->zplEscape($fields['pallet_no'])."^FS\n"; $y += $lineHeight + 4; } + if ($template->hasField('serial_no') && ! empty($fields['serial_no'])) { + $zpl .= "^FO20,{$y}^A0N,32,32^FD".$this->zplEscape($fields['serial_no'])."^FS\n"; + $y += $lineHeight + 4; + } + if ($template->hasField('psn') && ! empty($fields['psn'])) { + $zpl .= "^FO20,{$y}^A0N,20,20^FDPSN: ".$this->zplEscape($fields['psn'])."^FS\n"; + $y += $lineHeight; + } if ($template->hasField('wo_number') && ! empty($fields['wo_number'])) { $zpl .= "^FO20,{$y}^A0N,28,28^FD".$this->zplEscape($fields['wo_number'])."^FS\n"; $y += $lineHeight; @@ -253,6 +452,12 @@ private function zplLabel(array $data, LabelTemplate $template): string $zpl .= "^FO20,{$y}^A0N,22,22^FDQty: ".$this->zplEscape($fields['quantity'])."^FS\n"; $y += $lineHeight; } + foreach (['material' => 22, 'status' => 22, 'supplier_lot' => 20] as $key => $size) { + if ($template->hasField($key) && ! empty($fields[$key])) { + $zpl .= "^FO20,{$y}^A0N,{$size},{$size}^FD".$this->zplEscape($fields[$key])."^FS\n"; + $y += $lineHeight; + } + } if ($template->hasField('lot') && ! empty($fields['lot'])) { $zpl .= "^FO20,{$y}^A0N,20,20^FDLOT: ".$this->zplEscape($fields['lot'])."^FS\n"; $y += $lineHeight; diff --git a/backend/app/Services/Packaging/UnitPackingService.php b/backend/app/Services/Packaging/UnitPackingService.php new file mode 100644 index 000000000..3e541829a --- /dev/null +++ b/backend/app/Services/Packaging/UnitPackingService.php @@ -0,0 +1,579 @@ +> packing config per work order, for the request */ + private array $configCache = []; + + public function __construct( + private readonly SerialTraceService $serials, + private readonly BatchService $batches, + private readonly MaterialAllocationService $allocations, + private readonly LotPickingService $lotPicking, + ) {} + + /** How many units a carton of this order holds (the packing step's size), or null when unlimited. */ + public function cartonCapacityFor(?int $workOrderId): ?int + { + $capacity = (int) ($this->packingConfigFor($workOrderId)['carton_capacity'] ?? 0); + + return $capacity > 0 ? $capacity : null; + } + + /** + * The packing step an order is on right now: running, else the one ready to + * run. With the unit's batch known, that batch's step - an order packed in + * several batches counts each unit on its own batch. + */ + public function packingStepFor(?int $workOrderId, ?int $batchId = null): ?BatchStep + { + if (! $workOrderId) { + return null; + } + if ($batchId) { + $step = BatchStep::query() + ->where('kind', TemplateStep::KIND_PACKING) + ->whereIn('status', [BatchStep::STATUS_READY, BatchStep::STATUS_IN_PROGRESS]) + ->where('batch_id', $batchId) + ->orderByRaw("CASE WHEN status = 'IN_PROGRESS' THEN 0 ELSE 1 END") + ->orderBy('step_number') + ->first(); + if ($step) { + return $step; + } + } + + return BatchStep::query() + ->where('kind', TemplateStep::KIND_PACKING) + ->whereIn('status', [BatchStep::STATUS_READY, BatchStep::STATUS_IN_PROGRESS]) + ->whereHas('batch', fn ($q) => $q->where('work_order_id', $workOrderId)) + ->orderByRaw("CASE WHEN status = 'IN_PROGRESS' THEN 0 ELSE 1 END") + ->orderBy('step_number') + ->first(); + } + + /** + * The packaging the routing's BOM puts on this packing step - cartons, + * pallets, foil - with what the batch needs of each, what was reserved and + * from which lots, and what has been used so far. `needs_pick` says the + * station has to name the lot before packing starts (lot tracking on). + * + * @return array> + */ + public function packingMaterials(BatchStep $step): array + { + $batch = $step->batch; + // The snapshot is read afresh: the station's step list loads the order + // with a handful of columns, and a partial relation has no snapshot. + $bom = $batch ? (WorkOrder::find($batch->work_order_id)?->process_snapshot['bom'] ?? []) : []; + $lotTracking = $this->lotPicking->isLotTrackingEnabled(); + + $out = []; + foreach ($bom as $line) { + if ((int) ($line['step_number'] ?? 0) !== (int) $step->step_number || empty($line['material_id'])) { + continue; + } + $material = Material::find($line['material_id']); + if (! $material) { + continue; + } + $required = round((float) $line['quantity_per_unit'] * (float) $batch->target_qty * (1 + (float) ($line['scrap_percentage'] ?? 0) / 100), 4); + $allocation = MaterialAllocation::where('batch_id', $batch->id)->where('material_id', $material->id) + ->with('lotPicks.lot:id,lot_number')->first(); + + $out[] = [ + 'material_id' => $material->id, + 'code' => $line['material_code'] ?? $material->code, + 'name' => $line['material_name'] ?? $material->name, + 'unit_of_measure' => $line['unit_of_measure'] ?? $material->unit_of_measure, + 'per' => $line['per'] ?? BomItem::PER_UNIT, + 'quantity_per_basis' => (float) ($line['quantity_per_basis'] ?? $line['quantity_per_unit']), + 'required_qty' => $required, + 'status' => $allocation?->status ?? 'pending', + 'allocated_qty' => (float) ($allocation?->allocated_qty ?? 0), + 'consumed_qty' => (float) ($allocation?->consumed_qty ?? 0), + 'lots' => $allocation ? $allocation->lotPicks->map(fn ($pick) => ['lot_number' => $pick->lot?->lot_number, 'qty' => (float) $pick->picked_qty])->values()->all() : [], + 'needs_pick' => $lotTracking && ! $allocation && $material->tracking_type !== 'serial' && $step->status === BatchStep::STATUS_READY, + ]; + } + + return $out; + } + + /** Why a scan must wait: the packaging lots have not been picked yet. Null when packing may go on. */ + public function packingMaterialsGate(BatchStep $step): ?string + { + if ($step->status !== BatchStep::STATUS_READY) { + return null; + } + $waiting = collect($this->packingMaterials($step))->filter(fn ($m) => $m['needs_pick'])->pluck('name'); + if ($waiting->isEmpty()) { + return null; + } + + return __('Pick the packaging lots first (:materials), then scan.', ['materials' => $waiting->implode(', ')]); + } + + /** + * Start the packing step from the station with the lots the operator + * picked - the same start the operator queue does, so reservations and + * genealogy land the same way. + * + * @param array> $picks + * + * @throws BindingException + */ + public function startPackingStep(BatchStep $step, User $operator, array $picks = []): BatchStep + { + if ($step->status !== BatchStep::STATUS_READY) { + throw new BindingException(__('Packing step :name is not ready to start.', ['name' => $step->name])); + } + $byMaterial = []; + foreach ($picks as $materialId => $rows) { + $byMaterial[(int) $materialId] = $rows; + } + try { + return $this->batches->startStep($step, $operator, $byMaterial); + } catch (\Throwable $e) { + throw new BindingException($e->getMessage()); + } + } + + /** + * A carton or pallet closed: its packaging (the BOM lines counted per + * carton / per pallet) is used up, one more each. Booked on the reservation + * so the station's "used" count is live; the batch's completion settles the + * rest as it always did. Never in the way of the closing itself. + */ + private function consumePackaging(?int $workOrderId, string $basis): void + { + $step = $this->packingStepFor($workOrderId); + if (! $step || $step->status !== BatchStep::STATUS_IN_PROGRESS) { + return; + } + try { + foreach ($this->packingMaterials($step) as $line) { + if ($line['per'] !== $basis || $line['status'] !== MaterialAllocation::STATUS_ALLOCATED) { + continue; + } + $allocation = MaterialAllocation::where('batch_id', $step->batch_id)->where('material_id', $line['material_id'])->first(); + if ($allocation) { + $this->allocations->recordConsumption($allocation, min((float) $allocation->allocated_qty, $line['consumed_qty'] + $line['quantity_per_basis']), (float) $allocation->scrap_qty); + } + } + } catch (\Throwable $e) { + Log::info('Packaging consumption not booked: '.$e->getMessage(), ['batch_step_id' => $step->id, 'basis' => $basis]); + } + } + + /** The pallet is done (closed by hand or by reaching its size): book its packaging. */ + public function palletClosed(Pallet $pallet): void + { + $this->consumePackaging($pallet->work_order_id, BomItem::PER_PALLET); + } + + /** + * The packing step's configuration that governs an order: the frozen batch + * step first, the order's snapshot when no batch exists yet, nothing when + * the routing has no packing step (then the station's defaults apply). + * + * @return array + */ + public function packingConfigFor(?int $workOrderId): array + { + if (! $workOrderId) { + return []; + } + if (array_key_exists($workOrderId, $this->configCache)) { + return $this->configCache[$workOrderId]; + } + + $step = BatchStep::query() + ->where('kind', TemplateStep::KIND_PACKING) + ->whereHas('batch', fn ($q) => $q->where('work_order_id', $workOrderId)) + ->orderBy('step_number') + ->first(); + $config = $step?->config; + if ($config === null) { + foreach (WorkOrder::find($workOrderId)?->process_snapshot['steps'] ?? [] as $snapshotStep) { + if (($snapshotStep['kind'] ?? null) === TemplateStep::KIND_PACKING) { + $config = $snapshotStep['config'] ?? []; + break; + } + } + } + + return $this->configCache[$workOrderId] = $config ?? []; + } + + /** + * The label template the packing step names, for the container level it + * packs into ('unit', 'carton' or 'pallet'); null means the type's default. + */ + public function labelTemplateIdFor(?int $workOrderId, string $level): ?int + { + $config = $this->packingConfigFor($workOrderId); + if (($config['unit'] ?? 'carton') !== $level || empty($config['label_template_id'])) { + return null; + } + + return (int) $config['label_template_id']; + } + + /** + * A pallet that reached the packing step's size closes itself, the way a + * full carton does: counting closed cartons when the step packs into + * cartons, units when it packs straight onto pallets. Returns whether it + * closed just now, so the station can print its label and drop the bench. + */ + public function closePalletIfFull(?Pallet $pallet): bool + { + if (! $pallet || ! $pallet->isOpen()) { + return false; + } + $config = $this->packingConfigFor($pallet->work_order_id); + $capacity = (int) ($config['pallet_capacity'] ?? 0); + if ($capacity < 1) { + return false; + } + $filled = ($config['unit'] ?? 'carton') === 'pallet' + ? $pallet->units()->count() + : $pallet->cartons()->where('status', UnitCarton::STATUS_CLOSED)->count(); + if ($filled < $capacity) { + return false; + } + + // Only the request that actually closes it books the pallet's packaging. + $closed = Pallet::whereKey($pallet->id)->where('status', PalletStatus::Open->value) + ->update(['status' => PalletStatus::Closed->value, 'active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + if ($closed === 0) { + return false; + } + $pallet->refresh(); + $this->palletClosed($pallet); + + return true; + } + + /** + * A unit leaves production here. With a carton it goes into the box (and + * onto the box's pallet, if it already has one); with a pallet alone it goes + * straight onto the pallet; with neither it is just marked packed. + * + * @throws BindingException + */ + /** @param ?int $workstationId the bench the operator picked for this session; their account's bench otherwise */ + /** + * A unit the line has taken off (scrapped, held, already shipped) is not + * packed - checked before anything else happens at the bench (the scale). + * + * @throws BindingException + */ + public function assertPackable(SerialUnit $unit): void + { + if (in_array($unit->status, [...SerialUnit::TERMINAL_STATUSES, SerialUnit::STATUS_BLOCKED], true)) { + throw new BindingException(__('Unit :sn is :status and cannot be packed.', ['sn' => $unit->display_id, 'status' => $unit->status_label])); + } + } + + /** @param array $measured what was measured at packing (the weight), kept on the "packed" event */ + public function packUnit(SerialUnit $unit, User $operator, ?UnitCarton $carton = null, ?Pallet $pallet = null, ?int $workstationId = null, array $measured = []): SerialUnit + { + [$unit, $firstPacking] = DB::transaction(function () use ($unit, $operator, $carton, $pallet, $workstationId, $measured) { + // Read again under lock: two benches, or a scan racing the box's close, + // must not both see room in the same carton or an open pallet. + $unit = SerialUnit::whereKey($unit->id)->lockForUpdate()->firstOrFail(); + $carton = $carton ? UnitCarton::whereKey($carton->id)->lockForUpdate()->firstOrFail() : null; + $pallet = $pallet ? Pallet::whereKey($pallet->id)->lockForUpdate()->firstOrFail() : null; + + $this->assertPackable($unit); + if ($carton && ! $carton->isOpen()) { + throw new BindingException(__('Carton :no is closed.', ['no' => $carton->carton_no])); + } + if ($unit->carton_id && $carton && $unit->carton_id !== $carton->id) { + throw new BindingException(__('Unit :sn is already in carton :no.', ['sn' => $unit->display_id, 'no' => $unit->carton?->carton_no])); + } + // The box is full at the packing step's size: the server holds that + // line, whatever the station's auto-close managed to do. + $capacity = $this->cartonCapacityFor($unit->work_order_id); + if ($carton && $unit->carton_id !== $carton->id && $capacity && $carton->qty >= $capacity) { + throw new BindingException(__('Carton :no is full (:count units).', ['no' => $carton->carton_no, 'count' => $capacity])); + } + + // A box already on a pallet takes the unit there; another pallet would + // split the box. A box with no pallet yet goes onto this one first. + if ($carton && $pallet && $carton->pallet_id && $carton->pallet_id !== $pallet->id) { + throw new BindingException(__('Carton :no is already on pallet :pallet.', ['no' => $carton->carton_no, 'pallet' => $carton->pallet?->pallet_no])); + } + if ($carton && $pallet && ! $carton->pallet_id) { + $carton = $this->assignCartonToPallet($carton, $pallet, $operator, $workstationId); + $unit->refresh(); + } + + $pallet ??= $carton?->pallet; + // A closed or shipped pallet takes nothing more, not even through a box still on it. + if ($pallet && ! $pallet->isOpen()) { + throw new BindingException(__('Pallet :no is not open.', ['no' => $pallet->pallet_no])); + } + // A box or pallet belongs to an order; a unit from another order does not go in it. + foreach ([$carton?->carton_no => $carton, $pallet?->pallet_no => $pallet] as $no => $container) { + if ($container && $unit->work_order_id && $container->work_order_id && $container->work_order_id !== $unit->work_order_id) { + throw new BindingException(__('Unit :sn belongs to another work order than :no.', ['sn' => $unit->display_id, 'no' => $no])); + } + } + // A unit already on a pallet stays there; moving it is a supervisor's job, not a rescan. + if ($pallet && $unit->pallet_id && $unit->pallet_id !== $pallet->id) { + throw new BindingException(__('Unit :sn is already on pallet :no.', ['sn' => $unit->display_id, 'no' => $unit->pallet?->pallet_no])); + } + $alreadyInCarton = $carton && $unit->carton_id === $carton->id; + $newOnPallet = $pallet && $unit->pallet_id !== $pallet->id; + $firstPacking = $unit->packed_at === null; + // The same label read twice (a wedge scanner, a nervous hand) changes + // nothing and writes nothing: the history keeps one "packed" per move. + if (! $firstPacking && ($alreadyInCarton || (! $carton && ! $newOnPallet))) { + return [$unit, false]; + } + + $unit->fill([ + 'carton_id' => $carton?->id ?? $unit->carton_id, + 'pallet_id' => $pallet?->id ?? $unit->pallet_id, + 'packed_at' => $unit->packed_at ?? now(), + 'status' => $unit->status === SerialUnit::STATUS_IN_PRODUCTION ? SerialUnit::STATUS_COMPLETED : $unit->status, + ])->save(); + + if ($carton && ! $alreadyInCarton) { + $carton->increment('qty'); + } + // "Pieces on pallet" counts serialised units too, whether they land + // there directly or through a carton that is already on it. + if ($newOnPallet) { + $pallet->increment('qty'); + $pallet->recomputeQualityStatus(); + } + + $this->serials->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => array_filter([ + 'event' => 'packed', + 'psn' => $unit->psn, + 'carton_no' => $carton?->carton_no, + 'pallet_no' => $pallet?->pallet_no, + ...$measured, + ], fn ($v) => $v !== null && $v !== ''), + ]); + + return [$unit->fresh(), $firstPacking]; + }); + + // One unit counts once on the packing step, however often its label is + // rescanned - and only once the packing itself is safely committed. + if ($firstPacking) { + $this->countOnPackingStep($unit, $operator); + } + + return $unit; + } + + /** + * The routing's packing step, if the order has one, gets the piece: that + * is what makes packing a step with a station, a duration and a count like + * any other, instead of activity beside the routing. A READY step is + * started by the first piece. Nothing here may stop the packing itself - + * a step the flow rules refuse (not reached yet, a required control still + * open, production not yet available) is logged and skipped, whatever the + * rule throws. + */ + private function countOnPackingStep(SerialUnit $unit, User $operator): void + { + $step = $this->packingStepFor($unit->work_order_id, $unit->batch_id); + if (! $step) { + return; + } + + try { + if ($step->status === BatchStep::STATUS_READY) { + $step = $this->batches->startStep($step, $operator); + } + $this->batches->recordQuantity($step, $operator, 1, 0); + } catch (\Throwable $e) { + Log::info('Packing step not counted: '.$e->getMessage(), ['batch_step_id' => $step->id, 'serial_unit_id' => $unit->id]); + } + } + + /** + * The packing steps a station works on right now: every order with a + * packing step that is ready or running, with the step's configuration. + * With a workstation selected only steps bound to it (or to none) show. + * + * @return array> + */ + public function openPackingSteps(?int $workstationId = null): array + { + return BatchStep::query() + ->where('kind', TemplateStep::KIND_PACKING) + ->whereIn('status', [BatchStep::STATUS_PENDING, BatchStep::STATUS_READY, BatchStep::STATUS_IN_PROGRESS]) + ->when($workstationId, fn ($q) => $q->where(fn ($w) => $w->whereNull('workstation_id')->orWhere('workstation_id', $workstationId))) + ->with(['batch:id,work_order_id,batch_number,target_qty', 'batch.workOrder:id,order_no,product_type_id,planned_qty', 'batch.workOrder.productType:id,name', 'workstation:id,name']) + ->whereHas('batch.workOrder', fn ($q) => $q->packable()) + ->orderBy('status')->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn (BatchStep $s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'status' => $s->status, + 'step_number' => $s->step_number, + 'config' => $s->config ?? [], + 'workstation' => $s->workstation?->name, + 'batch_number' => $s->batch?->batch_number, + 'target_qty' => (float) ($s->batch?->target_qty ?? 0), + 'passed_qty' => (float) $s->passed_qty, + 'work_order_id' => $s->batch?->work_order_id, + 'order_no' => $s->batch?->workOrder?->order_no, + 'product' => $s->batch?->workOrder?->productType?->name, + 'materials' => $this->packingMaterials($s), + ]) + ->values() + ->all(); + } + + /** @throws BindingException when the pallet is closed or belongs to another order */ + public function openCarton(User $operator, ?WorkOrder $workOrder = null, ?Pallet $pallet = null, ?int $workstationId = null): UnitCarton + { + if ($pallet && ! $pallet->isOpen()) { + throw new BindingException(__('Pallet :no is not open.', ['no' => $pallet->pallet_no])); + } + if ($pallet && $workOrder && $pallet->work_order_id && $pallet->work_order_id !== $workOrder->id) { + throw new BindingException(__('Pallet :no belongs to another work order.', ['no' => $pallet->pallet_no])); + } + UnitCarton::where('active_by_id', $operator->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + + return UnitCarton::create([ + 'work_order_id' => $workOrder?->id ?? $pallet?->work_order_id, + 'pallet_id' => $pallet?->id, + 'status' => UnitCarton::STATUS_OPEN, + 'created_by_id' => $operator->id, + 'active_by_id' => $operator->id, + 'active_workstation_id' => $workstationId ?? $operator->workstation_id, + 'activated_at' => now(), + ]); + } + + /** @throws BindingException on an empty carton - a box with nothing in it has no label to print */ + public function closeCarton(UnitCarton $carton, User $operator): UnitCarton + { + return DB::transaction(function () use ($carton, $operator) { + // Under lock: the station's auto-close and a click on "Close" may land + // together, and the box's packaging is booked once. + $carton = UnitCarton::whereKey($carton->id)->lockForUpdate()->firstOrFail(); + if (! $carton->isOpen()) { + return $carton; + } + if ($carton->units()->count() === 0) { + throw new BindingException(__('Carton :no is empty.', ['no' => $carton->carton_no])); + } + $carton->update(['status' => UnitCarton::STATUS_CLOSED, 'closed_at' => now(), 'closed_by_id' => $operator->id, 'active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $this->consumePackaging($carton->work_order_id, BomItem::PER_CARTON); + + return $carton->fresh(); + }); + } + + /** + * Put a carton (and every unit in it) on a pallet. The pallet's piece count + * grows by the carton's, so "pieces on pallet" stays true for serialised + * goods that never went through the EAN scan. + */ + public function assignCartonToPallet(UnitCarton $carton, Pallet $pallet, User $operator, ?int $workstationId = null): UnitCarton + { + return DB::transaction(function () use ($carton, $pallet, $operator, $workstationId) { + if (! $pallet->isOpen()) { + throw new BindingException(__('Pallet :no is not open.', ['no' => $pallet->pallet_no])); + } + if ($carton->pallet_id === $pallet->id) { + return $carton; + } + if ($carton->pallet_id !== null) { + throw new BindingException(__('Carton :no is already on pallet :pallet.', ['no' => $carton->carton_no, 'pallet' => $carton->pallet?->pallet_no])); + } + // A pallet belongs to an order, like a carton does: the two must agree. + if ($carton->work_order_id && $pallet->work_order_id && $carton->work_order_id !== $pallet->work_order_id) { + throw new BindingException(__('Carton :no belongs to another order than pallet :pallet.', ['no' => $carton->carton_no, 'pallet' => $pallet->pallet_no])); + } + // A unit already counted on another pallet does not move silently. + $elsewhere = $carton->units()->whereNotNull('pallet_id')->where('pallet_id', '!=', $pallet->id)->first(); + if ($elsewhere) { + throw new BindingException(__('Unit :sn is already on pallet :no.', ['sn' => $elsewhere->display_id, 'no' => $elsewhere->pallet?->pallet_no])); + } + + $carton->update(['pallet_id' => $pallet->id]); + // Only the units not yet on this pallet count and get a history row: + // one may have been scanned straight onto it before the box was placed. + $arriving = $carton->units()->where(fn ($q) => $q->whereNull('pallet_id')->orWhere('pallet_id', '!=', $pallet->id))->get(); + if ($arriving->isNotEmpty()) { + $pallet->increment('qty', $arriving->count()); + } + + foreach ($arriving as $unit) { + $unit->update(['pallet_id' => $pallet->id]); + $this->serials->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => ['event' => 'palletised', 'carton_no' => $carton->carton_no, 'pallet_no' => $pallet->pallet_no], + ]); + } + // Only now are the carton's units on the pallet: its quality is theirs. + $pallet->recomputeQualityStatus(); + + return $carton->fresh(); + }); + } + + /** + * The pallet left: every unit on it is shipped, scrap excepted. Called from + * the pallet's own status transition so it holds whichever screen ships it. + */ + public function markPalletShipped(Pallet $pallet, ?User $actor = null): int + { + // A held unit does not leave with the pallet unnoticed: it keeps its hold. + $units = SerialUnit::where('pallet_id', $pallet->id) + ->whereNotIn('status', [SerialUnit::STATUS_SCRAPPED, SerialUnit::STATUS_SHIPPED, SerialUnit::STATUS_BLOCKED]) + ->get(); + + foreach ($units as $unit) { + $unit->update(['status' => SerialUnit::STATUS_SHIPPED, 'shipped_at' => $pallet->shipped_at ?? now()]); + if ($actor) { + $this->serials->recordStep($unit, $actor, null, [ + 'parameters' => ['event' => 'shipped', 'pallet_no' => $pallet->pallet_no], + ]); + } + } + + return $units->count(); + } +} diff --git a/backend/app/Services/Production/OperatorScreens.php b/backend/app/Services/Production/OperatorScreens.php new file mode 100644 index 000000000..e749a30ef --- /dev/null +++ b/backend/app/Services/Production/OperatorScreens.php @@ -0,0 +1,249 @@ +> derived screens per workstation id; the service is request-scoped */ + private array $cache = []; + + public function __construct(private readonly OperatorWorkstationSelection $selection) {} + + /** + * The screens for this user at the bench this session works at. The bench + * comes from the same source the operator pages use (the one picked, else a + * workstation account's own), so the whole-line view shows every tab. + */ + public function forRequest(Request $request): array + { + $user = $request->user(); + if (! $user || ! $request->hasSession()) { + return self::ALL; + } + $workstationId = $this->selection->storedWorkstationId($request); + + return $this->for($user, $workstationId ? Workstation::find($workstationId) : null); + } + + public function for(User $user, ?Workstation $workstation): array + { + if (! $workstation || $user->hasAnyRole(['Admin', 'Supervisor'])) { + return self::ALL; + } + + return $this->forWorkstation($workstation); + } + + /** The bench's own list: pinned by an admin, else from the routing. */ + public function forWorkstation(Workstation $workstation): array + { + $pinned = array_values(array_intersect(self::ALL, (array) ($workstation->operator_screens ?? []))); + + return $pinned !== [] ? $pinned : $this->derived($workstation); + } + + /** What the routing sends to the bench, whatever an admin pinned. */ + public function derived(Workstation $workstation): array + { + return $this->derivedFor(collect([$workstation]))[$workstation->id]; + } + + /** + * Derived screens for several benches in one pass (the admin list shows + * them for every bench of a line). + * + * @param Collection $workstations + * @return array> keyed by workstation id + */ + public function derivedFor(Collection $workstations): array + { + $missing = $workstations->reject(fn (Workstation $w) => isset($this->cache[$w->id])); + if ($missing->isNotEmpty()) { + foreach ($this->derive($missing) as $id => $screens) { + $this->cache[$id] = $screens; + } + } + + return $workstations->mapWithKeys(fn (Workstation $w) => [$w->id => $this->cache[$w->id]])->all(); + } + + /** + * @param Collection $workstations + * @return array> + */ + private function derive(Collection $workstations): array + { + $ids = $workstations->pluck('id')->all(); + $types = $workstations->pluck('workstation_type_id')->filter()->unique()->values()->all(); + + // Steps of active templates: pinned to a bench, or open to any bench of + // a type (their own type, else their process segment's) and not pinned. + $templateSteps = TemplateStep::query() + ->whereHas('processTemplate', fn ($q) => $q->where('is_active', true)) + ->where(function ($q) use ($ids, $types) { + $q->whereIn('workstation_id', $ids); + if ($types !== []) { + $q->orWhere(fn ($t) => $t->whereNull('workstation_id')->where(fn ($ty) => $ty + ->whereIn('workstation_type_id', $types) + ->orWhere(fn ($seg) => $seg->whereNull('workstation_type_id') + ->whereHas('processSegment', fn ($ps) => $ps->whereIn('workstation_type_id', $types))))); + } + }) + ->with(['processTemplate:id,product_type_id', 'processTemplate.productType:id', 'processTemplate.productType.lines:id', 'processSegment:id,workstation_type_id']) + ->get(['id', 'kind', 'process_template_id', 'workstation_id', 'workstation_type_id', 'process_segment_id']); + + // Orders already running keep their snapshot's routing: their open steps + // count too, but not those of a cancelled batch or a finished order. + $batchSteps = BatchStep::query() + ->whereNotIn('status', [BatchStep::STATUS_DONE, BatchStep::STATUS_SKIPPED]) + ->whereHas('batch', fn ($b) => $b->whereNotIn('status', [Batch::STATUS_CANCELLED, Batch::STATUS_DONE]) + ->whereHas('workOrder', fn ($w) => $w->whereNotIn('status', [WorkOrder::STATUS_CANCELLED, WorkOrder::STATUS_DONE, WorkOrder::STATUS_REJECTED]))) + ->where(function ($q) use ($ids, $types) { + $q->whereIn('workstation_id', $ids); + if ($types !== []) { + $q->orWhere(fn ($t) => $t->whereNull('workstation_id')->whereIn('workstation_type_id', $types)); + } + }) + ->with('batch:id,work_order_id', 'batch.workOrder:id,product_type_id,line_id') + ->get(['id', 'kind', 'batch_id', 'workstation_id', 'workstation_type_id']); + + $serialProducts = $this->serialisedProducts( + $templateSteps->toBase()->map(fn ($s) => $s->processTemplate?->product_type_id) + ->concat($batchSteps->toBase()->map(fn ($s) => $s->batch?->workOrder?->product_type_id)) + ->filter()->unique()->values()->all() + ); + + $result = []; + foreach ($workstations as $ws) { + $steps = collect(); + foreach ($templateSteps as $s) { + $typeId = $s->workstation_type_id ?? $s->processSegment?->workstation_type_id; + $mine = $s->workstation_id === $ws->id + || ($s->workstation_id === null && $ws->workstation_type_id && $typeId === $ws->workstation_type_id + // by type only for products this bench's line makes + && ($s->processTemplate?->productType?->lines?->contains('id', $ws->line_id) ?? false)); + if ($mine) { + $steps->push(['kind' => $s->kind, 'product' => $s->processTemplate?->product_type_id]); + } + } + foreach ($batchSteps as $s) { + $mine = $s->workstation_id === $ws->id + || ($s->workstation_id === null && $ws->workstation_type_id && $s->workstation_type_id === $ws->workstation_type_id + && $s->batch?->workOrder?->line_id === $ws->line_id); + if ($mine) { + $steps->push(['kind' => $s->kind, 'product' => $s->batch?->workOrder?->product_type_id]); + } + } + $result[$ws->id] = $this->screensFrom($steps, $serialProducts); + } + + return $result; + } + + /** @param array $serialProducts */ + private function screensFrom(Collection $steps, array $serialProducts): array + { + $packing = $steps->contains(fn ($s) => $s['kind'] === TemplateStep::KIND_PACKING); + $production = $steps->filter(fn ($s) => ($s['kind'] ?? TemplateStep::KIND_PRODUCTION) !== TemplateStep::KIND_PACKING); + + $screens = []; + if ($production->isNotEmpty()) { + $screens[] = self::QUEUE; + $screens[] = self::WORKSTATION; + if ($production->contains(fn ($s) => in_array($s['product'], $serialProducts, true))) { + $screens[] = self::UNIT_LABELS; + } + } + if ($packing) { + $screens[] = self::PACKING; + } + + // A bench nothing is routed to still gets somewhere to work. + return $screens === [] ? [self::QUEUE, self::WORKSTATION] : $screens; + } + + /** + * The products that number their units (a serial or process-serial sequence), + * or whose routing makes a serial-tracked sub-assembly. + * + * @param array $productTypeIds + * @return array + */ + private function serialisedProducts(array $productTypeIds): array + { + if ($productTypeIds === []) { + return []; + } + + $numbered = LotSequence::query() + ->whereIn('purpose', [LotSequence::PURPOSE_PROCESS_SERIAL, LotSequence::PURPOSE_UNIT_SERIAL]) + ->whereIn('product_type_id', $productTypeIds) + ->distinct()->pluck('product_type_id'); + // A product whose routing makes a serial-tracked sub-assembly: its bench + // registers each piece by its own serial on the label station. + $makingSerialParts = \App\Models\ProcessTemplate::query() + ->whereIn('product_type_id', $productTypeIds) + ->whereIn('id', \App\Models\Material::query()->where('tracking_type', 'serial')->whereNotNull('producing_process_template_id')->select('producing_process_template_id')) + ->distinct()->pluck('product_type_id'); + + return $numbered->merge($makingSerialParts)->unique()->map(fn ($id) => (int) $id)->values()->all(); + } + + /** + * Where an operator lands after picking a line and bench: the line's + * default production view when the bench does production, else its first + * screen (a packing bench goes straight to packing). + */ + public function landingRoute(array $screens, string $lineDefaultView = 'queue'): string + { + $preferred = $lineDefaultView === 'workstation' ? self::WORKSTATION : self::QUEUE; + $screen = in_array($preferred, $screens, true) ? $preferred : ($screens[0] ?? self::QUEUE); + + return self::routeName($screen); + } + + public static function routeName(string $screen): string + { + return match ($screen) { + self::WORKSTATION => 'operator.workstation', + self::UNIT_LABELS => 'operator.unit-labels.station', + self::PACKING => 'operator.packaging', + default => 'operator.queue', + }; + } +} diff --git a/backend/app/Services/Production/OperatorWorkstationSelection.php b/backend/app/Services/Production/OperatorWorkstationSelection.php index 1b659efab..2c0389c8e 100644 --- a/backend/app/Services/Production/OperatorWorkstationSelection.php +++ b/backend/app/Services/Production/OperatorWorkstationSelection.php @@ -4,6 +4,7 @@ use App\Models\Batch; use App\Models\BatchStep; +use App\Models\Line; use App\Models\WorkOrder; use App\Models\Workstation; use App\Support\ProductionFlow; @@ -20,6 +21,57 @@ */ class OperatorWorkstationSelection { + /** + * The line an operator screen works on: `?line=` in the address wins and is + * kept in the session (only a line the user may work on - an operator's + * assigned lines, any line for a supervisor or admin), else the session, + * else a workstation account's own line. Null sends the caller to the line + * picker. With the address carrying the context, a link to the queue or a + * station opens on the right line for whoever follows it. + */ + public function resolveLine(Request $request): ?int + { + $user = $request->user(); + $raw = $request->query('line'); + if (is_numeric($raw)) { + $id = (int) $raw; + $allowed = $user->hasRole('Admin') || $user->hasRole('Supervisor') + ? Line::whereKey($id)->exists() + : $user->lines()->where('lines.id', $id)->exists(); + if ($allowed) { + if ((int) $request->session()->get('selected_line_id') !== $id) { + // Another line: the old workstation would not belong to it. + $request->session()->forget('selected_workstation_id'); + } + $request->session()->put('selected_line_id', $id); + + return $id; + } + } + + $id = $request->session()->get('selected_line_id') + ?? ($user?->account_type === 'workstation' ? $user->workstation?->line_id : null); + if ($id) { + $request->session()->put('selected_line_id', $id); + } + + return $id ? (int) $id : null; + } + + /** + * The bench this session works at, without reading the address: the one + * picked earlier, else a workstation account's own bench. Null means the + * whole line (an operator account's assigned bench is not a choice made). + */ + public function storedWorkstationId(Request $request, bool $fallBackToAccount = true): ?int + { + $user = $request->user(); + $id = $request->session()->get('selected_workstation_id') + ?? ($fallBackToAccount && $user?->account_type === 'workstation' ? $user->workstation_id : null); + + return $id ? (int) $id : null; + } + public function resolve(Request $request, int $lineId, bool $allowOtherLines = false, bool $fallBackToAccount = true): ?Workstation { if ($request->has('workstation')) { @@ -27,9 +79,7 @@ public function resolve(Request $request, int $lineId, bool $allowOtherLines = f $id = is_numeric($raw) ? (int) $raw : null; $request->session()->put('selected_workstation_id', $id); } else { - $user = $request->user(); - $id = $request->session()->get('selected_workstation_id') - ?? ($fallBackToAccount && $user?->account_type === 'workstation' ? $user->workstation_id : null); + $id = $this->storedWorkstationId($request, $fallBackToAccount); } if (! $id) { diff --git a/backend/app/Services/Settings/ConfigurationImporter.php b/backend/app/Services/Settings/ConfigurationImporter.php new file mode 100644 index 000000000..8ab1d5337 --- /dev/null +++ b/backend/app/Services/Settings/ConfigurationImporter.php @@ -0,0 +1,385 @@ + natural key columns, in dependency order. */ + public const TABLES = [ + 'sites' => ['code'], + 'areas' => ['code'], + 'view_templates' => ['name'], + 'warehouses' => ['code'], + 'lines' => ['code'], + 'workstation_types' => ['code'], + 'workstations' => ['code'], + 'material_types' => ['code'], + 'product_types' => ['code'], + 'line_product_type' => ['line_id', 'product_type_id'], + 'skills' => ['code'], + 'personnel_classes' => ['code'], + 'process_segments' => ['code'], + 'label_templates' => ['name'], + 'process_templates' => ['product_type_id', 'name'], + 'materials' => ['code'], + 'template_steps' => ['process_template_id', 'step_number'], + // A line is a material or a component product type: both are part of its identity. + 'bom_items' => ['process_template_id', 'template_step_id', 'material_id', 'product_type_id'], + 'lot_sequences' => ['product_type_id', 'purpose'], + 'scrap_reasons' => ['code'], + 'issue_types' => ['code'], + 'shifts' => ['code'], + 'line_statuses' => ['name'], + 'dashboard_widgets' => ['widget_id'], + 'maintenance_schedules' => ['name'], + 'inspection_plans' => ['name'], + ]; + + /** Table => [foreign key column => referenced table]. */ + public const REFERENCES = [ + 'areas' => ['site_id' => 'sites'], + 'lines' => ['area_id' => 'areas', 'view_template_id' => 'view_templates', 'division_id' => 'divisions', 'warehouse_id' => 'warehouses'], + 'workstations' => ['line_id' => 'lines', 'workstation_type_id' => 'workstation_types'], + 'materials' => ['material_type_id' => 'material_types', 'producing_process_template_id' => 'process_templates'], + 'line_product_type' => ['line_id' => 'lines', 'product_type_id' => 'product_types'], + 'process_segments' => ['workstation_type_id' => 'workstation_types'], + 'process_templates' => ['product_type_id' => 'product_types'], + 'template_steps' => ['process_template_id' => 'process_templates', 'workstation_id' => 'workstations', 'workstation_type_id' => 'workstation_types', 'process_segment_id' => 'process_segments'], + 'bom_items' => ['process_template_id' => 'process_templates', 'template_step_id' => 'template_steps', 'material_id' => 'materials', 'product_type_id' => 'product_types'], + 'lot_sequences' => ['product_type_id' => 'product_types'], + 'shifts' => ['line_id' => 'lines'], + 'line_statuses' => ['line_id' => 'lines'], + 'maintenance_schedules' => ['line_id' => 'lines', 'workstation_id' => 'workstations', 'tool_id' => 'tools', 'cost_source_id' => 'cost_sources'], + 'inspection_plans' => ['material_id' => 'materials', 'material_type_id' => 'material_types', 'root_id' => 'inspection_plans'], + ]; + + /** Settings the import may write (the settings form's own keys) besides those already stored. */ + public const SETTINGS = [ + 'production_period', 'allow_overproduction', 'block_negative_stock', 'force_sequential_steps', + 'workstation_routing_enabled', 'backflush_on_pallet_creation', 'pallet_stock_documents', 'hold_on_material_shortage', 'lot_tracking_enabled', 'lot_picking_strategy', + 'workflow_mode', 'pin_login_enabled', 'language', 'app_timezone', 'schedule_view_mode', 'schedule_shifts_per_day', + 'schedule_horizon_weeks', 'schedule_show_weekends', 'realtime_mode', 'production_tracking_mode', + 'production_qty_edit_policy', 'production_qty_edit_window_minutes', 'scanner_mode', + 'unit_identifier_normalize', 'unit_serial_pattern', 'unit_psn_pattern', 'unit_psn_required', 'unit_psn_unique', + 'unit_test_fail_policy', 'unit_test_max_attempts', 'unit_test_attempts_scope', 'standard_weekly_hours', 'default_currency', + 'default_pay_type', 'default_pay_rate', 'enabled_modules', + ]; + + /** Never taken from a file: infrastructure, security, and switches that need their own checks. */ + public const FORBIDDEN_SETTINGS = [ + 'app_key', 'app_debug', 'app_env', + 'db_host', 'db_port', 'db_database', 'db_username', 'db_password', 'db_connection', + 'mail_host', 'mail_port', 'mail_username', 'mail_password', + 'cors_allowed_origins', 'cors_allowed_methods', + 'modules_enabled', + // Switched only on its own form, with its own checks. + 'production_flow_mode', + ]; + + private const SKIP_COLUMNS = ['id', 'created_at', 'updated_at', 'tenant_id', 'deleted_at', 'deleted_by_id']; + + /** @var array> table => file id => database id */ + private array $ids = []; + + /** @var array tables the file carries rows for */ + private array $fileTables = []; + + /** @var array|null the settings form's rules, per key */ + private ?array $settingRules = null; + + /** + * @param array $data the decoded file + * @return int rows and settings written + */ + public function import(array $data): int + { + // Backward compat: old files carried only 'settings'. + if (isset($data['settings']) && ! isset($data['system_settings'])) { + $data['system_settings'] = $data['settings']; + } + $this->ids = []; + $this->fileTables = []; + foreach (array_keys(self::TABLES) as $table) { + if (! empty($data[$table]) && is_array($data[$table])) { + $this->fileTables[$table] = true; + } + } + $count = 0; + + DB::transaction(function () use ($data, &$count) { + if (is_array($data['system_settings'] ?? null)) { + $count += $this->importSettings($data['system_settings']); + } + foreach (self::TABLES as $table => $key) { + if (! isset($this->fileTables[$table]) || ! Schema::hasTable($table)) { + continue; + } + $columns = Schema::getColumnListing($table); + foreach ($data[$table] as $row) { + if (is_array($row) && $this->importRow($table, $key, $columns, $row)) { + $count++; + } + } + } + }); + + return $count; + } + + private function importSettings(array $settings): int + { + $existing = DB::table('system_settings')->pluck('key')->all(); + $count = 0; + foreach ($settings as $key => $value) { + $key = (string) $key; + if ($key === TimezoneRegistry::SETTING_KEY) { + // Only a zone the settings form offers, stored the way it stores + // it - and in force for the rest of this request (a scenario in + // the same file reads its times in the plant's zone). + $zone = is_string($value) ? (json_decode($value, true) ?? $value) : null; + if (is_string($zone) && TimezoneRegistry::isValid($zone)) { + TimezoneRegistry::save($zone); + TimezoneRegistry::apply(); + $count++; + } + + continue; + } + if (in_array(strtolower($key), self::FORBIDDEN_SETTINGS, true) + || (! in_array($key, $existing, true) && ! in_array($key, self::SETTINGS, true))) { + continue; + } + $stored = self::settingValue($value); + if ($stored === null || strlen($stored) > 1000 || ! $this->settingPasses($key, json_decode($stored, true))) { + continue; + } + DB::table('system_settings')->updateOrInsert(['key' => $key], ['value' => $stored]); + $count++; + } + + return $count; + } + + /** The settings form's rule for this key (without "required"), when it has one. */ + private function settingPasses(string $key, mixed $value): bool + { + $this->settingRules ??= (new UpdateSystemSettingsRequest)->rules(); + if (! array_key_exists($key, $this->settingRules)) { + return true; // a stored key the form does not own is kept as before + } + $clean = fn (mixed $rule) => collect(is_array($rule) ? $rule : explode('|', (string) $rule)) + ->reject(fn ($r) => is_string($r) && ($r === 'sometimes' || str_starts_with($r, 'required'))) + ->prepend('nullable')->values()->all(); + $rules = [$key => $clean($this->settingRules[$key])]; + if (array_key_exists($key.'.*', $this->settingRules)) { + $rules[$key.'.*'] = $clean($this->settingRules[$key.'.*']); + } + + return Validator::make([$key => $value], $rules)->passes(); + } + + /** A setting as the form stores it: JSON text. An exported value already is. */ + private static function settingValue(mixed $value): ?string + { + if (is_string($value)) { + json_decode($value); + + return json_last_error() === JSON_ERROR_NONE ? $value : json_encode($value); + } + if (is_bool($value) || is_int($value) || is_float($value) || is_array($value) || $value === null) { + return json_encode($value); + } + + return null; + } + + private function importRow(string $table, array $key, array $columns, array $row): bool + { + // Soft-deleted rows in an export stay out. + if (! empty($row['deleted_at'])) { + return false; + } + $fileId = $row['id'] ?? null; + $references = self::REFERENCES[$table] ?? []; + + foreach ($references as $column => $refTable) { + if (array_key_exists($column, $row) && $row[$column] !== null) { + $row[$column] = $this->resolve($refTable, $row[$column]); + } + } + // Files from before sequences had a purpose: every sequence was a LOT one. + if ($table === 'lot_sequences' && empty($row['purpose'])) { + $row['purpose'] = 'lot'; + } + if ($table === 'template_steps' && isset($row['config'])) { + $row['config'] = $this->resolveStepConfig($row['config']); + } + + $values = []; + foreach ($row as $column => $value) { + if (! in_array($column, $columns, true) || in_array($column, self::SKIP_COLUMNS, true) || $value === null) { + continue; + } + // Another database's ids (a user, a record we do not import) mean nothing here. + if (str_ends_with($column, '_id') && ! isset($references[$column]) && ! in_array($column, $key, true)) { + continue; + } + $values[$column] = is_array($value) ? json_encode($value) : $value; + } + foreach ($key as $column) { + // A key column the row leaves empty still has to match that empty value. + if (! array_key_exists($column, $values) && in_array($column, $columns, true)) { + $values[$column] = null; + } + } + if ($values === [] || collect($key)->every(fn ($c) => ($values[$c] ?? null) === null)) { + return false; + } + + if ($table === 'warehouses' && ! empty($values['is_default'])) { + $values = $this->keepOneDefaultWarehouse($values); + } + + $match = array_intersect_key($values, array_flip($key)); + $softDeletes = in_array('deleted_at', $columns, true); + if ($softDeletes) { + // A match that was deleted comes back whole - not still marked as deleted by someone. + $values += ['deleted_at' => null] + (in_array('deleted_by_id', $columns, true) ? ['deleted_by_id' => null] : []); + } + $now = now(); + $withTimes = fn (array $v, bool $insert) => $v + + (in_array('updated_at', $columns, true) ? ['updated_at' => $now] : []) + + ($insert && in_array('created_at', $columns, true) ? ['created_at' => $now] : []); + $matching = function () use ($table, $match) { + $query = DB::table($table); + foreach ($match as $column => $value) { + $value === null ? $query->whereNull($column) : $query->where($column, $value); + } + + return $query; + }; + + try { + DB::statement('SAVEPOINT config_row'); + $query = $matching(); + if ($softDeletes) { + // The live row first: a deleted twin with the same code must not be the one updated. + $query->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END'); + } + $existing = in_array('id', $columns, true) ? $query->orderBy('id')->first() : $query->first(); + if ($existing) { + $update = $withTimes(array_diff_key($values, $match), false); + if ($update !== []) { + // By id where there is one: only the chosen row changes, not its deleted twin. + (isset($existing->id) ? DB::table($table)->where('id', $existing->id) : $matching())->update($update); + } + $newId = $existing->id ?? null; + } else { + $insert = $withTimes($values, true); + if (in_array('id', $columns, true)) { + $newId = DB::table($table)->insertGetId($insert); + } else { + DB::table($table)->insert($insert); // a pivot: nothing refers to it by id + $newId = null; + } + } + DB::statement('RELEASE SAVEPOINT config_row'); + } catch (\Throwable) { + DB::statement('ROLLBACK TO SAVEPOINT config_row'); + + return false; + } + + if ($fileId !== null && $newId !== null) { + $this->ids[$table][$fileId] = (int) $newId; + } + + return true; + } + + /** + * One default warehouse per kind: when the plant already has another, the + * imported one comes in as a regular warehouse instead of failing (and + * taking every line that points at it down with it). + */ + private function keepOneDefaultWarehouse(array $values): array + { + $taken = DB::table('warehouses')->whereNull('deleted_at')->where('is_default', true) + ->where('kind', $values['kind'] ?? null)->where('code', '!=', $values['code'] ?? '')->exists(); + if ($taken) { + $values['is_default'] = false; + } + + return $values; + } + + /** + * A file id to this database's id. A table the file carries answers only + * through its own rows; one it does not carry, by an existing row. + */ + private function resolve(string $table, mixed $fileId): ?int + { + if (isset($this->ids[$table][$fileId])) { + return $this->ids[$table][$fileId]; + } + if (isset($this->fileTables[$table])) { + return null; + } + if (Schema::hasTable($table) && DB::table($table)->where('id', $fileId)->exists()) { + return (int) $fileId; + } + + return null; + } + + /** A packing step's label template is referenced inside its config. */ + private function resolveStepConfig(mixed $config): mixed + { + $decoded = is_string($config) ? json_decode($config, true) : $config; + if (! is_array($decoded)) { + return $config; + } + if (isset($decoded['label_template_id'])) { + $id = $this->resolve('label_templates', $decoded['label_template_id']); + if ($id === null) { + unset($decoded['label_template_id']); + } else { + $decoded['label_template_id'] = $id; + } + } + + return $decoded; + } +} diff --git a/backend/app/Services/Settings/ProductionScenarioImporter.php b/backend/app/Services/Settings/ProductionScenarioImporter.php new file mode 100644 index 000000000..cf09e105e --- /dev/null +++ b/backend/app/Services/Settings/ProductionScenarioImporter.php @@ -0,0 +1,626 @@ + */ + private array $users = []; + + /** @var array file reference => serial unit id */ + private array $units = []; + + /** @var array file reference => pallet id */ + private array $pallets = []; + + private int $tests = 0; + + public function __construct( + private readonly WorkOrderService $workOrders, + private readonly BatchService $batches, + private readonly SerialTraceService $serials, + private readonly UnitPackingService $packing, + private readonly TestLogImporter $testLogs, + private readonly LotService $lots, + private readonly PalletBackflushService $backflush, + private readonly SchedulePlannerService $planner, + ) {} + + /** + * @param array $scenario the file's `scenario` section + * @param User $actor who imports - acts for events that name nobody + * @return int events replayed + * + * @throws ScenarioException + */ + public function import(array $scenario, User $actor): int + { + $this->actor = $actor; + $frozen = [Carbon::getTestNow(), CarbonImmutable::getTestNow()]; + $this->base = CarbonImmutable::now(); + $this->clock = null; + $this->users = $this->units = $this->pallets = []; + $this->tests = 0; + + $mode = $scenario['requires']['production_flow_mode'] ?? null; + if (is_string($mode) && ProductionFlow::mode() !== $mode) { + throw new ScenarioException(__('This production scenario needs the ":mode" production flow mode. Change it under Settings → Production and import again.', ['mode' => $mode])); + } + + $events = array_values(array_filter((array) ($scenario['events'] ?? []), 'is_array')); + + try { + foreach (array_values((array) ($scenario['users'] ?? [])) as $i => $row) { + $this->step(__('user :n', ['n' => $i + 1]), fn () => $this->createUser((array) $row)); + } + foreach (array_values((array) ($scenario['material_lots'] ?? [])) as $i => $row) { + $this->step(__('material lot :n', ['n' => $i + 1]), fn () => $this->createLot((array) $row)); + } + foreach ($events as $i => $event) { + $label = __('event :n (:type)', ['n' => $i + 1, 'type' => $event['do'] ?? '?']); + $this->step($label, function () use ($event) { + $this->moveClock($event['at'] ?? null); + $this->apply($event); + }); + } + } finally { + // Back to the real clock - or to whatever a caller had frozen it at. + Carbon::setTestNow($frozen[0]); + CarbonImmutable::setTestNow($frozen[1]); + } + + return count($events); + } + + /** Run one entry; any failure becomes a message naming the entry. */ + private function step(string $label, callable $run): void + { + try { + $run(); + } catch (\Throwable $e) { + throw new ScenarioException($label.': '.$e->getMessage(), 0, $e); + } + } + + private function apply(array $e): void + { + match ($e['do'] ?? null) { + 'work_order' => $this->createWorkOrder($e), + 'batch' => $this->createBatch($e), + 'schedule' => $this->schedule($e), + 'step_start' => $this->batches->startStep($this->batchStep($e), $this->user($e)), + 'step_log' => $this->batches->recordQuantity($this->batchStep($e), $this->user($e), (float) ($e['good'] ?? 0), (float) ($e['scrap'] ?? 0), $e['note'] ?? null), + 'step_complete' => $this->batches->completeStep($this->batchStep($e), $this->user($e)), + 'unit_start' => $this->startUnit($e), + 'subassembly' => $this->registerSubassembly($e), + 'unit_serial' => $this->bindSerial($e), + 'component' => $this->bindComponent($e), + 'test' => $this->recordTest($e), + 'block' => $this->serials->blockUnit($this->unit($e), $this->user($e), $this->scrapReason($e), $e['note'] ?? null, $this->workstation($e)?->id), + 'unblock' => $this->serials->unblockUnit($this->unit($e), $this->user($e), (string) ($e['note'] ?? __('Released')), $this->workstation($e)?->id), + 'scrap' => $this->serials->scrapUnit($this->unit($e), $this->user($e), (string) ($e['reason'] ?? __('Scrapped')), $this->workstation($e)?->id), + 'pack' => $this->pack($e), + 'carton_close' => $this->closeCarton($e), + 'pallet_open' => $this->openPallet($e), + 'pallet_close' => $this->closePallet($e), + 'pallet_ship' => $this->shipPallet($e), + default => throw new ScenarioException(__('Unknown event type ":type".', ['type' => $e['do'] ?? ''])), + }; + } + + // ── time ──────────────────────────────────────────────────────────────── + + private function moveClock(mixed $spec): void + { + $time = $spec === null || $spec === '' + ? ($this->clock ?? $this->base)->addSeconds(10) + : $this->parseTime((string) $spec); + + if ($time->greaterThan($this->base)) { + throw new ScenarioException(__('The time :time is after the import - scenario events must lie in the past.', ['time' => $time->format('Y-m-d H:i')])); + } + if ($this->clock && $time->lessThan($this->clock)) { + throw new ScenarioException(__('The time :time is earlier than the previous event - list events in time order.', ['time' => $time->format('Y-m-d H:i:s')])); + } + + $this->clock = $time; + Carbon::setTestNow($time); + CarbonImmutable::setTestNow($time); + } + + /** "-2d 07:30" a day relative to the import at a clock time; "-3h20m" before the import; "+4m" after the previous event; or an absolute date. */ + private function parseTime(string $spec, bool $allowFuture = false): CarbonImmutable + { + $spec = trim($spec); + if (preg_match('/^([+-]?\d+)d\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/i', $spec, $m)) { + return $this->base->startOfDay()->addDays((int) $m[1])->setTime((int) $m[2], (int) $m[3], (int) ($m[4] ?? 0)); + } + if (preg_match('/^([+-])\s*((?:\d+\s*[dhms]\s*)+)$/i', $spec, $m)) { + $seconds = 0; + preg_match_all('/(\d+)\s*([dhms])/i', $m[2], $parts, PREG_SET_ORDER); + foreach ($parts as [, $n, $unit]) { + $seconds += (int) $n * ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1][strtolower($unit)]; + } + + return $m[1] === '+' + ? ($allowFuture ? $this->base : ($this->clock ?? $this->base))->addSeconds($seconds) + : $this->base->subSeconds($seconds); + } + try { + return CarbonImmutable::parse($spec); + } catch (\Throwable) { + throw new ScenarioException(__('Unreadable time ":time".', ['time' => $spec])); + } + } + + // ── references ────────────────────────────────────────────────────────── + + private function user(array $e): User + { + $name = $e['by'] ?? null; + if ($name === null || $name === '') { + return $this->actor; + } + + return $this->users[$name] ??= User::where('username', $name)->first() + ?? throw new ScenarioException(__('Unknown user ":name" - list the account under scenario.users.', ['name' => $name])); + } + + private function workOrder(array $e): WorkOrder + { + $no = (string) ($e['order'] ?? ''); + + return WorkOrder::where('order_no', $no)->first() + ?? throw new ScenarioException(__('Unknown work order ":no".', ['no' => $no])); + } + + private function workstation(array $e): ?Workstation + { + if (empty($e['workstation'])) { + return null; + } + + return Workstation::where('code', $e['workstation'])->first() + ?? throw new ScenarioException(__('Unknown workstation ":code".', ['code' => $e['workstation']])); + } + + private function batchStep(array $e): BatchStep + { + $order = $this->workOrder($e); + $batch = $order->batches()->where('batch_number', (int) ($e['batch'] ?? 1))->first() + ?? throw new ScenarioException(__('Work order :no has no batch :n.', ['no' => $order->order_no, 'n' => $e['batch'] ?? 1])); + + return $batch->steps()->where('step_number', (int) ($e['step'] ?? 0))->first() + ?? throw new ScenarioException(__('Batch :n of :no has no step :step.', ['n' => $batch->batch_number, 'no' => $order->order_no, 'step' => $e['step'] ?? '?'])); + } + + private function unit(array $e): SerialUnit + { + $ref = (string) ($e['unit'] ?? ''); + $unit = isset($this->units[$ref]) ? SerialUnit::find($this->units[$ref]) : SerialUnit::findByIdentifier($ref); + + return $unit ?? throw new ScenarioException(__('Unknown unit ":ref".', ['ref' => $ref])); + } + + private function pallet(string $ref): Pallet + { + $pallet = isset($this->pallets[$ref]) ? Pallet::find($this->pallets[$ref]) : Pallet::where('pallet_no', $ref)->first(); + + return $pallet ?? throw new ScenarioException(__('Unknown pallet ":ref".', ['ref' => $ref])); + } + + private function scrapReason(array $e): ScrapReason + { + return ScrapReason::where('code', $e['reason'] ?? '')->first() + ?? throw new ScenarioException(__('Unknown error code ":code".', ['code' => $e['reason'] ?? ''])); + } + + // ── set-up ────────────────────────────────────────────────────────────── + + /** A new account; an existing one is used as it is - never changed. */ + private function createUser(array $row): void + { + $username = trim((string) ($row['username'] ?? '')); + if ($username === '') { + throw new ScenarioException(__('An account needs a username.')); + } + if ($existing = User::where('username', $username)->first()) { + $this->users[$username] = $existing; + + return; + } + + // A workstation account is a bench's own terminal login: locked to that bench, an operator. + $type = ($row['account_type'] ?? 'user') === 'workstation' ? 'workstation' : 'user'; + $role = $type === 'workstation' ? 'Operator' : (string) ($row['role'] ?? 'Operator'); + if (! Role::where('name', $role)->exists()) { + throw new ScenarioException(__('Unknown role ":role".', ['role' => $role])); + } + // A file shared around must not leave an administrator with a known password behind. + if ($role === 'Admin') { + throw new ScenarioException(__('A scenario file cannot create administrator accounts.')); + } + $workstation = $this->workstation($row); + if ($type === 'workstation' && ! $workstation) { + throw new ScenarioException(__('A workstation account needs its workstation.')); + } + + $user = User::create([ + 'username' => $username, + 'name' => (string) ($row['name'] ?? $username), + 'email' => (string) ($row['email'] ?? $username.'@example.invalid'), + 'password' => Hash::make((string) ($row['password'] ?? Str::random(40))), + 'account_type' => $type, + 'workstation_id' => $workstation?->id, + ]); + $user->assignRole($role); + $lineIds = Line::whereIn('code', (array) ($row['lines'] ?? []))->pluck('id'); + if ($lineIds->isNotEmpty()) { + $user->lines()->syncWithoutDetaching($lineIds->all()); + } + $this->users[$username] = $user; + } + + private function createLot(array $row): void + { + $material = Material::where('code', $row['material'] ?? '')->first() + ?? throw new ScenarioException(__('Unknown material ":code".', ['code' => $row['material'] ?? ''])); + $quantity = (float) ($row['quantity'] ?? 0); + + MaterialLot::firstOrCreate(['lot_number' => (string) ($row['lot_number'] ?? '')], [ + 'material_id' => $material->id, + 'quantity_received' => $quantity, + 'quantity_available' => $quantity, + 'unit_of_measure' => $row['unit_of_measure'] ?? $material->unit_of_measure, + 'received_at' => isset($row['received']) ? $this->parseTime((string) $row['received']) : $this->base, + 'status' => MaterialLot::STATUS_RELEASED, + 'supplier_lot_no' => $row['supplier_lot_no'] ?? null, + ]); + } + + // ── orders and steps ──────────────────────────────────────────────────── + + private function createWorkOrder(array $e): void + { + $no = (string) ($e['order_no'] ?? ''); + if ($no === '' || WorkOrder::where('order_no', $no)->exists()) { + throw new ScenarioException(__('Work order ":no" is missing a number or already exists.', ['no' => $no])); + } + $product = ProductType::where('code', $e['product'] ?? '')->first() + ?? throw new ScenarioException(__('Unknown product ":code".', ['code' => $e['product'] ?? ''])); + $line = isset($e['line']) ? (Line::where('code', $e['line'])->first() + ?? throw new ScenarioException(__('Unknown line ":code".', ['code' => $e['line']]))) : null; + + $this->workOrders->createWorkOrder([ + 'order_no' => $no, + 'product_type_id' => $product->id, + 'line_id' => $line?->id, + 'planned_qty' => (float) ($e['quantity'] ?? 0), + 'customer_order_no' => $e['customer_order_no'] ?? null, + 'priority' => (int) ($e['priority'] ?? 0), + 'due_date' => isset($e['due']) ? $this->parseTime((string) $e['due'], true)->toDateString() : null, + 'description' => $e['description'] ?? null, + ]); + } + + /** + * Place an order on the schedule, as dragging it on the planner does. Its + * window may lie ahead ("+1d 06:00"): planning is about the future. An + * overlap with another order on the line is refused unless `force`. + */ + private function schedule(array $e): void + { + $order = $this->workOrder($e); + $input = []; + if (isset($e['line'])) { + $input['line_id'] = Line::where('code', $e['line'])->value('id') + ?? throw new ScenarioException(__('Unknown line ":code".', ['code' => $e['line']])); + } + $start = isset($e['start']) ? $this->parseTime((string) $e['start'], true) : null; + $end = isset($e['end']) ? $this->parseTime((string) $e['end'], true) : null; + if ($start) { + $input['planned_start_at'] = $start->toDateTimeString(); + } + if ($end) { + $input['planned_end_at'] = $end->toDateTimeString(); + } + if (isset($e['due']) || $start || $end) { + $input['due_date'] = (isset($e['due']) ? $this->parseTime((string) $e['due'], true) : ($end ?? $start))->toDateString(); + } + if (isset($e['shift'])) { + $input['shift_number'] = (int) $e['shift']; + } + + $result = $this->planner->updateOrder($order, $input, (bool) ($e['force'] ?? false)); + if ($result['conflict']) { + throw new ScenarioException($result['message']); + } + } + + private function createBatch(array $e): void + { + $order = $this->workOrder($e); + $lot = $e['lot'] ?? null; + if ($lot === 'auto') { + $lot = $this->lots->generateLot($order->productType); + } + $this->workOrders->createBatch($order, (float) ($e['quantity'] ?? $order->planned_qty), $this->workstation($e)?->id, $lot); + } + + // ── units ─────────────────────────────────────────────────────────────── + + private function startUnit(array $e): void + { + $order = $this->workOrder($e); + $psn = $e['psn'] ?? 'auto'; + if ($psn === 'auto') { + $psn = $this->lots->generate($order->productType, LotSequence::PURPOSE_PROCESS_SERIAL); + } + $unit = $this->serials->startUnit((string) $psn, $this->user($e), [ + 'work_order_id' => $order->id, + 'workstation_id' => $this->workstation($e)?->id, + ]); + $this->remember($e, $unit); + } + + /** A sub-assembly registered by its own serial where it is made. */ + private function registerSubassembly(array $e): void + { + $material = Material::where('code', $e['material'] ?? '')->first() + ?? throw new ScenarioException(__('Unknown material ":code".', ['code' => $e['material'] ?? ''])); + $unit = $this->serials->registerSubassembly((string) ($e['sn'] ?? ''), $material, $this->workOrder($e), $this->user($e), $this->workstation($e)?->id); + $this->remember($e, $unit); + } + + private function bindSerial(array $e): void + { + $unit = $this->unit($e); + $sn = $e['sn'] ?? 'auto'; + if ($sn === 'auto') { + $sn = $this->lots->generate($unit->workOrder?->productType, LotSequence::PURPOSE_UNIT_SERIAL); + } + $bound = $this->serials->bindProcessSerial((string) $sn, $unit->psn, $this->user($e), [ + 'work_order_id' => $unit->work_order_id, + 'workstation_id' => $this->workstation($e)?->id, + ]); + $this->remember(['ref' => $e['unit']], $bound['unit']); + } + + private function bindComponent(array $e): void + { + $unit = $this->unit($e); + $material = isset($e['material']) ? (Material::where('code', $e['material'])->first() + ?? throw new ScenarioException(__('Unknown material ":code".', ['code' => $e['material']]))) : null; + $step = isset($e['step']) && $unit->work_order_id + ? $this->batchStep(['order' => $unit->workOrder->order_no, 'batch' => $e['batch'] ?? 1, 'step' => $e['step']]) + : null; + + $this->serials->bindComponent($unit, (string) ($e['identifier'] ?? ''), $this->user($e), [ + 'material_id' => $material?->id, + 'quantity' => $e['quantity'] ?? 1, + 'batch_step_id' => $step?->id, + 'workstation_id' => $this->workstation($e)?->id, + ]); + } + + /** A tester's run, imported the way the test-run API imports it. */ + private function recordTest(array $e): void + { + $unit = $this->unit($e); + if (empty($unit->serial_no)) { + throw new ScenarioException(__('Unit :ref has no serial number yet - a test result is filed under the SN.', ['ref' => $unit->display_id])); + } + $workstation = $this->workstation($e); + $end = CarbonImmutable::now(); + $steps = []; + foreach (array_values((array) ($e['steps'] ?? [])) as $i => $s) { + $s = (array) $s; + $verdict = strtolower((string) ($s['verdict'] ?? 'pass')); + $steps[] = [ + 'id' => (string) ($s['id'] ?? $i + 1), + 'name' => $s['name'] ?? null, + 'verdict' => $verdict === 'fail' ? TestRun::FAIL : TestRun::PASS, + 'duration_ms' => null, + 'measurements' => array_key_exists('value', $s) ? [array_filter([ + 'name' => $s['name'] ?? null, 'value' => $s['value'], 'unit' => $s['unit'] ?? null, + 'low' => $s['low'] ?? null, 'high' => $s['high'] ?? null, + ], fn ($v) => $v !== null)] : [], + ]; + } + $failed = array_values(array_map(fn ($s) => $s['id'], array_filter($steps, fn ($s) => $s['verdict'] === TestRun::FAIL))); + $verdict = strtolower((string) ($e['result'] ?? 'pass')) === 'fail' || $failed !== [] ? TestRun::FAIL : TestRun::PASS; + + $this->testLogs->import(new TestRun( + serialNo: $unit->serial_no, + psn: $unit->psn, + runId: (string) ($e['run_id'] ?? sprintf('scenario-%s-%d', $unit->serial_no, ++$this->tests)), + verdict: $verdict, + startedAt: $end->subSeconds((int) ($e['duration_s'] ?? 60)), + endedAt: $end, + station: $workstation?->code, + line: $unit->workOrder?->line?->code, + operator: null, + steps: $steps, + failedSteps: $failed, + ), ['work_order' => $unit->workOrder, 'workstation' => $workstation, 'operator' => $this->user($e), 'source' => 'scenario']); + } + + private function remember(array $e, SerialUnit $unit): void + { + $ref = $e['ref'] ?? null; + if (is_string($ref) && $ref !== '') { + $this->units[$ref] = $unit->id; + } + } + + // ── packing ───────────────────────────────────────────────────────────── + + /** Scan at the packing bench: weigh when the step asks, open a carton when none is open, pack. */ + private function pack(array $e): void + { + $unit = $this->unit($e); + $user = $this->user($e); + $workstation = $this->workstation($e); + $config = $this->packing->packingConfigFor($unit->work_order_id); + + $measured = []; + if (isset($config['weight_expected_g'])) { + if (! isset($e['weight_g'])) { + throw new ScenarioException(__('The packing step weighs every unit - give weight_g.')); + } + $weight = round((float) $e['weight_g'], 2); + $expected = (float) $config['weight_expected_g']; + $tolerance = (float) ($config['weight_tolerance_g'] ?? 0); + if (abs($weight - $expected) > $tolerance + 1e-9) { + // Refused at the scale, as at the bench: the reading stays on the unit's history. + $this->serials->recordStep($unit, $user, null, [ + 'workstation_id' => $workstation?->id, + 'parameters' => ['event' => 'weight_check', 'verdict' => 'fail', 'weight_g' => $weight, 'expected_g' => $expected, 'tolerance_g' => $tolerance], + ]); + + return; + } + $measured = ['weight_g' => $weight]; + } + + $pallet = isset($e['pallet']) ? $this->pallet((string) $e['pallet']) : null; + $carton = null; + if ($config !== [] && ($config['unit'] ?? 'carton') === 'carton') { + $carton = UnitCarton::where('active_by_id', $user->id)->where('status', UnitCarton::STATUS_OPEN) + ->where('work_order_id', $unit->work_order_id)->first() + ?? $this->packing->openCarton($user, $unit->workOrder, $pallet, $workstation?->id); + } + + $unit = $this->packing->packUnit($unit, $user, $carton, $carton ? null : $pallet, $workstation?->id, $measured); + $this->packing->closePalletIfFull($unit->pallet); + } + + /** Close the operator's open carton for the order, optionally putting it on a pallet. */ + private function closeCarton(array $e): void + { + $order = $this->workOrder($e); + $user = $this->user($e); + $carton = UnitCarton::where('work_order_id', $order->id)->where('status', UnitCarton::STATUS_OPEN) + ->orderByRaw('CASE WHEN active_by_id = ? THEN 0 ELSE 1 END', [$user->id])->latest('id')->first() + ?? throw new ScenarioException(__('Work order :no has no open carton.', ['no' => $order->order_no])); + + $carton = $this->packing->closeCarton($carton, $user); + if (isset($e['pallet'])) { + $pallet = $this->pallet((string) $e['pallet']); + $this->packing->assignCartonToPallet($carton, $pallet, $user, $this->workstation($e)?->id); + $this->packing->closePalletIfFull($pallet->fresh()); + } + } + + /** A new pallet for the order, as the packing bench creates it. */ + private function openPallet(array $e): void + { + $order = $this->workOrder($e); + $user = $this->user($e); + $batchIds = $order->batches()->pluck('id'); + + Pallet::where('active_by_id', $user->id)->update(['active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $pallet = Pallet::create([ + 'work_order_id' => $order->id, + 'batch_id' => $batchIds->count() === 1 ? $batchIds->first() : null, + 'status' => PalletStatus::Open->value, + 'location' => $e['location'] ?? null, + 'qty' => 0, + 'active_by_id' => $user->id, + 'active_workstation_id' => $this->workstation($e)?->id ?? $user->workstation_id, + 'activated_at' => now(), + ]); + if ($this->backflush->isEnabled()) { + $this->backflush->backflushForPallet($pallet, null, $user); + } + if (is_string($e['ref'] ?? null)) { + $this->pallets[$e['ref']] = $pallet->id; + } + } + + private function closePallet(array $e): void + { + $pallet = $this->pallet((string) ($e['pallet'] ?? '')); + if (! $pallet->isOpen()) { + return; + } + $pallet->update(['status' => PalletStatus::Closed->value, 'active_by_id' => null, 'active_workstation_id' => null, 'activated_at' => null]); + $this->packing->palletClosed($pallet); + } + + /** + * Ship the pallet; its units leave with it (the pallet's own status hook), + * attributed to the event's user. A pallet still open is closed first, as + * the bench would, so its packaging is booked. + */ + private function shipPallet(array $e): void + { + $pallet = $this->pallet((string) ($e['pallet'] ?? '')); + if ($pallet->isOpen()) { + $this->closePallet($e); + $pallet->refresh(); + } + $previous = Auth::user(); + Auth::setUser($this->user($e)); + try { + $pallet->update(['status' => PalletStatus::Shipped->value]); + } finally { + $previous ? Auth::setUser($previous) : Auth::forgetUser(); + } + } +} diff --git a/backend/app/Services/Settings/ScenarioException.php b/backend/app/Services/Settings/ScenarioException.php new file mode 100644 index 000000000..9f967fe47 --- /dev/null +++ b/backend/app/Services/Settings/ScenarioException.php @@ -0,0 +1,6 @@ + $serialNo, 'tenant_id' => $attributes['tenant_id'] ?? null], + ['serial_no' => $serialNo], [ + 'psn' => $attributes['psn'] ?? null, 'work_order_id' => $attributes['work_order_id'] ?? null, 'batch_id' => $attributes['batch_id'] ?? null, 'material_id' => $attributes['material_id'] ?? null, @@ -33,6 +43,53 @@ public function registerUnit(string $serialNo, array $attributes = []): SerialUn ); } + /** + * Register a sub-assembly by its own serial where it is made (a board, a + * module), so the product it goes into later is linked to a unit with a + * history - not to a bare number. It has no process serial and does not + * follow the product's serial format; it is complete once registered. + * + * @throws BindingException when the serial is already taken + */ + public function registerSubassembly(string $serialNo, Material $material, WorkOrder $workOrder, User $operator, ?int $workstationId = null): SerialUnit + { + $serialNo = app(UnitSerialisation::class)->normalize($serialNo) ?? ''; + + return DB::transaction(function () use ($serialNo, $material, $workOrder, $operator, $workstationId) { + if ($serialNo === '') { + throw new BindingException(__('Scan the serial number of the sub-assembly.')); + } + $owner = SerialUnit::where('serial_no', $serialNo)->lockForUpdate()->first() + ?? SerialUnit::where('psn', $serialNo)->lockForUpdate()->first(); + if ($owner) { + throw new BindingException(__('Serial :sn is already registered (:material, order :order).', [ + 'sn' => $serialNo, + 'material' => $owner->material?->name ?? '—', + 'order' => $owner->workOrder?->order_no ?? '—', + ])); + } + + try { + $unit = SerialUnit::create([ + 'serial_no' => $serialNo, + 'material_id' => $material->id, + 'work_order_id' => $workOrder->id, + 'status' => SerialUnit::STATUS_COMPLETED, + 'produced_at' => now(), + ]); + } catch (\Illuminate\Database\UniqueConstraintViolationException) { + // The same label scanned at two benches at once: one wins, the other is told. + throw new BindingException(__('Serial :sn is already registered (:material, order :order).', ['sn' => $serialNo, 'material' => '—', 'order' => '—'])); + } + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId, + 'parameters' => ['event' => 'subassembly_registered', 'material' => $material->code], + ]); + + return $unit->fresh(); + }); + } + /** * Record a processing event for a unit at a workstation. The high-precision * timestamp guarantees ordering even for sub-second consecutive steps. @@ -42,23 +99,524 @@ public function recordStep(SerialUnit $unit, User $operator, ?BatchStep $step, a return DB::transaction(function () use ($unit, $operator, $step, $data) { $entry = $unit->history()->create([ 'batch_step_id' => $step?->id, - 'workstation_id' => $data['workstation_id'] ?? $step?->workstation_id ?? $operator->workstation_id, + // `exact_workstation`: the caller knows the bench, or knows it is unknown + // (a tester log from an unregistered station) - no fallback to the operator's. + 'workstation_id' => ! empty($data['exact_workstation']) + ? ($data['workstation_id'] ?? null) + : ($data['workstation_id'] ?? $step?->workstation_id ?? $operator->workstation_id), 'operator_id' => $operator->id, 'parameters' => $data['parameters'] ?? null, 'result' => $data['result'] ?? null, 'notes' => $data['notes'] ?? null, - 'processed_at' => now()->format('Y-m-d H:i:s.u'), + // An import passes the tester's own time so history reads in the + // order things happened, not the order files were loaded. + // Stored in the app's timezone whatever zone the caller's stamp + // carries (a tester's "…Z"), so history sorts with station rows. + 'processed_at' => ($data['processed_at'] ?? now())->copy()->setTimezone(config('app.timezone'))->format('Y-m-d H:i:s.u'), ]); - // Mark a failed unit as scrapped so it drops out of in-production counts. - if (($data['result'] ?? null) === 'fail') { - $unit->update(['status' => SerialUnit::STATUS_SCRAPPED]); + if (($data['result'] ?? null) === 'fail' && ($data['parameters']['event'] ?? null) !== 'scrapped') { + $this->applyFailPolicy($unit, $entry, $operator); + } + // A hold from failed tests waits for a retest: a pass lifts it (policy + // `block`) and the unit goes back to where it was - packed units stay + // packed. A hold someone put on by hand (a non-conformity) is not a + // test's to lift. + $unit->refresh(); + // Only a test's pass - not an inspection or step marked "pass" elsewhere. + if (($data['result'] ?? null) === 'pass' && ($data['parameters']['event'] ?? null) === 'test' + && $unit->status === SerialUnit::STATUS_BLOCKED + && ($unit->extra_data['hold']['source'] ?? 'test') !== 'manual' + && $this->passLiftsHold($unit->extra_data['hold'] ?? [], $entry)) { + $unit->update([ + 'status' => $unit->packed_at ? SerialUnit::STATUS_COMPLETED : SerialUnit::STATUS_IN_PRODUCTION, + 'extra_data' => array_diff_key($unit->extra_data ?? [], ['hold' => true]) ?: null, + ]); + // The release shows in the history next to the pass that made it. + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $entry->workstation_id, + 'exact_workstation' => true, + 'processed_at' => $entry->processed_at, + 'parameters' => array_filter(['event' => 'unblocked', 'hold_source' => 'test', 'released_by' => 'test', 'station' => $entry->parameters['station'] ?? null]), + ]); + } + // A verdict on a unit already on a pallet changes what that pallet may + // do. Read the pallet from the database: the caller's instance may + // predate the packing. + if (($data['result'] ?? null) !== null) { + $palletId = SerialUnit::whereKey($unit->id)->value('pallet_id'); + if ($palletId) { + Pallet::find($palletId)?->recomputeQualityStatus(); + } } return $entry; }); } + /** + * What a failed verdict does to the unit is the plant's call (settings): + * scrap at once, count attempts and block once the limit is reached so a + * retest can still clear it, or just keep the verdict. Scrapped is final. + */ + private function applyFailPolicy(SerialUnit $unit, UnitStepHistory $entry, User $operator): void + { + $settings = app(UnitSerialisation::class); + $policy = $settings->get('unit_test_fail_policy'); + + if ($policy === UnitSerialisation::FAIL_SCRAP) { + // A late or re-imported log does not scrap a unit that already left (shipped) or is scrapped. + $unit->refresh(); + if (! in_array($unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + $unit->update(['status' => SerialUnit::STATUS_SCRAPPED]); + } + + return; + } + if ($policy !== UnitSerialisation::FAIL_BLOCK) { + return; // record only + } + + $limit = max(1, (int) $settings->get('unit_test_max_attempts')); + + $fails = $settings->get('unit_test_attempts_scope') === UnitSerialisation::ATTEMPTS_TEST + ? $this->failsSinceLastPassAt($unit, $entry) + : $unit->history()->where('result', 'fail')->count(); + if ($fails >= $limit && ! in_array($unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + $unit->refresh(); + $wasBlocked = $unit->status === SerialUnit::STATUS_BLOCKED; + $unit->update([ + 'status' => SerialUnit::STATUS_BLOCKED, + // A hand-made hold stays what it is; otherwise the tests put it on - + // remembering which test and when, so only that test's later pass lifts it. + 'extra_data' => array_merge($unit->extra_data ?? [], ['hold' => $unit->extra_data['hold'] ?? array_filter([ + 'source' => 'test', + 'at' => $entry->processed_at?->toIso8601String() ?? now()->toIso8601String(), + 'workstation_id' => $entry->workstation_id, + 'station' => $entry->parameters['station'] ?? null, + ], fn ($v) => $v !== null)]), + ]); + // The hold is a moment in the unit's history, not only its status: + // it reads right after the fail that put it on, with the count. + if (! $wasBlocked) { + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $entry->workstation_id, + 'exact_workstation' => true, + 'processed_at' => $entry->processed_at, + 'parameters' => array_filter([ + 'event' => 'blocked', 'hold_source' => 'test', + 'station' => $entry->parameters['station'] ?? null, + 'failed_attempts' => $fails, 'max_attempts' => $limit, + ], fn ($v) => $v !== null), + ]); + } + } + } + + /** + * Failed verdicts of this test: at the same station as the verdict just + * recorded, after that station's last pass (by the tester's own time - logs + * can arrive out of order). A scrap is not a test and does not count. + */ + private function failsSinceLastPassAt(SerialUnit $unit, UnitStepHistory $entry): int + { + $atStation = fn () => $this->sameTest($unit->history()->reorder(), $entry->workstation_id, $entry->parameters['station'] ?? null); + + $lastPass = $atStation()->where('result', 'pass')->max('processed_at'); + + return $atStation()->where('result', 'fail') + ->where(fn ($q) => $q->whereNull('parameters->event')->orWhere('parameters->event', '!=', 'scrapped')) + ->when($lastPass, fn ($q) => $q->where('processed_at', '>', $lastPass)) + ->count(); + } + + /** + * Rows of the same test: the same workstation, or - for a tester the plant + * has no workstation for - the same station code in its log. + */ + private function sameTest($query, ?int $workstationId, ?string $station) + { + if ($workstationId) { + return $query->where('workstation_id', $workstationId); + } + + return $query->whereNull('workstation_id') + ->when($station !== null, fn ($q) => $q->where('parameters->station', $station), fn ($q) => $q->whereNull('parameters->station')); + } + + /** + * Whether a pass lifts a test hold. Counting per test, only a pass of the + * test that put the hold on, and no older than the failure that did; every + * failure of the unit counting, any pass does (a hold from before the test + * was remembered also lifts on any pass). + */ + private function passLiftsHold(array $hold, UnitStepHistory $pass): bool + { + // By the tester's own time in either scope: an older pass arriving late + // (logs come out of order) does not undo a newer hold. + $newer = ! isset($hold['at']) || ! $pass->processed_at || $pass->processed_at->greaterThanOrEqualTo(\Carbon\Carbon::parse($hold['at'])); + if (app(UnitSerialisation::class)->get('unit_test_attempts_scope') !== UnitSerialisation::ATTEMPTS_TEST + || (! isset($hold['workstation_id']) && ! isset($hold['station']))) { + return $newer; + } + $sameTest = isset($hold['workstation_id']) + ? (int) $pass->workstation_id === (int) $hold['workstation_id'] + : $pass->workstation_id === null && ($pass->parameters['station'] ?? null) === $hold['station']; + + return $sameTest && $newer; + } + + /** + * Start a unit on its process serial alone - the line numbers the unit at + * its first station, before the product label (and its SN) exists. The PSN + * is the unit's identity until then, so it must be free. + * + * @throws BindingException + */ + public function startUnit(string $psn, User $operator, array $context = []): SerialUnit + { + $psn = app(UnitSerialisation::class)->normalize($psn) ?? ''; + + return DB::transaction(function () use ($psn, $operator, $context) { + if ($psn === '') { + throw new BindingException(__('Scan or issue the process serial first.')); + } + $owner = SerialUnit::where('psn', $psn)->lockForUpdate()->first(); + if ($owner) { + throw new BindingException(__('Process serial :psn already belongs to unit :sn.', ['psn' => $psn, 'sn' => $owner->display_id])); + } + + $unit = SerialUnit::create([ + 'serial_no' => null, + 'psn' => $psn, + 'work_order_id' => $context['work_order_id'] ?? null, + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + ]); + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $context['workstation_id'] ?? null, + 'parameters' => ['event' => 'started', 'psn' => $psn], + ]); + + return $unit->fresh(); + }); + } + + /** + * Bind a unit's serial number to its process serial number at a station: + * the moment the pre-printed label goes onto the product. Registers the + * unit on first sight, records the event in its history, and applies the + * plant's rules - a process serial owned by another unit is refused when + * process serials are unique, and a unit already carrying a different + * process serial is only re-bound with `force`, which callers reserve for + * supervisors and record with a reason. + * + * @return array{unit: SerialUnit, created: bool, rebound: bool, serial_assigned: bool} + * + * @throws BindingException + */ + public function bindProcessSerial(string $serialNo, ?string $psn, User $operator, array $context = []): array + { + $settings = app(UnitSerialisation::class); + $serialNo = $settings->normalize($serialNo); + $psn = $settings->normalize($psn); + + return DB::transaction(function () use ($serialNo, $psn, $operator, $context, $settings) { + $unit = SerialUnit::where('serial_no', $serialNo)->lockForUpdate()->first(); + // A unit started on its PSN earlier gets its SN now: the product label. + $waiting = $psn !== null + ? SerialUnit::where('psn', $psn)->whereNull('serial_no')->when($unit, fn ($q) => $q->whereKeyNot($unit->id))->lockForUpdate()->first() + : null; + if ($unit && $waiting) { + // Two units would claim one PSN: the one waiting for its label and + // the one that already carries this SN. A person has to sort that out. + throw new BindingException(__('Process serial :psn belongs to a unit still waiting for its serial number, and :sn is already another unit.', ['psn' => $psn, 'sn' => $serialNo])); + } + $gotSerial = false; + if (! $unit && $waiting) { + $unit = $waiting; + $unit->serial_no = $serialNo; + $gotSerial = true; + } + + if ($unit && in_array($unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + throw new BindingException(__('Unit :sn is :status and can no longer be bound.', ['sn' => $unit->display_id, 'status' => $unit->status_label])); + } + // A held unit does not move on to its label: that would read as the next + // step done while it waits for a retest or a release. A supervisor's + // re-binding (a mixed-up PSN) is a correction, not a step, and still goes. + if ($unit && $unit->status === SerialUnit::STATUS_BLOCKED && empty($context['force'])) { + // With a different PSN on the scan the station still offers the + // supervisor's re-bind, the one way this refusal can be overridden. + throw new BindingException(__('Unit :sn is blocked - it cannot move on until it passes a retest or a supervisor releases it.', ['sn' => $unit->display_id]), + rebindable: $psn !== null && $unit->psn !== null && $unit->psn !== $psn); + } + + if ($psn !== null && $settings->get('unit_psn_unique')) { + $owner = SerialUnit::where('psn', $psn)->when($unit, fn ($q) => $q->whereKeyNot($unit->id))->first(); + if ($owner) { + throw new BindingException(__('Process serial :psn already belongs to unit :sn.', ['psn' => $psn, 'sn' => $owner->display_id])); + } + } + + $rebound = false; + $previousPsn = $unit?->psn; + if ($unit) { + if ($psn !== null && $unit->psn !== null && $unit->psn !== $psn) { + if (empty($context['force'])) { + throw new BindingException(__('Unit :sn is already bound to process serial :psn.', ['sn' => $serialNo, 'psn' => $unit->psn]), rebindable: true); + } + $rebound = true; + } + $unit->fill(array_filter([ + 'psn' => $psn ?? $unit->psn, + 'work_order_id' => $unit->work_order_id ?: ($context['work_order_id'] ?? null), + ], fn ($v) => $v !== null)); + $created = false; + } else { + $unit = $this->registerUnit($serialNo, [ + 'psn' => $psn, + 'work_order_id' => $context['work_order_id'] ?? null, + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + ]); + $created = true; + } + + $unit->extra_data = array_merge($unit->extra_data ?? [], ['label_applied_at' => now()->toIso8601String()]); + $unit->save(); + + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $context['workstation_id'] ?? null, + 'parameters' => array_filter([ + 'event' => $rebound ? 'process_serial_rebound' : 'label_applied', + 'psn' => $psn, + 'previous_psn' => $rebound ? $previousPsn : null, + ]), + 'notes' => $rebound ? ($context['reason'] ?? null) : null, + ]); + + return ['unit' => $unit->fresh(), 'created' => $created, 'rebound' => $rebound, 'serial_assigned' => $gotSerial]; + }); + } + + /** + * Bind a component onto a unit by the identifier on the component's label. + * The identifier is resolved to what the system already knows - another + * serialised unit, or a material lot - and kept as scanned either way, so a + * bought-in part with only a vendor serial still traces. A component that is + * itself a serialised unit can only be inside one parent at a time. + * + * @throws BindingException + */ + public function bindComponent(SerialUnit $unit, string $identifier, User $operator, array $context = []): SerialUnitComponent + { + $identifier = app(UnitSerialisation::class)->normalize($identifier) ?? ''; + + return DB::transaction(function () use ($unit, $identifier, $operator, $context) { + if (in_array($unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + throw new BindingException(__('Unit :sn is :status and can no longer be bound.', ['sn' => $unit->display_id, 'status' => $unit->status_label])); + } + if ($identifier === '' || $identifier === $unit->serial_no || $identifier === $unit->psn) { + throw new BindingException(__('A unit cannot be a component of itself.')); + } + + $existing = $unit->components()->installed()->where('identifier', $identifier)->first(); + if ($existing) { + return $existing; // the same scan twice is a confirmation, not a second part + } + + // A sub-assembly is known by its SN, or by its PSN while it has none. + $componentUnit = SerialUnit::findByIdentifier($identifier); + // Lot numbers are stored as typed; compare both sides folded (case, + // spacing) so the match does not depend on the normalise setting. + $lot = $componentUnit ? null : MaterialLot::whereRaw("UPPER(REPLACE(lot_number, ' ', '')) = ?", [strtoupper(str_replace(' ', '', $identifier))])->first(); + + if ($componentUnit) { + // Locked: two benches scanning the same board into two products at + // once must not both find it free. + $componentUnit = SerialUnit::whereKey($componentUnit->id)->lockForUpdate()->first(); + // A sub-assembly that is held, scrapped or gone does not go into a product. + if (in_array($componentUnit->status, [...SerialUnit::TERMINAL_STATUSES, SerialUnit::STATUS_BLOCKED], true)) { + throw new BindingException(__('Component :id is :status and cannot be installed.', ['id' => $identifier, 'status' => $componentUnit->status_label])); + } + $elsewhere = SerialUnitComponent::installed()->where('component_serial_unit_id', $componentUnit->id)->with('unit:id,serial_no,psn')->first(); + if ($elsewhere) { + throw new BindingException(__('Component :id is already installed in unit :sn.', ['id' => $identifier, 'sn' => $elsewhere->unit->display_id])); + } + // A unit cannot end up inside something it contains (A ⊃ B ⊃ A). + if ($this->contains($componentUnit, $unit)) { + throw new BindingException(__('Unit :sn is already inside :id - it cannot also contain it.', ['sn' => $unit->display_id, 'id' => $identifier])); + } + } + + $component = $unit->components()->create([ + 'identifier' => $identifier, + 'component_serial_unit_id' => $componentUnit?->id, + 'material_lot_id' => $lot?->id, + 'material_id' => $context['material_id'] ?? $lot?->material_id ?? $componentUnit?->material_id, + 'quantity' => $context['quantity'] ?? 1, + 'batch_step_id' => $context['batch_step_id'] ?? null, + 'workstation_id' => $context['workstation_id'] ?? null, + 'bound_by_id' => $operator->id, + 'bound_at' => now(), + ]); + + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $context['workstation_id'] ?? null, + 'parameters' => array_filter([ + 'event' => 'component_bound', + 'identifier' => $identifier, + 'material' => $component->material?->code, + 'kind' => $componentUnit ? 'serial_unit' : ($lot ? 'material_lot' : 'identifier'), + ]), + ]); + + return $component; + }); + } + + /** + * Scrap a unit - final. The reason goes into its history next to who did + * it and where; the row itself is never deleted, the label exists. + * + * @throws BindingException when the unit already left the line + */ + public function scrapUnit(SerialUnit $unit, User $operator, string $reason, ?int $workstationId = null): SerialUnit + { + return DB::transaction(function () use ($unit, $operator, $reason, $workstationId) { + if ($unit->status === SerialUnit::STATUS_SHIPPED) { + throw new BindingException(__('Unit :sn is :status and can no longer be bound.', ['sn' => $unit->display_id, 'status' => $unit->status_label])); + } + if ($unit->status !== SerialUnit::STATUS_SCRAPPED) { + $unit->update(['status' => SerialUnit::STATUS_SCRAPPED]); + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => ['event' => 'scrapped'], + 'result' => 'fail', + 'notes' => $reason, + ]); + } + + return $unit->fresh(); + }); + } + + /** + * Hold a non-conforming unit with an error code: nothing downstream takes + * it (packing, the pallet's quality gate) until a supervisor releases it. + * A passing retest does not lift this hold - it was not the test's. + * + * @throws BindingException + */ + public function blockUnit(SerialUnit $unit, User $operator, ScrapReason $reason, ?string $note = null, ?int $workstationId = null): SerialUnit + { + return DB::transaction(function () use ($unit, $operator, $reason, $note, $workstationId) { + $unit = SerialUnit::whereKey($unit->id)->lockForUpdate()->firstOrFail(); + if (in_array($unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + throw new BindingException(__('Unit :sn is :status and can no longer be bound.', ['sn' => $unit->display_id, 'status' => $unit->status_label])); + } + if ($unit->status === SerialUnit::STATUS_BLOCKED) { + throw new BindingException(__('Unit :sn is already blocked.', ['sn' => $unit->display_id])); + } + $unit->update([ + 'status' => SerialUnit::STATUS_BLOCKED, + 'extra_data' => array_merge($unit->extra_data ?? [], ['hold' => [ + 'source' => 'manual', 'reason_code' => $reason->code, 'reason' => $reason->name, + 'by_id' => $operator->id, 'at' => now()->toIso8601String(), + ]]), + ]); + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => ['event' => 'blocked', 'reason_code' => $reason->code, 'reason' => $reason->name, 'category' => $reason->category], + 'notes' => $note, + ]); + $this->recomputePallet($unit); + + return $unit->fresh(); + }); + } + + /** + * Release a held unit (by hand or after failed tests) - a supervisor's call, + * with the reason. It goes back to where it was: packed units stay packed. + * + * @throws BindingException + */ + public function unblockUnit(SerialUnit $unit, User $operator, string $note, ?int $workstationId = null): SerialUnit + { + return DB::transaction(function () use ($unit, $operator, $note, $workstationId) { + $unit = SerialUnit::whereKey($unit->id)->lockForUpdate()->firstOrFail(); + if ($unit->status !== SerialUnit::STATUS_BLOCKED) { + throw new BindingException(__('Unit :sn is not blocked.', ['sn' => $unit->display_id])); + } + $hold = $unit->extra_data['hold'] ?? []; + $unit->update([ + 'status' => $unit->packed_at ? SerialUnit::STATUS_COMPLETED : SerialUnit::STATUS_IN_PRODUCTION, + 'extra_data' => array_diff_key($unit->extra_data ?? [], ['hold' => true]) ?: null, + ]); + $this->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => array_filter(['event' => 'unblocked', 'reason_code' => $hold['reason_code'] ?? null, 'hold_source' => $hold['source'] ?? 'test']), + 'notes' => $note, + ]); + $this->recomputePallet($unit); + + return $unit->fresh(); + }); + } + + /** A unit's hold changes what the pallet it sits on may do. */ + private function recomputePallet(SerialUnit $unit): void + { + $palletId = SerialUnit::whereKey($unit->id)->value('pallet_id'); + if ($palletId) { + Pallet::find($palletId)?->recomputeQualityStatus(); + } + } + + /** Take a component out of a unit; the row stays, closed, with the reason. */ + /** Whether $needle sits somewhere inside $haystack's installed sub-assemblies (any depth). */ + private function contains(SerialUnit $haystack, SerialUnit $needle, int $depth = 0): bool + { + if ($depth > 50) { + return false; + } + $childIds = SerialUnitComponent::installed()->where('serial_unit_id', $haystack->id)->whereNotNull('component_serial_unit_id')->pluck('component_serial_unit_id'); + if ($childIds->contains($needle->id)) { + return true; + } + foreach (SerialUnit::whereIn('id', $childIds)->get() as $child) { + if ($this->contains($child, $needle, $depth + 1)) { + return true; + } + } + + return false; + } + + /** @throws BindingException when the product has shipped or been scrapped - its genealogy is final */ + public function unbindComponent(SerialUnitComponent $component, User $operator, ?string $reason = null, ?int $workstationId = null): SerialUnitComponent + { + return DB::transaction(function () use ($component, $operator, $reason, $workstationId) { + $component = SerialUnitComponent::whereKey($component->id)->lockForUpdate()->firstOrFail(); + if ($component->unbound_at !== null) { + return $component; + } + // What left the plant (or was scrapped) keeps the parts it had: removing + // one would rewrite the record and free the part for another product. + if (in_array($component->unit->status, SerialUnit::TERMINAL_STATUSES, true)) { + throw new BindingException(__('Unit :sn is :status and can no longer be bound.', ['sn' => $component->unit->display_id, 'status' => $component->unit->status_label])); + } + $component->update(['unbound_at' => now(), 'unbound_by_id' => $operator->id, 'unbind_reason' => $reason]); + $this->recordStep($component->unit, $operator, null, [ + 'workstation_id' => $workstationId ?? $operator->workstation_id, + 'parameters' => ['event' => 'component_unbound', 'identifier' => $component->identifier], + 'notes' => $reason, + ]); + + return $component; + }); + } + /** * Full chronological process history for a unit (the birth certificate). */ @@ -69,6 +627,8 @@ public function getHistory(SerialUnit $unit): SerialUnit 'workOrder.productType:id,name,code', 'batch:id,batch_number,lot_number', 'material:id,name,code', + 'carton:id,carton_no', + 'pallet:id,pallet_no', 'history.workstation:id,name,code,line_id', 'history.workstation.line:id,name,code', 'history.operator:id,name', diff --git a/backend/app/Services/Traceability/TestLog/CompactCycleFormat.php b/backend/app/Services/Traceability/TestLog/CompactCycleFormat.php new file mode 100644 index 000000000..9d2a75abd --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/CompactCycleFormat.php @@ -0,0 +1,117 @@ + $cycle = $row, + 's' => $steps[] = $row, + 'v' => $verdict = $row, + default => null, + }; + } + if ($cycle === null || empty($cycle['sn'])) { + return null; + } + + $start = self::time($cycle['start'] ?? null); + $failedIds = array_map('strval', (array) ($verdict['fail'] ?? [])); + $failedFromSteps = array_map(fn ($s) => (string) ($s['n'] ?? ''), array_filter($steps, fn ($s) => ($s['r'] ?? '') === 'F')); + // Step ids as strings; "0" is a real step, only empty ones are dropped. + $failed = array_values(array_unique(array_filter([...$failedIds, ...$failedFromSteps], fn ($id) => $id !== ''))); + + return new TestRun( + serialNo: (string) $cycle['sn'], + psn: isset($cycle['psn']) && $cycle['psn'] !== '' ? (string) $cycle['psn'] : null, + // Without an id the run is still identified by unit and start time, so a + // re-import of the same file does not record it twice. + runId: isset($cycle['id']) ? (string) $cycle['id'] : ($start ? $cycle['sn'].'@'.$start->toIso8601String() : null), + // The verdict line's letter decides; without one, the failed steps do. + verdict: match ($verdict['r'] ?? null) { + 'F' => TestRun::FAIL, + 'R' => TestRun::REWORK, + 'P' => TestRun::PASS, + default => $failed === [] ? TestRun::PASS : TestRun::FAIL, + }, + startedAt: $start, + endedAt: self::timeOfDay($verdict['ts'] ?? null, $start), + station: $cycle['station'] ?? null, + line: $cycle['line'] ?? null, + operator: $cycle['op'] ?? null, + steps: array_map(fn ($s) => [ + 'id' => (string) ($s['n'] ?? ''), + 'name' => $s['cmd'] ?? null, + 'verdict' => match ($s['r'] ?? null) { + 'F' => TestRun::FAIL, 'P' => TestRun::PASS, default => null + }, + 'duration_ms' => null, + 'measurements' => array_key_exists('v', $s) ? [array_filter([ + 'name' => $s['cmd'] ?? null, + 'value' => $s['v'], + 'unit' => $s['u'] ?? null, + 'low' => $s['lo'] ?? null, + 'high' => $s['hi'] ?? null, + ], fn ($v) => $v !== null)] : [], + ], $steps), + failedSteps: $failed, + extra: array_filter([ + 'software' => $cycle['sw'] ?? null, + 'limits' => $cycle['lim'] ?? null, + 'fixture' => $cycle['sfr'] ?? null, + 'dut' => $cycle['dut'] ?? null, + ], fn ($v) => $v !== null), + ); + } + + private static function time(?string $value): ?CarbonImmutable + { + try { + return $value ? CarbonImmutable::parse($value) : null; + } catch (\Throwable) { + return null; + } + } + + /** Step lines carry a clock time only; the date is the cycle's (or the next day's, past midnight). */ + private static function timeOfDay(?string $clock, ?CarbonImmutable $day): ?CarbonImmutable + { + if (! $clock || ! $day) { + return null; + } + try { + $at = CarbonImmutable::parse($day->toDateString().' '.$clock, $day->timezone); + } catch (\Throwable) { + return null; + } + + // A clock time earlier than the start means the run went past midnight. + return $at->lessThan($day) ? $at->addDay() : $at; + } +} diff --git a/backend/app/Services/Traceability/TestLog/EventStreamFormat.php b/backend/app/Services/Traceability/TestLog/EventStreamFormat.php new file mode 100644 index 000000000..469cbb526 --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/EventStreamFormat.php @@ -0,0 +1,120 @@ + $id, 'name' => $row['name'] ?? null, 'verdict' => null, 'duration_ms' => null, 'measurements' => []]; + break; + case 'measure': + $id = (string) ($row['step_id'] ?? ''); + $steps[$id] ??= ['id' => $id, 'name' => null, 'verdict' => null, 'duration_ms' => null, 'measurements' => []]; + $steps[$id]['measurements'][] = array_filter([ + 'name' => $row['char_id'] ?? null, + 'value' => $row['value'] ?? null, + 'unit' => $row['unit'] ?? null, + 'low' => $row['low'] ?? null, + 'high' => $row['high'] ?? null, + 'expected' => $row['expected'] ?? null, + 'verdict' => isset($row['verdict']) ? strtolower((string) $row['verdict']) : null, + ], fn ($v) => $v !== null); + break; + case 'step_end': + $id = (string) ($row['step_id'] ?? ''); + $steps[$id] ??= ['id' => $id, 'name' => null, 'verdict' => null, 'duration_ms' => null, 'measurements' => []]; + $steps[$id]['verdict'] = isset($row['verdict']) ? strtolower((string) $row['verdict']) : null; + $steps[$id]['duration_ms'] = isset($row['duration_ms']) ? (int) $row['duration_ms'] : null; + break; + case 'run_end': + $end = $row; + break; + } + } + + $serial = self::serialFrom($start ?? []) ?? self::serialFrom($end ?? []); + if ($start === null || $serial === null) { + return null; + } + + $failed = array_values(array_map(fn ($s) => $s['id'], array_filter($steps, fn ($s) => $s['verdict'] === TestRun::FAIL))); + $verdict = strtolower((string) ($end['verdict'] ?? '')); + + return new TestRun( + serialNo: $serial, + psn: isset($start['psn']) && $start['psn'] !== '' ? (string) $start['psn'] : null, + runId: $start['run_id'] ?? $start['id'] ?? (isset($start['ts']) ? ($start['station'] ?? '').'@'.$start['ts'] : null), + verdict: match ($verdict) { + 'fail' => TestRun::FAIL, + 'rework' => TestRun::REWORK, + 'pass' => TestRun::PASS, + default => $failed === [] ? TestRun::PASS : TestRun::FAIL, + }, + startedAt: self::time($start['ts'] ?? null), + endedAt: self::time($end['ts'] ?? null), + station: $start['station'] ?? null, + line: $start['line'] ?? null, + operator: $start['operator'] ?? $start['op'] ?? null, + steps: array_values($steps), + failedSteps: $failed, + extra: array_filter([ + 'software' => $start['sw'] ?? null, + 'limits' => $start['limits_map'] ?? null, + 'station_config' => $start['station_cfg'] ?? null, + 'dut' => $start['dut'] ?? null, + 'shift' => $start['shift'] ?? null, + 'first_fail_step' => $end['first_fail_step'] ?? null, + ], fn ($v) => $v !== null), + ); + } + + /** The unit's serial under whatever name the tester gives it. */ + private static function serialFrom(array $row): ?string + { + foreach ($row as $key => $value) { + if (is_string($value) && $value !== '' && preg_match('/(^|_)(sn|serial|serial_no)$/i', (string) $key)) { + return $value; + } + } + + return null; + } + + private static function time(?string $value): ?CarbonImmutable + { + try { + return $value ? CarbonImmutable::parse($value) : null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/backend/app/Services/Traceability/TestLog/NativeTestRunFormat.php b/backend/app/Services/Traceability/TestLog/NativeTestRunFormat.php new file mode 100644 index 000000000..b55fd822d --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/NativeTestRunFormat.php @@ -0,0 +1,83 @@ +","ended_at":"", + * "station":"","line":"","operator":"", + * "steps":[{"id":"…","name":"…","verdict":"pass|fail","duration_ms":0, + * "measurements":[{"name":"…","value":0,"unit":"…","low":0,"high":0}]}], + * "failed_steps":["…"],"extra":{"":""}} + * + * `verdict` is pass | fail | rework; with no verdict, a run with a failed step + * fails and any other passes. Everything but `serial_no` is optional. + */ +class NativeTestRunFormat implements TestLogFormat +{ + public function supports(array $firstRecord): bool + { + return isset($firstRecord['serial_no']) && ! isset($firstRecord['t']) && ! isset($firstRecord['evt']); + } + + public function parse(array $records): ?TestRun + { + $row = $records[0] ?? null; + if (! is_array($row) || trim((string) ($row['serial_no'] ?? '')) === '') { + return null; + } + + $steps = array_values(array_map(fn ($s) => [ + 'id' => (string) ($s['id'] ?? $s['n'] ?? ''), + 'name' => isset($s['name']) ? (string) $s['name'] : null, + 'verdict' => self::verdict($s['verdict'] ?? null), + 'duration_ms' => isset($s['duration_ms']) ? (int) $s['duration_ms'] : null, + 'measurements' => array_values(array_filter((array) ($s['measurements'] ?? []), 'is_array')), + ], array_filter((array) ($row['steps'] ?? []), 'is_array'))); + + $failed = array_values(array_unique(array_map('strval', array_merge( + (array) ($row['failed_steps'] ?? []), + array_column(array_filter($steps, fn ($s) => $s['verdict'] === TestRun::FAIL), 'id'), + )))); + + return new TestRun( + serialNo: trim((string) $row['serial_no']), + psn: isset($row['psn']) && trim((string) $row['psn']) !== '' ? trim((string) $row['psn']) : null, + runId: isset($row['run_id']) && (string) $row['run_id'] !== '' ? (string) $row['run_id'] : null, + verdict: self::verdict($row['verdict'] ?? null) ?? ($failed === [] ? TestRun::PASS : TestRun::FAIL), + startedAt: self::time($row['started_at'] ?? null), + endedAt: self::time($row['ended_at'] ?? null), + station: isset($row['station']) ? (string) $row['station'] : null, + line: isset($row['line']) ? (string) $row['line'] : null, + operator: isset($row['operator']) ? (string) $row['operator'] : null, + steps: $steps, + failedSteps: $failed, + extra: array_filter((array) ($row['extra'] ?? []), fn ($v) => $v !== null && $v !== ''), + ); + } + + private static function verdict(mixed $value): ?string + { + return match (strtolower((string) $value)) { + 'pass', 'p', 'ok' => TestRun::PASS, + 'fail', 'f', 'nok' => TestRun::FAIL, + 'rework', 'r' => TestRun::REWORK, + default => null, + }; + } + + private static function time(mixed $value): ?CarbonImmutable + { + try { + return $value ? CarbonImmutable::parse((string) $value) : null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/backend/app/Services/Traceability/TestLog/TestLogFormat.php b/backend/app/Services/Traceability/TestLog/TestLogFormat.php new file mode 100644 index 000000000..3a6dc8c1c --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/TestLogFormat.php @@ -0,0 +1,17 @@ + $firstRecord */ + public function supports(array $firstRecord): bool; + + /** @param array> $records decoded JSONL rows, in file order */ + public function parse(array $records): ?TestRun; +} diff --git a/backend/app/Services/Traceability/TestLog/TestLogImporter.php b/backend/app/Services/Traceability/TestLog/TestLogImporter.php new file mode 100644 index 000000000..6a4a47507 --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/TestLogImporter.php @@ -0,0 +1,128 @@ +settings->normalize($run->serialNo); + $psn = $this->settings->normalize($run->psn); + // A blank serial would match any unit still waiting for its label. + if ($serialNo === null) { + throw new InvalidArgumentException(__('The test run names no serial number.')); + } + + return DB::transaction(function () use ($run, $serialNo, $psn, $options) { + $unit = SerialUnit::where('serial_no', $serialNo)->lockForUpdate()->first(); + // A unit started on its PSN and not labelled yet: the tester knows + // both numbers, so the SN goes onto that unit instead of a new one. + if (! $unit && $psn !== null) { + $unit = SerialUnit::where('psn', $psn)->whereNull('serial_no')->lockForUpdate()->first(); + $unit?->update(['serial_no' => $serialNo]); + } + $unit ??= $this->serials->registerUnit($serialNo, [ + 'psn' => $psn, + 'work_order_id' => ($options['work_order'] ?? null)?->id, + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + ]); + + $fill = []; + if ($psn !== null && empty($unit->psn)) { + $fill['psn'] = $psn; + } + if (isset($options['work_order']) && empty($unit->work_order_id)) { + $fill['work_order_id'] = $options['work_order']->id; + } + if ($fill !== []) { + $unit->update($fill); + } + + if ($run->runId !== null && $unit->history()->where('parameters->run_id', $run->runId)->exists()) { + return ['status' => self::DUPLICATE, 'unit' => $unit, 'entry' => null]; + } + + $workstation = ($options['workstation'] ?? null) ?? ($run->station ? Workstation::where('code', $run->station)->first() : null); + $operator = ($options['operator'] ?? null) ?? $this->operatorNamed($run->operator, $options['actor'] ?? null); + $line = $run->line ? Line::where('code', $run->line)->first() : null; + + $entry = $this->serials->recordStep($unit, $operator, null, [ + // An unknown station stays unknown - recordStep's fallback to the + // operator's own station would attribute the test to the wrong bench. + 'workstation_id' => $workstation?->id, + 'exact_workstation' => true, + 'parameters' => array_filter([ + 'event' => 'test', + 'run_id' => $run->runId, + 'source' => $options['source'] ?? null, + 'station' => $run->station, + 'line' => $line?->code ?? $run->line, + 'operator' => $run->operator, + 'started_at' => $run->startedAt?->toIso8601String(), + 'ended_at' => $run->endedAt?->toIso8601String(), + 'steps' => $run->steps, + 'failed_steps' => $run->failedSteps, + ...$run->extra, + ], fn ($v) => $v !== null && $v !== []), + 'result' => $run->verdict, + 'notes' => $this->notes($run), + 'processed_at' => $run->processedAt(), + ]); + + return ['status' => self::IMPORTED, 'unit' => $unit->fresh(), 'entry' => $entry]; + }); + } + + /** The user the log names, by name, e-mail or username; else the fallback; a system account is not invented. */ + private function operatorNamed(?string $name, ?User $fallback = null): User + { + $user = $name ? User::where('name', $name)->orWhere('email', $name)->orWhere('username', $name)->first() : null; + + return $user ?? $fallback ?? User::orderBy('id')->firstOrFail(); + } + + private function notes(TestRun $run): string + { + $text = match ($run->verdict) { + TestRun::FAIL => __('Test verdict FAIL').($run->failedSteps ? ' - '.__('steps').': '.implode(', ', $run->failedSteps) : ''), + TestRun::REWORK => __('Test verdict REWORK'), + default => __('Test verdict PASS'), + }; + + return mb_strimwidth($text, 0, 500, '…'); + } +} diff --git a/backend/app/Services/Traceability/TestLog/TestLogParser.php b/backend/app/Services/Traceability/TestLog/TestLogParser.php new file mode 100644 index 000000000..e87c1cee0 --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/TestLogParser.php @@ -0,0 +1,71 @@ + */ + private array $formats; + + /** @param array|TestLogFormat>|null $formats */ + public function __construct(?array $formats = null) + { + $this->formats = array_map( + fn ($f) => $f instanceof TestLogFormat ? $f : app($f), + $formats ?? config('traceability.test_log_formats', []), + ); + } + + /** @throws InvalidArgumentException when no format recognises the file */ + public function parseFile(string $path): ?TestRun + { + return $this->parseString((string) file_get_contents($path)); + } + + /** @throws InvalidArgumentException when no format recognises the content */ + public function parseString(string $jsonl): ?TestRun + { + $records = []; + foreach (preg_split('/\r?\n/', $jsonl) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $row = json_decode($line, true); + if (is_array($row)) { + $records[] = $row; + } + } + + return $this->parseRecords($records); + } + + /** + * Already-decoded records (a file's lines, or an API body) to a run. + * + * @param array> $records + * + * @throws InvalidArgumentException when no format recognises them + */ + public function parseRecords(array $records): ?TestRun + { + if ($records === []) { + return null; + } + + foreach ($this->formats as $format) { + if ($format->supports($records[0])) { + return $format->parse($records); + } + } + + throw new InvalidArgumentException('No configured test-log format recognises this file (first record keys: '.implode(', ', array_keys($records[0])).').'); + } +} diff --git a/backend/app/Services/Traceability/TestLog/TestRun.php b/backend/app/Services/Traceability/TestLog/TestRun.php new file mode 100644 index 000000000..66916edcf --- /dev/null +++ b/backend/app/Services/Traceability/TestLog/TestRun.php @@ -0,0 +1,45 @@ +>}> $steps + * @param array $failedSteps ids of the steps that failed + * @param array $extra anything else worth keeping (software, limits, fixture…) + */ + public function __construct( + public readonly string $serialNo, + public readonly ?string $psn, + public readonly ?string $runId, + public readonly string $verdict, + public readonly ?CarbonImmutable $startedAt, + public readonly ?CarbonImmutable $endedAt, + public readonly ?string $station, + public readonly ?string $line, + public readonly ?string $operator, + public readonly array $steps = [], + public readonly array $failedSteps = [], + public readonly array $extra = [], + ) {} + + /** The moment the history row is stamped with: when the verdict fell, else when the run began. */ + public function processedAt(): ?CarbonImmutable + { + return $this->endedAt ?? $this->startedAt; + } +} diff --git a/backend/app/Services/Traceability/TraceabilityService.php b/backend/app/Services/Traceability/TraceabilityService.php index 468ee8b69..c63985ea1 100644 --- a/backend/app/Services/Traceability/TraceabilityService.php +++ b/backend/app/Services/Traceability/TraceabilityService.php @@ -8,8 +8,10 @@ use App\Models\MaterialLot; use App\Models\Pallet; use App\Models\SerialUnit; +use App\Models\SerialUnitComponent; use App\Models\WorkOrder; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; /** @@ -468,6 +470,7 @@ public function palletTrace(Pallet $pallet): array return [ 'pallet' => [ + 'id' => $pallet->id, 'pallet_no' => $pallet->pallet_no, 'status' => $pallet->status instanceof PalletStatus ? $pallet->status->value : $pallet->status, 'qty' => (int) $pallet->qty, @@ -481,6 +484,18 @@ public function palletTrace(Pallet $pallet): array 'product' => $pallet->workOrder->productType?->name, ] : null, 'batch' => $pallet->batch ? $this->batchChain($pallet->batch) : null, + // Serialised units on the pallet, by carton - the shipment's exact contents. + 'units' => SerialUnit::where('pallet_id', $pallet->id) + ->with('carton:id,carton_no') + ->orderBy('carton_id')->orderBy('serial_no') + ->get() + ->map(fn (SerialUnit $u) => [ + 'serial_no' => $u->serial_no, + 'psn' => $u->psn, + 'status' => $u->status, + 'carton_no' => $u->carton?->carton_no, + 'packed_at' => $u->packed_at?->format('Y-m-d H:i'), + ])->values(), ]; } @@ -553,7 +568,34 @@ private function batchChain(Batch $batch): array */ public function customerOrderTrace(string $customerOrderNo): array { - $workOrders = WorkOrder::where('customer_order_no', $customerOrderNo) + return [ + 'customer_order_no' => $customerOrderNo, + 'work_orders' => $this->traceWorkOrders(WorkOrder::where('customer_order_no', $customerOrderNo)), + ]; + } + + /** + * The same descent as customerOrderTrace(), for the one work order the + * console was pointed at - by its order number or from the orders table. + */ + public function workOrderTrace(WorkOrder $workOrder): array + { + return [ + 'order_no' => $workOrder->order_no, + 'customer_order_no' => $workOrder->customer_order_no, + 'work_orders' => $this->traceWorkOrders(WorkOrder::whereKey($workOrder->getKey())), + ]; + } + + /** + * Work orders with their pallets and batches, each batch carrying the lots it + * produced and the component lots it consumed. + * + * @param Builder $query + */ + private function traceWorkOrders(Builder $query): Collection + { + $workOrders = $query ->with([ 'productType:id,name', 'pallets:id,work_order_id,batch_id,pallet_no,status', @@ -570,35 +612,32 @@ public function customerOrderTrace(string $customerOrderNo): array $batchIds = $workOrders->pluck('batches')->flatten(1)->pluck('id')->filter()->unique()->all(); $inputLotsByBatch = $this->inputLotsByBatch($batchIds); - return [ - 'customer_order_no' => $customerOrderNo, - 'work_orders' => $workOrders->map(fn ($wo) => [ - 'order_no' => $wo->order_no, - 'product' => $wo->productType?->name, - 'status' => $wo->status, - 'pallets' => $wo->pallets->map(fn ($p) => [ - 'pallet_no' => $p->pallet_no, - 'status' => $p->status instanceof PalletStatus ? $p->status->value : $p->status, - 'batch_lot' => $p->batch?->lot_number, + return $workOrders->map(fn ($wo) => [ + 'order_no' => $wo->order_no, + 'product' => $wo->productType?->name, + 'status' => $wo->status, + 'pallets' => $wo->pallets->map(fn ($p) => [ + 'pallet_no' => $p->pallet_no, + 'status' => $p->status instanceof PalletStatus ? $p->status->value : $p->status, + 'batch_lot' => $p->batch?->lot_number, + ])->values(), + 'batches' => $wo->batches->map(fn ($b) => [ + 'batch_number' => $b->batch_number, + 'lot_number' => $b->lot_number, + 'status' => $b->status, + 'output_lots' => $b->outputLots->map(fn ($o) => [ + 'lot_number' => $o->lot_number, + 'material' => $o->material?->name, + 'status' => $o->status, ])->values(), - 'batches' => $wo->batches->map(fn ($b) => [ - 'batch_number' => $b->batch_number, - 'lot_number' => $b->lot_number, - 'status' => $b->status, - 'output_lots' => $b->outputLots->map(fn ($o) => [ - 'lot_number' => $o->lot_number, - 'material' => $o->material?->name, - 'status' => $o->status, - ])->values(), - 'components' => collect($inputLotsByBatch[$b->id] ?? [])->map(fn ($lot) => [ - 'lot_number' => $lot->lot_number, - 'material' => $lot->material?->name, - 'supplier_lot_no' => $lot->supplier_lot_no, - 'status' => $lot->status, - ])->values(), + 'components' => collect($inputLotsByBatch[$b->id] ?? [])->map(fn ($lot) => [ + 'lot_number' => $lot->lot_number, + 'material' => $lot->material?->name, + 'supplier_lot_no' => $lot->supplier_lot_no, + 'status' => $lot->status, ])->values(), ])->values(), - ]; + ])->values(); } /** @@ -675,4 +714,65 @@ public function resolve(string $term): ?array return null; } + + /** + * Where a component identifier ended up: every unit it was scanned into, + * installed or since removed. This is the recall question for a bought-in + * part the system knows only by the serial on its label. + * + * @return array{identifier: string, units: Collection>} + */ + public function componentTrace(string $identifier): array + { + $bindings = SerialUnitComponent::where('identifier', $identifier) + ->with(['unit:id,serial_no,psn,status,work_order_id', 'unit.workOrder:id,order_no,product_type_id', 'unit.workOrder.productType:id,name', 'material:id,code,name', 'workstation:id,name', 'boundBy:id,name']) + ->orderByDesc('bound_at')->orderByDesc('id') + ->get(); + + return [ + 'identifier' => $identifier, + 'material' => $bindings->first()?->material?->only(['code', 'name']), + 'units' => $bindings->map(fn (SerialUnitComponent $c) => [ + 'serial_no' => $c->unit?->serial_no, + 'psn' => $c->unit?->psn, + 'status' => $c->unit?->status, + 'work_order' => $c->unit?->workOrder?->order_no, + 'product' => $c->unit?->workOrder?->productType?->name, + 'installed' => $c->unbound_at === null, + 'bound_at' => $c->bound_at?->format('Y-m-d H:i'), + 'unbound_at' => $c->unbound_at?->format('Y-m-d H:i'), + 'workstation' => $c->workstation?->name, + 'bound_by' => $c->boundBy?->name, + ])->values(), + ]; + } + + /** + * The components inside a unit, as scanned: what each one resolved to, and + * the ones taken out since, so the trace shows the unit as built and as it is. + * + * @return Collection> + */ + public function installedComponents(SerialUnit $unit): Collection + { + return $unit->components() + ->with(['material:id,code,name', 'componentUnit:id,serial_no,status', 'materialLot:id,lot_number,supplier_lot_no', 'workstation:id,name', 'boundBy:id,name']) + ->orderBy('bound_at') + ->get() + ->map(fn (SerialUnitComponent $c) => [ + 'identifier' => $c->identifier, + 'kind' => $c->component_serial_unit_id ? 'serial_unit' : ($c->material_lot_id ? 'material_lot' : 'identifier'), + 'material' => $c->material?->name, + 'material_code' => $c->material?->code, + 'quantity' => (float) $c->quantity, + 'component_status' => $c->componentUnit?->status, + 'supplier_lot_no' => $c->materialLot?->supplier_lot_no, + 'installed' => $c->unbound_at === null, + 'bound_at' => $c->bound_at?->format('Y-m-d H:i'), + 'unbound_at' => $c->unbound_at?->format('Y-m-d H:i'), + 'unbind_reason' => $c->unbind_reason, + 'workstation' => $c->workstation?->name, + 'bound_by' => $c->boundBy?->name, + ]); + } } diff --git a/backend/app/Services/Warehouse/PalletStockDocumentService.php b/backend/app/Services/Warehouse/PalletStockDocumentService.php new file mode 100644 index 000000000..f6e88a834 --- /dev/null +++ b/backend/app/Services/Warehouse/PalletStockDocumentService.php @@ -0,0 +1,126 @@ +where('key', self::SETTING_KEY)->value('value'); + } catch (\Throwable) { + return self::OFF; + } + $mode = $raw === null ? null : json_decode((string) $raw, true); + + return in_array($mode, self::MODES, true) ? $mode : self::OFF; + } + + /** On, and there is a warehouse module to book into. */ + public function enabled(): bool + { + return self::mode() !== self::OFF + && config('openmmes.warehouse.auto_documents', true) + && ModuleRegistry::isModuleEnabled('warehouse'); + } + + /** The pallet's status just changed: book what that transition means. */ + public function statusChanged(Pallet $pallet, ?User $user = null): void + { + if (! $this->enabled()) { + return; + } + try { + if (in_array($pallet->status, [PalletStatus::Closed, PalletStatus::Shipped], true)) { + // Shipped straight from open still arrives in the warehouse first. + $this->book($pallet, StockDocument::TYPE_PRODUCT_RECEIPT, $user); + } + if ($pallet->status === PalletStatus::Shipped) { + $this->book($pallet, StockDocument::TYPE_PRODUCT_ISSUE, $user); + } + } catch (\Throwable $e) { + Log::warning('Could not book the pallet in the finished-goods warehouse', ['pallet_id' => $pallet->id, 'error' => $e->getMessage()]); + } + } + + private function book(Pallet $pallet, string $type, ?User $user): ?StockDocument + { + if (StockDocument::where('pallet_id', $pallet->id)->where('type', $type)->exists()) { + return null; + } + $workOrder = $pallet->workOrder; + $quantity = $this->quantity($pallet); + if (! $workOrder?->product_type_id || $quantity <= 0 || ! Warehouse::resolveDefault(Warehouse::KIND_FINISHED_GOODS)) { + return null; + } + + $document = $this->documents->createDraft([ + 'type' => $type, + 'work_order_id' => $workOrder->id, + 'batch_id' => $pallet->batch_id, + 'pallet_id' => $pallet->id, + 'notes' => $type === StockDocument::TYPE_PRODUCT_RECEIPT + ? __('Pallet :pallet received from work order :order', ['pallet' => $pallet->pallet_no, 'order' => $workOrder->order_no]) + : __('Pallet :pallet shipped', ['pallet' => $pallet->pallet_no]), + 'lines' => [[ + 'product_type_id' => $workOrder->product_type_id, + 'quantity' => $quantity, + 'unit_of_measure' => $workOrder->productType?->unit_of_measure, + ]], + ], $user); + + if (self::mode() === self::POST) { + try { + $document = $this->documents->post($document, $user); + } catch (\Throwable $e) { + // Left as a draft for the warehouse to sort out (e.g. stock it cannot issue). + Log::warning('Pallet stock document left as a draft', ['document_id' => $document->id, 'error' => $e->getMessage()]); + } + } + + return $document; + } + + /** Serialised goods count their good units (scrap and held ones excepted); anything else the pallet's pieces. */ + private function quantity(Pallet $pallet): float + { + $units = SerialUnit::where('pallet_id', $pallet->id)->whereNotIn('status', [SerialUnit::STATUS_SCRAPPED, SerialUnit::STATUS_BLOCKED])->count(); + + return $units > 0 ? (float) $units : (float) $pallet->qty; + } +} diff --git a/backend/app/Services/Warehouse/WorkOrderStockDocumentService.php b/backend/app/Services/Warehouse/WorkOrderStockDocumentService.php index 99f8fc0eb..43295c2b2 100644 --- a/backend/app/Services/Warehouse/WorkOrderStockDocumentService.php +++ b/backend/app/Services/Warehouse/WorkOrderStockDocumentService.php @@ -39,6 +39,12 @@ public function generateForCompletion(WorkOrder $workOrder): array if ($this->alreadyHas($workOrder, $type)) { continue; } + // Booked pallet by pallet instead: an order that packs onto pallets has + // its pallets carry the receipt; one that never palletises keeps this one. + if ($type === StockDocument::TYPE_PRODUCT_RECEIPT && PalletStockDocumentService::mode() !== PalletStockDocumentService::OFF + && \App\Models\BatchStep::where('kind', \App\Models\TemplateStep::KIND_PACKING)->whereHas('batch', fn ($q) => $q->where('work_order_id', $workOrder->id))->exists()) { + continue; + } $document = $type === StockDocument::TYPE_MATERIAL_ISSUE ? $this->generateMaterialIssue($workOrder) diff --git a/backend/app/Services/WorkOrder/BatchService.php b/backend/app/Services/WorkOrder/BatchService.php index 579b7fb60..7489bdf4a 100644 --- a/backend/app/Services/WorkOrder/BatchService.php +++ b/backend/app/Services/WorkOrder/BatchService.php @@ -8,6 +8,7 @@ use App\Models\ScrapEntry; use App\Models\Shift; use App\Models\User; +use App\Models\WorkOrder; use App\Models\Workstation; use App\Services\Material\MaterialAllocationService; use App\Services\Quality\QualityTriggerService; @@ -57,6 +58,12 @@ public function startStep(BatchStep $step, User $user, array $picksByMaterial = $batch = $step->batch; $wasPending = $batch->status === Batch::STATUS_PENDING; + // Production hold (setting): a batch does not start production while + // stock cannot cover its order - the planner's shortage check, enforced. + if ($wasPending && self::holdsOnMaterialShortage()) { + $this->assertMaterialAvailable($batch->workOrder); + } + // Start the step $step->update([ 'status' => BatchStep::STATUS_IN_PROGRESS, @@ -89,6 +96,31 @@ public function startStep(BatchStep $step, User $user, array $picksByMaterial = }); } + /** System setting `hold_on_material_shortage` (off unless switched on). */ + public static function holdsOnMaterialShortage(): bool + { + try { + return (bool) json_decode((string) DB::table('system_settings')->where('key', 'hold_on_material_shortage')->value('value'), true); + } catch (\Throwable) { + return false; + } + } + + /** @throws \DomainException naming what is missing and how much */ + private function assertMaterialAvailable(WorkOrder $workOrder): void + { + $short = $this->allocationService->shortagesForWorkOrders(collect([$workOrder]))[$workOrder->id] ?? []; + if ($short === []) { + return; + } + $items = collect($short)->map(fn ($line) => trim(($line['material_code'] ?? $line['material_name'] ?? '?').' ('.__('missing :qty :unit', [ + 'qty' => rtrim(rtrim(number_format((float) $line['missing_qty'], 3, '.', ''), '0'), '.'), + 'unit' => $line['unit_of_measure'] ?? '', + ]).')'))->implode(', '); + + throw new \DomainException(__('Production hold: stock does not cover order :order. Missing: :items.', ['order' => $workOrder->order_no, 'items' => $items])); + } + /** * Complete a batch step. * diff --git a/backend/app/Services/WorkOrder/WorkOrderService.php b/backend/app/Services/WorkOrder/WorkOrderService.php index 7f1dc5322..ad27b04a4 100644 --- a/backend/app/Services/WorkOrder/WorkOrderService.php +++ b/backend/app/Services/WorkOrder/WorkOrderService.php @@ -769,6 +769,8 @@ protected function createBatchStepsFromSnapshot(Batch $batch, array $processSnap 'batch_id' => $batch->id, 'step_number' => $stepData['step_number'], 'name' => $stepData['name'], + 'kind' => $stepData['kind'] ?? \App\Models\TemplateStep::KIND_PRODUCTION, + 'config' => $stepData['config'] ?? null, 'instruction' => $stepData['instruction'] ?? null, 'requires_confirmation' => $stepData['requires_confirmation'] ?? false, 'workstation_id' => $stepData['workstation_id'] ?? null, diff --git a/backend/app/Support/CoreVersionConstraint.php b/backend/app/Support/CoreVersionConstraint.php new file mode 100644 index 000000000..5ff396d28 --- /dev/null +++ b/backend/app/Support/CoreVersionConstraint.php @@ -0,0 +1,77 @@ +=` must match before `>`. */ + private const OPERATORS = ['>=', '<=', '==', '!=', '>', '<', '=']; + + /** + * @param string|null $constraint e.g. `>=0.25.0`, or null when the module declares none + * @param string|null $current the running core version, `v` prefix optional + */ + public static function isSatisfied(?string $constraint, ?string $current = null): bool + { + $constraint = trim((string) $constraint); + + // No constraint is the normal case — most modules declare none, and a + // module that does not care which core it runs on is not an error. + if ($constraint === '') { + return true; + } + + [$operator, $required] = self::parse($constraint); + + return version_compare(self::normalise($current ?? config('version.current')), $required, $operator); + } + + /** + * @return array{0: string, 1: string} + * + * @throws \InvalidArgumentException when the constraint is not one operator and one version + */ + private static function parse(string $constraint): array + { + foreach (self::OPERATORS as $operator) { + if (str_starts_with($constraint, $operator)) { + $version = self::normalise(substr($constraint, strlen($operator))); + + return [$operator === '=' ? '==' : $operator, self::assertVersion($version, $constraint)]; + } + } + + // A bare version means "this one or newer". Modules are forward + // compatible far more often than not, and reading it as an exact match + // would strand every module on the day core is patched. + return ['>=', self::assertVersion(self::normalise($constraint), $constraint)]; + } + + private static function normalise(?string $version): string + { + return ltrim(trim((string) $version), 'vV'); + } + + private static function assertVersion(string $version, string $constraint): string + { + if (! preg_match('/^\d+(\.\d+)*([-+].+)?$/', $version)) { + throw new \InvalidArgumentException("Unreadable core version requirement: \"{$constraint}\"."); + } + + return $version; + } +} diff --git a/backend/app/Support/DownloadName.php b/backend/app/Support/DownloadName.php new file mode 100644 index 000000000..49e33a1da --- /dev/null +++ b/backend/app/Support/DownloadName.php @@ -0,0 +1,20 @@ +register()` inside a + * try/catch, and the comment there promised "a bad module never prevents the + * application from booting". It only half held: `register()` was covered, but + * Laravel calls a provider's `boot()` later, while booting every provider, and + * an exception thrown there escapes that catch entirely. A module built + * against a newer core — calling a menu helper with an argument this version + * does not accept, say — took the entire installation down with a 502, on + * every route, including the admin screen needed to disable it again. + * + * Both phases run here instead, each guarded. A module that throws simply does + * not register its routes, menus or listeners; every other page keeps working + * and the reason is in the log rather than in an nginx error page. + */ +class ModuleProviderGuard extends ServiceProvider +{ + public function __construct( + Application $app, + private readonly ServiceProvider $inner, + private readonly string $module, + ) { + parent::__construct($app); + } + + public function register(): void + { + $this->guard('register'); + } + + public function boot(): void + { + $this->guard('boot'); + } + + /** + * Run one phase of the wrapped provider, swallowing anything it throws. + * + * `boot()` is resolved through the container because a provider may type-hint + * its dependencies there, which is how Laravel calls it too. `register()` is + * always defined by the base class; `boot()` is not, so it is checked. + */ + private function guard(string $phase): void + { + if (! method_exists($this->inner, $phase)) { + return; + } + + try { + $this->app->call([$this->inner, $phase]); + } catch (\Throwable $e) { + Log::error("module.provider_{$phase}_failed", [ + 'module' => $this->module, + 'provider' => $this->inner::class, + 'exception' => $e->getMessage(), + ]); + + // Also to the app's error reporting, so this is visible wherever + // exceptions normally are and not only to whoever tails the log. + report($e); + } + } +} diff --git a/backend/app/Support/PalletPackingList.php b/backend/app/Support/PalletPackingList.php new file mode 100644 index 000000000..83bb999bd --- /dev/null +++ b/backend/app/Support/PalletPackingList.php @@ -0,0 +1,73 @@ +, + * cartons: array>}>, + * loose_units: array>, + * totals: array{cartons: int, units: int, scrapped: int} + * } + */ + public static function data(Pallet $pallet): array + { + $pallet->loadMissing(['workOrder:id,order_no,customer_order_no,product_type_id', 'workOrder.productType:id,name,code']); + + $units = SerialUnit::where('pallet_id', $pallet->id) + ->orderBy('carton_id')->orderBy('serial_no') + ->get(['id', 'serial_no', 'psn', 'status', 'carton_id', 'packed_at']); + $cartons = UnitCarton::where('pallet_id', $pallet->id)->orderBy('carton_no')->get(); + + $row = fn (SerialUnit $u) => [ + 'serial_no' => $u->serial_no, + 'psn' => $u->psn, + 'status' => $u->status, + 'packed_at' => $u->packed_at?->format('Y-m-d H:i'), + ]; + $byCarton = $units->groupBy('carton_id'); + + return [ + 'pallet' => [ + 'id' => $pallet->id, + 'pallet_no' => $pallet->pallet_no, + 'status' => $pallet->status instanceof PalletStatus ? $pallet->status->value : $pallet->status, + 'quality_status' => $pallet->quality_status, + 'qty' => (int) $pallet->qty, + 'location' => $pallet->location, + 'destination' => $pallet->destination, + 'erp_reference' => $pallet->erp_reference, + 'order_no' => $pallet->workOrder?->order_no, + 'customer_order_no' => $pallet->workOrder?->customer_order_no, + 'product' => $pallet->workOrder?->productType?->name, + 'product_code' => $pallet->workOrder?->productType?->code, + 'created_at' => $pallet->created_at?->format('Y-m-d H:i'), + 'shipped_at' => $pallet->shipped_at?->format('Y-m-d H:i'), + ], + 'cartons' => $cartons->map(fn (UnitCarton $c) => [ + 'carton_no' => $c->carton_no, + 'status' => $c->status, + 'closed_at' => $c->closed_at?->format('Y-m-d H:i'), + 'units' => $byCarton->get($c->id, collect())->map($row)->values()->all(), + ])->values()->all(), + 'loose_units' => $byCarton->get(null, collect())->map($row)->values()->all(), + 'totals' => [ + 'cartons' => $cartons->count(), + 'units' => $units->where('status', '!=', SerialUnit::STATUS_SCRAPPED)->count(), + 'scrapped' => $units->where('status', SerialUnit::STATUS_SCRAPPED)->count(), + ], + ]; + } +} diff --git a/backend/app/Support/StockMovementReason.php b/backend/app/Support/StockMovementReason.php new file mode 100644 index 000000000..c6e089556 --- /dev/null +++ b/backend/app/Support/StockMovementReason.php @@ -0,0 +1,72 @@ + the parameters it takes, in order. */ + private const TEMPLATES = [ + 'Allocated to batch #:batch (step :step)' => ['batch', 'step'], + 'Allocated to batch #:batch' => ['batch'], + 'Batch #:batch completed — leftover returned to stock' => ['batch'], + 'Batch #:batch scrap qty recorded' => ['batch'], + 'Batch #:batch cancelled — return to stock' => ['batch'], + 'Adjustment on batch #:batch' => ['batch'], + 'Batch #:batch — unused material returned to stock' => ['batch'], + ]; + + /** The English note stored on the movement. */ + public static function make(string $template, array $params): string + { + self::assertKnown($template); + + return strtr($template, self::placeholders($params)); + } + + /** A stored note in the current locale - or itself, when it is not one of ours. */ + public static function translate(?string $reason): ?string + { + if ($reason === null || $reason === '') { + return $reason; + } + + foreach (self::TEMPLATES as $template => $names) { + // Quote the literal text between placeholders; each placeholder becomes a numeric capture. + $literals = array_map(fn ($part) => preg_quote($part, '/'), preg_split('/:\w+/', $template)); + $pattern = '/^'.implode('(\d+)', $literals).'$/u'; + if (preg_match($pattern, $reason, $m)) { + return __($template, array_combine($names, array_slice($m, 1))); + } + } + + return $reason; + } + + private static function assertKnown(string $template): void + { + if (! array_key_exists($template, self::TEMPLATES)) { + throw new \InvalidArgumentException("Unknown stock movement reason template: {$template}"); + } + } + + /** @return array */ + private static function placeholders(array $params): array + { + $out = []; + foreach ($params as $name => $value) { + $out[':'.$name] = (string) $value; + } + + return $out; + } +} diff --git a/backend/app/Support/UnitLabelActions.php b/backend/app/Support/UnitLabelActions.php new file mode 100644 index 000000000..386674403 --- /dev/null +++ b/backend/app/Support/UnitLabelActions.php @@ -0,0 +1,44 @@ + */ + public static function for(?User $user, ?Workstation $workstation): array + { + if (! $workstation || ! $user || $user->hasAnyRole(['Admin', 'Supervisor'])) { + return self::ALL; + } + $pinned = array_values(array_intersect(self::ALL, (array) ($workstation->unit_label_actions ?? []))); + + return $pinned !== [] ? $pinned : self::ALL; + } +} diff --git a/backend/app/Support/UnitSerialisation.php b/backend/app/Support/UnitSerialisation.php new file mode 100644 index 000000000..fd04698f9 --- /dev/null +++ b/backend/app/Support/UnitSerialisation.php @@ -0,0 +1,149 @@ + true, + 'unit_serial_pattern' => '', + 'unit_psn_pattern' => '', + 'unit_psn_required' => false, + 'unit_psn_unique' => true, + 'unit_test_fail_policy' => self::FAIL_BLOCK, + 'unit_test_max_attempts' => 1, + 'unit_test_attempts_scope' => self::ATTEMPTS_UNIT, + ]; + + public const FAIL_BLOCK = 'block'; + + public const FAIL_SCRAP = 'scrap'; + + public const FAIL_RECORD = 'record'; + + public const FAIL_POLICIES = [self::FAIL_BLOCK, self::FAIL_SCRAP, self::FAIL_RECORD]; + + public const ATTEMPTS_UNIT = 'unit'; + + public const ATTEMPTS_TEST = 'test'; + + public const ATTEMPT_SCOPES = [self::ATTEMPTS_UNIT, self::ATTEMPTS_TEST]; + + /** Settings read once per request (the container instance is request-scoped under Octane). */ + private ?array $settings = null; + + public function all(): array + { + if ($this->settings !== null) { + return $this->settings; + } + + $out = self::KEYS; + try { + $rows = DB::table('system_settings')->whereIn('key', [...array_keys(self::KEYS), 'unit_scrap_on_test_fail'])->pluck('value', 'key'); + } catch (\Throwable) { + return $this->settings = $out; // no database yet (install) + } + + foreach ($rows as $key => $raw) { + $value = json_decode((string) $raw, true); + if ($value !== null) { + $out[$key] = $value; + } + } + + // An install saved before the policy existed kept a "scrap on fail" flag. + if (! isset($rows['unit_test_fail_policy']) && json_decode((string) ($rows['unit_scrap_on_test_fail'] ?? 'false'), true)) { + $out['unit_test_fail_policy'] = self::FAIL_SCRAP; + } + + return $this->settings = $out; + } + + public function get(string $key): mixed + { + return $this->all()[$key] ?? self::KEYS[$key] ?? null; + } + + public function forget(): void + { + $this->settings = null; + } + + /** The stored form of a scanned identifier. */ + public function normalize(?string $identifier): ?string + { + if ($identifier === null) { + return null; + } + $identifier = trim($identifier); + if ($identifier === '') { + return null; + } + + return $this->get('unit_identifier_normalize') + ? strtoupper(preg_replace('/\s+/u', '', $identifier)) + : $identifier; + } + + /** Laravel validation rules for a unit serial number. */ + public function serialRules(): array + { + return array_merge(['required', 'string', 'max:100'], $this->patternRule((string) $this->get('unit_serial_pattern'))); + } + + /** Laravel validation rules for a process serial number. */ + public function psnRules(): array + { + return array_merge( + [$this->get('unit_psn_required') ? 'required' : 'nullable', 'string', 'max:100'], + $this->patternRule((string) $this->get('unit_psn_pattern')), + ); + } + + /** True when the regex is usable - the settings form refuses anything else. */ + public static function validPattern(string $pattern): bool + { + return $pattern === '' || @preg_match(self::delimit($pattern), '') !== false; + } + + /** @return array */ + private function patternRule(string $pattern): array + { + return $pattern === '' ? [] : ['regex:'.self::delimit($pattern)]; + } + + /** Patterns are stored bare ("^\d{3}-\d{2}-\d+$"); Laravel's regex rule wants delimiters. */ + private static function delimit(string $pattern): string + { + return '/'.str_replace('/', '\/', $pattern).'/u'; + } +} diff --git a/backend/app/Sync/CollectionBroadcaster.php b/backend/app/Sync/CollectionBroadcaster.php index 7fa761732..317656458 100644 --- a/backend/app/Sync/CollectionBroadcaster.php +++ b/backend/app/Sync/CollectionBroadcaster.php @@ -57,6 +57,7 @@ private static function map(): array 'users' => [Models\User::class, null], 'workers' => [Models\Worker::class, null], 'materials' => [Models\Material::class, null], + 'serial_units' => [Models\SerialUnit::class, null], 'data_imports' => [Models\CsvImport::class, fn ($m) => $m->created_at === null || $m->created_at->gte(now()->subDays(\App\Sync\Shapes\DataImportsRecentShape::DAYS))], 'material_types' => [Models\MaterialType::class, null], 'material_lots' => [Models\MaterialLot::class, null], diff --git a/backend/app/Sync/ShapeRegistry.php b/backend/app/Sync/ShapeRegistry.php index 13b655146..42b4bb101 100644 --- a/backend/app/Sync/ShapeRegistry.php +++ b/backend/app/Sync/ShapeRegistry.php @@ -9,6 +9,7 @@ use App\Sync\Shapes\OeeRecordsRecentShape; use App\Sync\Shapes\PalletMovementsRecentShape; use App\Sync\Shapes\ProductTypesShape; +use App\Sync\Shapes\SerialUnitsRecentShape; use App\Sync\Shapes\WorkOrdersActiveShape; /** @@ -139,7 +140,7 @@ class ShapeRegistry ], 'lot_sequences' => [ 'table' => 'lot_sequences', - 'columns' => ['id', 'name', 'product_type_id', 'prefix', 'suffix', 'pattern', 'next_number', 'pad_size', 'year_prefix', 'reset_period', 'created_at', 'updated_at'], + 'columns' => ['id', 'name', 'product_type_id', 'purpose', 'prefix', 'suffix', 'pattern', 'next_number', 'pad_size', 'year_prefix', 'reset_period', 'created_at', 'updated_at'], ], 'pallets' => [ 'table' => 'pallets', @@ -149,6 +150,8 @@ class ShapeRegistry // Rolling recent window (see the class) so the append-only ledger can't // grow the synced payload without bound. 'pallet_movements' => PalletMovementsRecentShape::class, + // Units on the floor plus recently finished ones - the table grows by one row per product. + 'serial_units' => SerialUnitsRecentShape::class, // integration_configs: exclude api_config (may hold credentials). 'integration_configs' => [ 'table' => 'integration_configs', @@ -208,7 +211,7 @@ class ShapeRegistry ], 'label_templates' => [ 'table' => 'label_templates', - 'columns' => ['id', 'name', 'type', 'size', 'barcode_format', 'is_default', 'is_active', 'created_at', 'updated_at'], + 'columns' => ['id', 'name', 'type', 'size', 'barcode_format', 'fields_config', 'is_default', 'is_active', 'created_at', 'updated_at'], ], // All issues (any status) for the supervisor/admin issue management page. 'issues_all' => [ diff --git a/backend/app/Sync/Shapes/SerialUnitsRecentShape.php b/backend/app/Sync/Shapes/SerialUnitsRecentShape.php new file mode 100644 index 000000000..bc55b13b3 --- /dev/null +++ b/backend/app/Sync/Shapes/SerialUnitsRecentShape.php @@ -0,0 +1,42 @@ +subDays(self::WINDOW_DAYS)->toDateString(); + $finished = "'".SerialUnit::STATUS_SHIPPED."', '".SerialUnit::STATUS_SCRAPPED."'"; + + return "(status NOT IN ({$finished}) OR updated_at >= '{$since}')"; + } +} diff --git a/backend/config/logging.php b/backend/config/logging.php index 9e998a496..9b8a199d0 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -118,6 +118,16 @@ 'replace_placeholders' => true, ], + // Declared here rather than left for the framework to add at runtime: + // under Octane that runtime copy lands in the request's config, not the + // one the log manager reads, and every deprecation notice then failed + // with "Log [deprecations] is not defined" through the emergency logger. + 'deprecations' => [ + 'driver' => 'stack', + 'channels' => [env('LOG_DEPRECATIONS_CHANNEL', 'null')], + 'ignore_exceptions' => false, + ], + 'null' => [ 'driver' => 'monolog', 'handler' => NullHandler::class, diff --git a/backend/config/traceability.php b/backend/config/traceability.php new file mode 100644 index 000000000..9a6879ea5 --- /dev/null +++ b/backend/config/traceability.php @@ -0,0 +1,25 @@ + [ + App\Services\Traceability\TestLog\NativeTestRunFormat::class, + App\Services\Traceability\TestLog\CompactCycleFormat::class, + App\Services\Traceability\TestLog\EventStreamFormat::class, + ], + +]; diff --git a/backend/config/version.php b/backend/config/version.php index de3e62fbb..c99bdce7d 100644 --- a/backend/config/version.php +++ b/backend/config/version.php @@ -1,6 +1,6 @@ 'v0.24.3', + 'current' => 'v0.25.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_09_23_100000_add_psn_to_serial_units_table.php b/backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php new file mode 100644 index 000000000..343a18e38 --- /dev/null +++ b/backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php @@ -0,0 +1,30 @@ +string('psn', 100)->nullable()->after('serial_no'); + $table->index('psn'); + }); + } + + public function down(): void + { + Schema::table('serial_units', function (Blueprint $table) { + $table->dropIndex(['psn']); + $table->dropColumn('psn'); + }); + } +}; diff --git a/backend/database/migrations/2026_09_24_120000_create_serial_unit_components_table.php b/backend/database/migrations/2026_09_24_120000_create_serial_unit_components_table.php new file mode 100644 index 000000000..2dc3e2545 --- /dev/null +++ b/backend/database/migrations/2026_09_24_120000_create_serial_unit_components_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('serial_unit_id')->constrained('serial_units')->cascadeOnDelete(); + // The scanned identifier as stored - the one key every component has. + $table->string('identifier', 100); + // Resolved links, whichever the identifier turned out to be. + $table->foreignId('component_serial_unit_id')->nullable()->constrained('serial_units')->nullOnDelete(); + $table->foreignId('material_lot_id')->nullable()->constrained('material_lots')->nullOnDelete(); + $table->foreignId('material_id')->nullable()->constrained('materials')->nullOnDelete(); + $table->decimal('quantity', 12, 4)->default(1); + $table->foreignId('batch_step_id')->nullable()->constrained('batch_steps')->nullOnDelete(); + $table->foreignId('workstation_id')->nullable()->constrained('workstations')->nullOnDelete(); + $table->foreignId('bound_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('bound_at', 6); + $table->timestamp('unbound_at', 6)->nullable(); + $table->foreignId('unbound_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('unbind_reason', 500)->nullable(); + $table->foreignId('tenant_id')->nullable()->index(); + $table->timestamps(); + + $table->index(['serial_unit_id', 'unbound_at']); + // Recall: which units contain this component. + $table->index(['identifier', 'tenant_id']); + $table->index('component_serial_unit_id'); + $table->index('material_lot_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('serial_unit_components'); + } +}; diff --git a/backend/database/migrations/2026_09_24_130000_add_purpose_to_lot_sequences_table.php b/backend/database/migrations/2026_09_24_130000_add_purpose_to_lot_sequences_table.php new file mode 100644 index 000000000..6b9062ad5 --- /dev/null +++ b/backend/database/migrations/2026_09_24_130000_add_purpose_to_lot_sequences_table.php @@ -0,0 +1,63 @@ +string('purpose', 20)->default('lot')->after('product_type_id'); + }); + + self::dropOldUnique(); + + // Partial: sequences are soft-deleted, and a deleted one must not keep the + // product's slot for its purpose (plain SQL - the Blueprint has no WHERE). + DB::statement('CREATE UNIQUE INDEX lot_sequences_product_purpose_unique ON lot_sequences (product_type_id, purpose, tenant_id) WHERE deleted_at IS NULL'); + } + + /** + * On PostgreSQL the old uniqueness may exist as a constraint or as a bare + * unique index depending on how the database was built (migrate vs a + * restored dump), and DROP CONSTRAINT fails on the latter - so both forms + * are dropped if present. Other drivers take the Blueprint route. + */ + private static function dropOldUnique(): void + { + $name = 'lot_sequences_product_type_id_tenant_id_unique'; + + if (Schema::getConnection()->getDriverName() === 'pgsql') { + DB::statement("ALTER TABLE lot_sequences DROP CONSTRAINT IF EXISTS {$name}"); + DB::statement("DROP INDEX IF EXISTS {$name}"); + + return; + } + + Schema::table('lot_sequences', function (Blueprint $table) use ($name) { + $table->dropUnique($name); + }); + } + + /** + * The one-sequence-per-product uniqueness is not put back: a product that has + * process- and unit-serial sequences by now would fail it. + */ + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS lot_sequences_product_purpose_unique'); + + Schema::table('lot_sequences', function (Blueprint $table) { + $table->dropColumn('purpose'); + }); + } +}; diff --git a/backend/database/migrations/2026_09_25_100000_create_unit_cartons_and_link_serial_units.php b/backend/database/migrations/2026_09_25_100000_create_unit_cartons_and_link_serial_units.php new file mode 100644 index 000000000..3d598b612 --- /dev/null +++ b/backend/database/migrations/2026_09_25_100000_create_unit_cartons_and_link_serial_units.php @@ -0,0 +1,60 @@ +id(); + $table->string('carton_no', 30); + $table->foreignId('work_order_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('pallet_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status', 10)->default('open'); // open | closed + $table->unsignedInteger('qty')->default(0); + $table->timestamp('closed_at')->nullable(); + $table->foreignId('closed_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('tenant_id')->nullable()->index(); + $table->timestamps(); + + $table->unique(['carton_no', 'tenant_id']); + $table->index(['status', 'work_order_id']); + $table->index('pallet_id'); + }); + + Schema::table('serial_units', function (Blueprint $table) { + $table->foreignId('carton_id')->nullable()->after('batch_id')->constrained('unit_cartons')->nullOnDelete(); + $table->foreignId('pallet_id')->nullable()->after('carton_id')->constrained('pallets')->nullOnDelete(); + $table->timestamp('packed_at')->nullable()->after('produced_at'); + $table->timestamp('shipped_at')->nullable()->after('packed_at'); + + $table->index('carton_id'); + $table->index('pallet_id'); + }); + } + + public function down(): void + { + Schema::table('serial_units', function (Blueprint $table) { + // Indexes first: SQLite rebuilds the table on a column drop. + $table->dropIndex(['carton_id']); + $table->dropIndex(['pallet_id']); + $table->dropConstrainedForeignId('carton_id'); + $table->dropConstrainedForeignId('pallet_id'); + $table->dropColumn(['packed_at', 'shipped_at']); + }); + + Schema::dropIfExists('unit_cartons'); + } +}; diff --git a/backend/database/migrations/2026_09_25_110000_add_active_bench_to_pallets_and_cartons.php b/backend/database/migrations/2026_09_25_110000_add_active_bench_to_pallets_and_cartons.php new file mode 100644 index 000000000..cda423798 --- /dev/null +++ b/backend/database/migrations/2026_09_25_110000_add_active_bench_to_pallets_and_cartons.php @@ -0,0 +1,40 @@ +foreignId('active_by_id')->nullable()->constrained('users')->nullOnDelete(); + $t->foreignId('active_workstation_id')->nullable()->constrained('workstations')->nullOnDelete(); + $t->timestamp('activated_at')->nullable(); + $t->index('active_by_id'); + }); + } + } + + public function down(): void + { + foreach (['pallets', 'unit_cartons'] as $table) { + Schema::table($table, function (Blueprint $t) { + // The index first: SQLite rebuilds the table on a column drop and + // trips over an index left on the column. + $t->dropIndex(['active_by_id']); + $t->dropConstrainedForeignId('active_by_id'); + $t->dropConstrainedForeignId('active_workstation_id'); + $t->dropColumn('activated_at'); + }); + } + } +}; diff --git a/backend/database/migrations/2026_09_25_120000_add_kind_and_config_to_steps.php b/backend/database/migrations/2026_09_25_120000_add_kind_and_config_to_steps.php new file mode 100644 index 000000000..6214e8969 --- /dev/null +++ b/backend/database/migrations/2026_09_25_120000_add_kind_and_config_to_steps.php @@ -0,0 +1,36 @@ +string('kind', 20)->default('production')->after('name'); + $t->json('config')->nullable()->after('kind'); + $t->index('kind'); + }); + } + } + + public function down(): void + { + foreach (['template_steps', 'batch_steps'] as $table) { + Schema::table($table, function (Blueprint $t) { + $t->dropIndex(['kind']); + $t->dropColumn(['kind', 'config']); + }); + } + } +}; diff --git a/backend/database/migrations/2026_09_25_130000_create_unit_cartons_carton_no_sequence.php b/backend/database/migrations/2026_09_25_130000_create_unit_cartons_carton_no_sequence.php new file mode 100644 index 000000000..8955faacd --- /dev/null +++ b/backend/database/migrations/2026_09_25_130000_create_unit_cartons_carton_no_sequence.php @@ -0,0 +1,35 @@ +getDriverName() !== 'pgsql') { + return; + } + DB::statement('CREATE SEQUENCE IF NOT EXISTS unit_cartons_carton_no_seq'); + $max = (int) DB::table('unit_cartons') + ->where('carton_no', 'like', 'CTN-%') + ->selectRaw('MAX(CAST(SUBSTRING(carton_no FROM 5) AS INTEGER)) AS n') + ->value('n'); + DB::statement($max > 0 + ? "SELECT setval('unit_cartons_carton_no_seq', {$max}, true)" + : "SELECT setval('unit_cartons_carton_no_seq', 1, false)"); + } + + public function down(): void + { + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement('DROP SEQUENCE IF EXISTS unit_cartons_carton_no_seq'); + } + } +}; diff --git a/backend/database/migrations/2026_09_27_100000_add_per_to_bom_items.php b/backend/database/migrations/2026_09_27_100000_add_per_to_bom_items.php new file mode 100644 index 000000000..917ed4a4a --- /dev/null +++ b/backend/database/migrations/2026_09_27_100000_add_per_to_bom_items.php @@ -0,0 +1,28 @@ +string('per', 10)->default('unit')->after('quantity_per_unit'); + }); + } + + public function down(): void + { + Schema::table('bom_items', function (Blueprint $table) { + $table->dropColumn('per'); + }); + } +}; diff --git a/backend/database/migrations/2026_09_27_120000_add_operator_screens_to_workstations.php b/backend/database/migrations/2026_09_27_120000_add_operator_screens_to_workstations.php new file mode 100644 index 000000000..5ac7dcd05 --- /dev/null +++ b/backend/database/migrations/2026_09_27_120000_add_operator_screens_to_workstations.php @@ -0,0 +1,27 @@ +json('operator_screens')->nullable()->after('workstation_type'); + }); + } + + public function down(): void + { + Schema::table('workstations', function (Blueprint $table) { + $table->dropColumn('operator_screens'); + }); + } +}; diff --git a/backend/database/migrations/2026_09_28_100000_allow_serial_units_without_serial_no.php b/backend/database/migrations/2026_09_28_100000_allow_serial_units_without_serial_no.php new file mode 100644 index 000000000..a8ecd54b0 --- /dev/null +++ b/backend/database/migrations/2026_09_28_100000_allow_serial_units_without_serial_no.php @@ -0,0 +1,32 @@ +string('serial_no', 100)->nullable()->change(); + }); + } + + public function down(): void + { + // Units still waiting for their SN get a placeholder built from their PSN + // and id: the PSN alone may be missing or shared, the serial is unique. + \Illuminate\Support\Facades\DB::table('serial_units')->whereNull('serial_no')->update(['serial_no' => \Illuminate\Support\Facades\DB::raw("COALESCE(psn, 'UNIT') || '-' || id")]); + Schema::table('serial_units', function (Blueprint $table) { + $table->string('serial_no', 100)->nullable(false)->change(); + }); + } +}; diff --git a/backend/database/migrations/2026_09_29_100000_add_pallet_id_to_stock_documents.php b/backend/database/migrations/2026_09_29_100000_add_pallet_id_to_stock_documents.php new file mode 100644 index 000000000..83a6cdef6 --- /dev/null +++ b/backend/database/migrations/2026_09_29_100000_add_pallet_id_to_stock_documents.php @@ -0,0 +1,26 @@ +foreignId('pallet_id')->nullable()->after('batch_id')->constrained('pallets')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('stock_documents', function (Blueprint $table) { + $table->dropConstrainedForeignId('pallet_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_09_29_110000_add_unit_label_actions_to_workstations.php b/backend/database/migrations/2026_09_29_110000_add_unit_label_actions_to_workstations.php new file mode 100644 index 000000000..811eba32a --- /dev/null +++ b/backend/database/migrations/2026_09_29_110000_add_unit_label_actions_to_workstations.php @@ -0,0 +1,27 @@ +json('unit_label_actions')->nullable()->after('operator_screens'); + }); + } + + public function down(): void + { + Schema::table('workstations', function (Blueprint $table) { + $table->dropColumn('unit_label_actions'); + }); + } +}; diff --git a/backend/database/seeders/LabelTemplatesSeeder.php b/backend/database/seeders/LabelTemplatesSeeder.php index 120f71d74..d8c377f0b 100644 --- a/backend/database/seeders/LabelTemplatesSeeder.php +++ b/backend/database/seeders/LabelTemplatesSeeder.php @@ -31,6 +31,18 @@ public function run(): void 'name' => 'Standard Pallet', 'size' => '100x100', ], + LabelTemplate::TYPE_SERIAL_UNIT => [ + 'name' => 'Standard Serial Unit', + 'size' => '80x40', + ], + LabelTemplate::TYPE_CARTON => [ + 'name' => 'Standard Carton', + 'size' => '100x100', + ], + LabelTemplate::TYPE_MATERIAL_LOT => [ + 'name' => 'Standard Material Lot (IQC)', + 'size' => '100x50', + ], ]; $tenants = Tenant::query()->get(); diff --git a/backend/database/seeders/TraceabilityDemoSeeder.php b/backend/database/seeders/TraceabilityDemoSeeder.php new file mode 100644 index 000000000..ebd9cd08b --- /dev/null +++ b/backend/database/seeders/TraceabilityDemoSeeder.php @@ -0,0 +1,381 @@ + a semi-finished frame lot -> finished bikes on pallets, with serial + * numbers and a customer order on top. It is written straight to the genealogy + * tables rather than driven through allocation, so it traces the same whether + * or not lot tracking is switched on. Additive only - it touches no existing row. + */ +class TraceabilityDemoSeeder extends Seeder +{ + private const MARKER_ORDER = 'TR-FRAME-001'; + + private const CUSTOMER_ORDER = 'TR-CO-5001'; + + private User $operator; + + public function run(): void + { + if (WorkOrder::where('order_no', self::MARKER_ORDER)->exists()) { + // The sequences came later than the rest of the demo; add them to an + // install that already has it. firstOrCreate keeps this a no-op after that. + $this->seedSequences(); + $this->command?->info('Traceability demo already present - sequences ensured, nothing else to do.'); + + return; + } + + $operator = User::orderBy('id')->first(); + if (! $operator) { + $this->command?->warn('Traceability demo needs at least one user.'); + + return; + } + $this->operator = $operator; + + DB::transaction(function () { + [$line, $stations] = $this->seedLine(); + $materials = $this->seedMaterials(); + $lots = $this->seedRawLots($materials); + + $this->seedSequences(); + $frameLot = $this->seedFrameOrder($line, $stations, $materials, $lots); + $this->seedFinishedBikeOrder($line, $stations, $materials, $lots, $frameLot); + $this->seedRunningBikeOrder($line, $stations, $lots, $frameLot); + $this->seedPendingBikeOrder($line); + }); + + $this->command?->info('Traceability demo seeded - open /admin/traceability and look for TR-.'); + } + + /** + * The two serial sequences of the demo product: a process serial the line + * works with (day of year - year - daily count) and the unit serial printed + * on the product (fixed prefix, year/ISO week/weekday, daily 4-digit count). + */ + private function seedSequences(): void + { + $bike = ProductType::firstOrCreate(['code' => 'TR-PT-BIKE'], ['name' => 'TR - City bike', 'is_active' => true]); + + LotSequence::firstOrCreate( + ['name' => 'TR - Process serial'], + ['product_type_id' => $bike->id, 'purpose' => LotSequence::PURPOSE_PROCESS_SERIAL, 'pattern' => '[doy]-[year2]-[seq]', 'prefix' => '', 'pad_size' => 1, 'reset_period' => 'daily', 'year_prefix' => false], + ); + LotSequence::firstOrCreate( + ['name' => 'TR - Unit serial'], + ['product_type_id' => $bike->id, 'purpose' => LotSequence::PURPOSE_UNIT_SERIAL, 'pattern' => '[year1][week][weekday][seq]', 'prefix' => '', 'pad_size' => 4, 'reset_period' => 'daily', 'year_prefix' => false], + ); + } + + /** @return array{0: Line, 1: array} */ + private function seedLine(): array + { + $line = Line::firstOrCreate(['code' => 'TR-LINE'], ['name' => 'TR - Bike assembly', 'is_active' => true]); + + $stations = []; + foreach (['CUT' => ['Tube cutting', 40], 'WELD' => ['Frame welding', 24], 'PAINT' => ['Paint booth', 30], 'ASSY' => ['Final assembly', 12]] as $code => [$name, $rate]) { + $stations[$code] = Workstation::firstOrCreate( + ['code' => "TR-{$code}"], + ['line_id' => $line->id, 'name' => $name, 'ideal_rate_per_hour' => $rate, 'is_active' => true], + ); + } + + return [$line, $stations]; + } + + /** @return array */ + private function seedMaterials(): array + { + $typeId = MaterialType::orderBy('id')->value('id') + ?? MaterialType::create(['code' => 'TR-RAW', 'name' => 'TR - Raw material'])->id; + + $defs = [ + 'steel' => ['TR-MAT-STEEL', 'Steel tube 32x2', 'm'], + 'paint' => ['TR-MAT-PAINT', 'Powder paint RAL 3020', 'kg'], + 'frame' => ['TR-MAT-FRAME', 'Welded bike frame', 'pcs'], + 'bike' => ['TR-MAT-BIKE', 'City bike (finished)', 'pcs'], + ]; + + $materials = []; + foreach ($defs as $key => [$code, $name, $unit]) { + $materials[$key] = Material::firstOrCreate( + ['code' => $code], + ['name' => $name, 'material_type_id' => $typeId, 'unit_of_measure' => $unit, 'tracking_type' => 'batch', 'is_active' => true], + ); + } + + return $materials; + } + + /** @return array */ + private function seedRawLots(array $materials): array + { + $defs = [ + 'steel1' => ['TR-RAW-STEEL-01', 'steel', 600, 120, 'SUP-ST-7781', 'TR-CONT-0042', MaterialLot::STATUS_RELEASED, 21], + 'steel2' => ['TR-RAW-STEEL-02', 'steel', 600, 510, 'SUP-ST-7802', 'TR-CONT-0057', MaterialLot::STATUS_RELEASED, 6], + 'paint1' => ['TR-RAW-PAINT-01', 'paint', 50, 31.5, 'SUP-PT-3020-114', 'TR-DRUM-0009', MaterialLot::STATUS_RELEASED, 14], + // Held at inbound inspection and never consumed: a lot with no forward trace. + 'steel3' => ['TR-RAW-STEEL-03', 'steel', 600, 600, 'SUP-ST-7815', 'TR-CONT-0063', MaterialLot::STATUS_QUARANTINE, 2], + ]; + + $lots = []; + foreach ($defs as $key => [$lotNo, $material, $received, $available, $supplierLot, $container, $status, $daysAgo]) { + $lots[$key] = MaterialLot::create([ + 'lot_number' => $lotNo, + 'material_id' => $materials[$material]->id, + 'quantity_received' => $received, + 'quantity_available' => $available, + 'unit_of_measure' => $materials[$material]->unit_of_measure, + 'received_at' => now()->subDays($daysAgo), + 'status' => $status, + 'supplier_lot_no' => $supplierLot, + 'supplier_reference' => 'PO-'.(4400 + $daysAgo), + 'source_container_no' => $container, + 'hold_reason' => $status === MaterialLot::STATUS_QUARANTINE ? 'Wall thickness out of tolerance on inbound sample' : null, + 'held_at' => $status === MaterialLot::STATUS_QUARANTINE ? now()->subDays($daysAgo) : null, + 'created_by_id' => $this->operator->id, + ]); + } + + return $lots; + } + + /** Semi-finished: steel -> welded frames, output as a lot the bike orders consume. */ + private function seedFrameOrder(Line $line, array $stations, array $materials, array $lots): MaterialLot + { + $start = now()->subDays(12)->setTime(6, 10); + $order = $this->order('TR-FRAME-001', $line, 'TR-PT-FRAME', 'Welded bike frame', 60, 60, WorkOrder::STATUS_DONE, null, $start->copy()->addHours(7)); + $batch = $this->batch($order, 1, 'TR-FRM-L001', 60, 60, Batch::STATUS_DONE, $start, $start->copy()->addHours(7)); + + $cut = $this->step($batch, 1, 'Cut tubes', $stations['CUT'], $start, 95, 62, 2, 10, 1.5); + $weld = $this->step($batch, 2, 'Weld frame', $stations['WELD'], $start->copy()->addMinutes(100), 310, 60, 2, 15, 5); + + $this->consume($cut, $lots['steel1'], 186, $cut->started_at); + $this->consume($weld, $lots['steel1'], 12, $weld->started_at); + + return MaterialLot::create([ + 'lot_number' => 'TR-SEMI-FRAME-01', + 'material_id' => $materials['frame']->id, + 'quantity_received' => 60, + 'quantity_available' => 8, + 'unit_of_measure' => 'pcs', + 'received_at' => $batch->completed_at, + 'manufacturing_date' => Carbon::parse($batch->completed_at)->toDateString(), + 'status' => MaterialLot::STATUS_RELEASED, + 'source_batch_id' => $batch->id, + 'created_by_id' => $this->operator->id, + ]); + } + + /** Finished and shipped: frames + paint -> bikes, pallets, serials, QC, customer order. */ + private function seedFinishedBikeOrder(Line $line, array $stations, array $materials, array $lots, MaterialLot $frameLot): void + { + $start = now()->subDays(8)->setTime(6, 5); + $end = $start->copy()->addHours(6); + $order = $this->order('TR-BIKE-001', $line, 'TR-PT-BIKE', 'City bike', 30, 30, WorkOrder::STATUS_DONE, self::CUSTOMER_ORDER, $end); + $batch = $this->batch($order, 1, 'TR-FG-L001', 30, 30, Batch::STATUS_DONE, $start, $end); + + $paint = $this->step($batch, 1, 'Powder coat', $stations['PAINT'], $start, 80, 31, 1, 10, 2); + $assy = $this->step($batch, 2, 'Final assembly', $stations['ASSY'], $start->copy()->addMinutes(90), 260, 30, 1, 10, 5); + + $this->consume($paint, $frameLot, 32, $paint->started_at); + $this->consume($paint, $lots['paint1'], 9.6, $paint->started_at); + + MaterialLot::create([ + 'lot_number' => 'TR-FG-BIKE-01', + 'material_id' => $materials['bike']->id, + 'quantity_received' => 30, + 'quantity_available' => 0, + 'unit_of_measure' => 'pcs', + 'received_at' => $end, + 'status' => MaterialLot::STATUS_RELEASED, + 'source_batch_id' => $batch->id, + 'created_by_id' => $this->operator->id, + ]); + + $shipped = Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 20, 'status' => 'shipped', + 'quality_status' => 'pass', 'location' => 'DOCK-2', 'destination' => 'Velo Nord GmbH, Hamburg', 'erp_reference' => 'TR-WZ-0871', + ]); + $shipped->forceFill(['shipped_at' => $end->copy()->addDays(2)])->saveQuietly(); + $closed = Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 10, 'status' => 'closed', + 'quality_status' => 'pass', 'location' => 'FG-A-03-01', + ]); + + $check = QualityCheck::create([ + 'batch_id' => $batch->id, 'pallet_id' => $closed->id, 'checked_by' => $this->operator->id, + 'checked_at' => $end->copy()->subMinutes(20), 'production_quantity' => 30, 'all_passed' => true, + 'notes' => 'Final inspection before packing.', + ]); + foreach ([['Coating thickness [um]', 'numeric', 82.4, null], ['Brake test', 'boolean', null, true], ['Wheel runout [mm]', 'numeric', 0.6, null]] as $i => [$name, $type, $numeric, $bool]) { + QualityCheckSample::create([ + 'quality_check_id' => $check->id, 'sample_number' => $i + 1, 'parameter_name' => $name, + 'parameter_type' => $type, 'value_numeric' => $numeric, 'value_boolean' => $bool, 'is_passed' => true, + ]); + } + + // Serial units: most pass both stations, one needed rework, one was scrapped. + foreach (range(1, 6) as $n) { + $failed = $n === 5; + $unit = SerialUnit::create([ + 'serial_no' => sprintf('TR-SN-%04d', $n), + // A demo process serial: day of year (1-based), two-digit year, daily sequence. + 'psn' => sprintf('%03d-%02d-%d', now()->dayOfYear, (int) now()->format('y'), $n), + 'work_order_id' => $order->id, + 'batch_id' => $batch->id, + 'material_id' => $materials['bike']->id, + 'status' => $failed ? 'scrapped' : ($n <= 4 ? 'shipped' : 'completed'), + 'produced_at' => $failed ? null : $end, + ]); + $at = $paint->started_at->copy()->addMinutes(10 + $n * 3); + $this->history($unit, $paint, $stations['PAINT'], $at, 'pass', ['oven_temp_c' => 190 + $n, 'cure_min' => 12]); + if ($n === 3) { + $this->history($unit, $assy, $stations['ASSY'], $at->copy()->addMinutes(95), 'rework', ['torque_nm' => 31.2], 'Crank bolt under torque - retightened.'); + } + $this->history($unit, $assy, $stations['ASSY'], $at->copy()->addMinutes(110), $failed ? 'fail' : 'pass', ['torque_nm' => $failed ? 22.5 : 40.1], $failed ? 'Cracked weld found at head tube.' : null); + } + } + + /** + * Still running, the way Transfer flow looks mid-order: both stations open + * at once, pieces waiting between them, some scrap already logged. + */ + private function seedRunningBikeOrder(Line $line, array $stations, array $lots, MaterialLot $frameLot): void + { + $start = now()->subHours(5); + $order = $this->order('TR-BIKE-002', $line, 'TR-PT-BIKE', 'City bike', 20, 9, WorkOrder::STATUS_IN_PROGRESS, self::CUSTOMER_ORDER, null); + $batch = $this->batch($order, 1, 'TR-FG-L002', 20, 9, Batch::STATUS_IN_PROGRESS, $start, null); + + $paint = $this->step($batch, 1, 'Powder coat', $stations['PAINT'], $start, null, 16, 1, 10, 2); + $assy = $this->step($batch, 2, 'Final assembly', $stations['ASSY'], $start->copy()->addMinutes(45), null, 9, 0, 10, 5); + + $this->consume($paint, $frameLot, 20, $paint->started_at); + $this->consume($paint, $lots['paint1'], 5.4, $paint->started_at); + // A second steel lot enters here (replacement brackets), so this order's + // genealogy differs from TR-BIKE-001 although the product is the same. + $this->consume($assy, $lots['steel2'], 4, $assy->started_at); + + Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 9, 'status' => 'open', + 'quality_status' => 'pending', 'location' => 'LINE-END', + ]); + } + + private function seedPendingBikeOrder(Line $line): void + { + $this->order('TR-BIKE-003', $line, 'TR-PT-BIKE', 'City bike', 40, 0, WorkOrder::STATUS_PENDING, 'TR-CO-5002', null); + } + + // ── builders ───────────────────────────────────────────────────────── + + private function order(string $no, Line $line, string $productCode, string $productName, int $planned, int $produced, string $status, ?string $customerOrder, ?Carbon $completedAt): WorkOrder + { + $product = ProductType::firstOrCreate(['code' => $productCode], ['name' => "TR - {$productName}", 'is_active' => true]); + + return WorkOrder::create([ + 'order_no' => $no, + 'line_id' => $line->id, + 'product_type_id' => $product->id, + 'customer_order_no' => $customerOrder, + 'planned_qty' => $planned, + 'produced_qty' => $produced, + 'status' => $status, + 'priority' => 3, + 'due_date' => now()->addDays(10)->toDateString(), + 'completed_at' => $completedAt, + 'description' => 'Traceability demo', + ]); + } + + private function batch(WorkOrder $order, int $number, string $lot, int $target, int $produced, string $status, Carbon $startedAt, ?Carbon $completedAt): Batch + { + return Batch::create([ + 'work_order_id' => $order->id, + 'batch_number' => $number, + 'lot_number' => $lot, + 'target_qty' => $target, + 'produced_qty' => $produced, + 'status' => $status, + 'started_at' => $startedAt, + 'completed_at' => $completedAt, + ]); + } + + /** $minutes null = still running. Times and quantities are what a station timeline will plot. */ + private function step(Batch $batch, int $number, string $name, Workstation $station, Carbon $startedAt, ?int $minutes, float $passed, float $scrap, int $setupMinutes, float $runPerUnit): BatchStep + { + $done = $minutes !== null; + + return BatchStep::create([ + 'batch_id' => $batch->id, + 'step_number' => $number, + 'name' => $name, + 'status' => $done ? BatchStep::STATUS_DONE : BatchStep::STATUS_IN_PROGRESS, + 'workstation_id' => $station->id, + 'started_at' => $startedAt, + 'started_by_id' => $this->operator->id, + 'completed_at' => $done ? $startedAt->copy()->addMinutes($minutes) : null, + 'completed_by_id' => $done ? $this->operator->id : null, + 'duration_minutes' => $minutes, + 'setup_time_minutes' => $setupMinutes, + 'run_time_per_unit_minutes' => $runPerUnit, + 'passed_qty' => $passed, + 'scrap_qty' => $scrap, + ]); + } + + private function consume(BatchStep $step, MaterialLot $lot, float $qty, $at): void + { + BatchStepLotConsumption::create([ + 'batch_step_id' => $step->id, + 'material_lot_id' => $lot->id, + 'quantity_consumed' => $qty, + 'consumed_at' => $at, + 'recorded_by_id' => $this->operator->id, + ]); + } + + private function history(SerialUnit $unit, BatchStep $step, Workstation $station, Carbon $at, string $result, array $parameters, ?string $notes = null): void + { + UnitStepHistory::create([ + 'serial_unit_id' => $unit->id, + 'batch_step_id' => $step->id, + 'workstation_id' => $station->id, + 'operator_id' => $this->operator->id, + 'parameters' => $parameters, + 'result' => $result, + 'notes' => $notes, + 'processed_at' => $at, + ]); + } +} diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index d5418b76a..c64712f18 100755 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -111,6 +111,30 @@ if [ "$IS_PRIMARY" = "1" ]; then echo "[OpenMES] Admin already exists, skipping default user creation." fi + # ── Anonymous usage reports ────────────────────────────────────────────── + # The stored setting is what decides this; the env var on its own only ever + # forces reporting off. So an install that answered "yes" in install.sh has + # to have that answer recorded here — the web wizard would have recorded it, + # and the next block is about to skip that wizard entirely. + # + # Only when OPENMES_TELEMETRY says something, and only while the application + # is not yet marked installed, so this can never overwrite a choice an + # administrator later made in Settings -> System. + if [ ! -f storage/installed ] && [ -n "${OPENMES_TELEMETRY:-}" ]; then + case "$(printf '%s' "${OPENMES_TELEMETRY}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) export TELEMETRY_CHOICE=true ;; + *) export TELEMETRY_CHOICE=false ;; + esac + echo "[OpenMES] Recording usage-report choice: ${TELEMETRY_CHOICE}" + # Read through getenv rather than interpolated, like the admin block above. + php artisan tinker --execute=" + \App\Support\TelemetrySettings::put( + \App\Support\TelemetrySettings::SETTING_KEY, + getenv('TELEMETRY_CHOICE') === 'true', + ); + " || echo "[OpenMES] Could not record the usage-report choice; reporting stays off." + fi + # ── Mark as installed (skip web installer) ─────────────────────────────── if [ ! -f storage/installed ]; then echo "[OpenMES] Marking application as installed..." diff --git a/backend/lang/en.json b/backend/lang/en.json index 9bb5d8904..c8c1781f7 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -1215,7 +1215,6 @@ "Failed to download update package. Check server internet connection.": "Failed to download update package. Check server internet connection.", "Failed to download update: :error": "Failed to download update: :error", "Failed to enter maintenance mode before update: :error": "Failed to enter maintenance mode before update: :error", - "Failed to import settings. Please check the file and try again.": "Failed to import settings. Please check the file and try again.", "Failed to load": "Failed to load", "Failed to open update package.": "Failed to open update package.", "Failed to save correction. Please try again.": "Failed to save correction. Please try again.", @@ -3344,7 +3343,7 @@ "Upload ZIP file": "Upload ZIP file", "Upload a module from a ZIP file or place the folder manually": "Upload a module from a ZIP file or place the folder manually", "Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.": "Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.", - "Upload a previously exported configuration file. This will overwrite current configuration including lines, products, templates, materials, and settings. Production data (work orders, batches, issues) is never affected. Database credentials are never imported.": "Upload a previously exported configuration file. This will overwrite current configuration including lines, products, templates, materials, and settings. Production data (work orders, batches, issues) is never affected. Database credentials are never imported.", + "Upload a configuration file: an export, or one prepared for a new plant. Lines, workstations, products, routings, materials, sequences, labels and settings are added or updated by their codes, never duplicated. A file with a production scenario also replays example orders and units on top of it. Database credentials and the production flow mode are never imported.": "Upload a configuration file: an export, or one prepared for a new plant. Lines, workstations, products, routings, materials, sequences, labels and settings are added or updated by their codes, never duplicated. A file with a production scenario also replays example orders and units on top of it. Database credentials and the production flow mode are never imported.", "Upload failed": "Upload failed", "Upstream source batch:": "Upstream source batch:", "Urgent": "Urgent", @@ -6357,9 +6356,15 @@ "Filter by step": "Filter by step", "All steps": "All steps", "No BOM components assigned to this step.": "No BOM components assigned to this step.", - "No production steps on this order": "No production steps on this order", - "A work order keeps the process configuration it was created with, so steps added to the template afterwards do not appear here. Ask a supervisor to apply a change request to this order, or to raise a new one.": "A work order keeps the process configuration it was created with, so steps added to the template afterwards do not appear here. Ask a supervisor to apply a change request to this order, or to raise a new one.", - "reserved by other batches": "reserved by other batches", + "Back to the list": "Back to the list", + "Identifier to trace": "Identifier to trace", + "Exact number: order, LOT, pallet, serial, supplier LOT, container…": "Exact number: order, LOT, pallet, serial, supplier LOT, container…", + "What to trace": "What to trace", + "Nothing traceable matches": "Nothing traceable matches", + "Finished-goods lot": "Finished-goods lot", + "No production steps recorded.": "No production steps recorded.", + "Origin": "Origin", + "Process serial (PSN)": "Process serial (PSN)", "Weekly view": "Weekly view", "Click a day to open its schedule": "Click a day to open its schedule", "No activities recorded for this day.": "No activities recorded for this day.", @@ -6367,6 +6372,487 @@ "Work order :order updated.": "Work order :order updated.", "Work order :order deleted.": "Work order :order deleted.", "Work order :order completed with :quantity produced.": "Work order :order completed with :quantity produced.", + "Stock receipt": "Stock receipt", + "Stock return": "Stock return", + "Material allocation": "Material allocation", + "Consumption": "Consumption", + "Stock adjustment": "Stock adjustment", + "Stock transfer": "Stock transfer", + "Reclassification": "Reclassification", + "Manual adjustment": "Manual adjustment", + "Stock document": "Stock document", + "ERP sync": "ERP sync", + "Allocated to batch #:batch": "Allocated to batch #:batch", + "Allocated to batch #:batch (step :step)": "Allocated to batch #:batch (step :step)", + "Batch #:batch completed — leftover returned to stock": "Batch #:batch completed — leftover returned to stock", + "Batch #:batch scrap qty recorded": "Batch #:batch scrap qty recorded", + "Batch #:batch cancelled — return to stock": "Batch #:batch cancelled — return to stock", + "Adjustment on batch #:batch": "Adjustment on batch #:batch", + "Batch #:batch — unused material returned to stock": "Batch #:batch — unused material returned to stock", + "Unit completed": "Unit completed", + "SN labels": "SN labels", + "SN Label Station": "SN Label Station", + "Apply the pre-printed serial number label and bind it to the process serial number (PSN).": "Apply the pre-printed serial number label and bind it to the process serial number (PSN).", + "Process Serial Number (PSN)": "Process Serial Number (PSN)", + "Serial Number (label)": "Serial Number (label)", + "Apply label": "Apply label", + "Unit registered": "Unit registered", + "Already registered": "Already registered", + "Could not apply label.": "Could not apply label.", + "Network error, try again.": "Network error, try again.", + "Scan or type the serial number first.": "Scan or type the serial number first.", + "Recent units (last 50)": "Recent units (last 50)", + "Serial No": "Serial No", + "Applied At": "Applied At", + "Carton label (scan PSN)": "Carton label (scan PSN)", + "Print carton label (SN)": "Print carton label (SN)", + "Scan the process serial number label. The printed label carries the unit serial number.": "Scan the process serial number label. The printed label carries the unit serial number.", + "Serial Number": "Serial Number", + "A process serial number is required.": "A process serial number is required.", + "A unit cannot be registered at a station without its process serial number.": "A unit cannot be registered at a station without its process serial number.", + "A unit is blocked after this many failed test verdicts. 0 never blocks.": "A unit is blocked after this many failed test verdicts. 0 never blocks.", + "Failed tests before a unit is blocked": "Failed tests before a unit is blocked", + "How serialised units are identified: the serial number on the product, the process serial number the line works with, and what a failed test does to a unit.": "How serialised units are identified: the serial number on the product, the process serial number the line works with, and what a failed test does to a unit.", + "Mark a unit scrapped on its first failed verdict instead of counting attempts.": "Mark a unit scrapped on its first failed verdict instead of counting attempts.", + "Normalise scanned identifiers": "Normalise scanned identifiers", + "Process serial :psn already belongs to unit :sn.": "Process serial :psn already belongs to unit :sn.", + "Process serial identifies one unit": "Process serial identifies one unit", + "Process serial pattern": "Process serial pattern", + "Process serial re-bound.": "Process serial re-bound.", + "Process serial required": "Process serial required", + "Re-bind": "Re-bind", + "Re-bind process serial": "Re-bind process serial", + "Refuse binding a process serial number that already belongs to another unit. Turn off when one process serial covers several units.": "Refuse binding a process serial number that already belongs to another unit. Turn off when one process serial covers several units.", + "Regular expression a process serial number must match. Leave empty to accept any.": "Regular expression a process serial number must match. Leave empty to accept any.", + "Regular expression a unit serial number must match. Leave empty to accept any.": "Regular expression a unit serial number must match. Leave empty to accept any.", + "Scan the process serial…": "Scan the process serial…", + "Scan the serial number label…": "Scan the serial number label…", + "Scrap on first failed test": "Scrap on first failed test", + "Serial number pattern": "Serial number pattern", + "Strip whitespace and uppercase before storing or matching, so a label printed with spaces and one read by a scanner are the same identifier.": "Strip whitespace and uppercase before storing or matching, so a label printed with spaces and one read by a scanner are the same identifier.", + "The :attribute is not a valid regular expression.": "The :attribute is not a valid regular expression.", + "The process serial number does not match the configured format.": "The process serial number does not match the configured format.", + "The serial number does not match the configured format.": "The serial number does not match the configured format.", + "Unit :sn found - print the carton label.": "Unit :sn found - print the carton label.", + "Unit :sn is :status and can no longer be bound.": "Unit :sn is :status and can no longer be bound.", + "Unit :sn is :status and cannot be packed.": "Unit :sn is :status and cannot be packed.", + "Unit :sn is already bound to process serial :psn.": "Unit :sn is already bound to process serial :psn.", + "Unit already registered, label binding confirmed.": "Unit already registered, label binding confirmed.", + "Unit registered, process serial bound to serial number.": "Unit registered, process serial bound to serial number.", + "Unit serialisation": "Unit serialisation", + "Unknown process serial number": "Unknown process serial number", + "e.g. ^[0-9]{3}-[0-9]{2}-[0-9]+$": "e.g. ^[0-9]{3}-[0-9]{2}-[0-9]+$", + "e.g. ^SN-[0-9]{4}-[0-9]{4}$": "e.g. ^SN-[0-9]{4}-[0-9]{4}$", + "Serial Unit (SN)": "Serial Unit (SN)", + "Test verdict FAIL": "Test verdict FAIL", + "Test verdict REWORK": "Test verdict REWORK", + "Test verdict PASS": "Test verdict PASS", + ":count components": ":count components", + "A unit cannot be a component of itself.": "A unit cannot be a component of itself.", + "Bind component": "Bind component", + "Bound": "Bound", + "Component :id bound to unit :sn.": "Component :id bound to unit :sn.", + "Component :id is already installed in unit :sn.": "Component :id is already installed in unit :sn.", + "Component bound": "Component bound", + "Component identifier": "Component identifier", + "Component material": "Component material", + "Could not bind the component.": "Could not bind the component.", + "From the identifier": "From the identifier", + "Identifier": "Identifier", + "Installed components": "Installed components", + "Installed in": "Installed in", + "No components were scanned into this unit.": "No components were scanned into this unit.", + "Removed": "Removed", + "Resolved as": "Resolved as", + "Scan the component identifier.": "Scan the component identifier.", + "Scan the component…": "Scan the component…", + "Scan the unit…": "Scan the unit…", + "Serial label": "Serial label", + "Serialised unit": "Serialised unit", + "Station mode": "Station mode", + "This component has not been scanned into any unit.": "This component has not been scanned into any unit.", + "Unit serial number": "Unit serial number", + "Unknown unit :sn - apply its label first.": "Unknown unit :sn - apply its label first.", + "A product may have one sequence of each kind; the station issues process and unit serials from them.": "A product may have one sequence of each kind; the station issues process and unit serials from them.", + "Batch LOT numbers": "Batch LOT numbers", + "Could not issue a number.": "Could not issue a number.", + "Issued :id": "Issued :id", + "Next number from the product's process serial sequence": "Next number from the product's process serial sequence", + "Next number from the product's unit serial sequence": "Next number from the product's unit serial sequence", + "No process serial sequence is configured for this product. Add one under LOT sequences.": "No process serial sequence is configured for this product. Add one under LOT sequences.", + "No unit serial sequence is configured for this product. Add one under LOT sequences.": "No unit serial sequence is configured for this product. Add one under LOT sequences.", + "Numbers": "Numbers", + "Process serial numbers (PSN)": "Process serial numbers (PSN)", + "Unit serial numbers": "Unit serial numbers", + "Counter, zero-padded to the pad size": "Counter, zero-padded to the pad size", + "Year, 4 digits": "Year, 4 digits", + "Year, 2 digits": "Year, 2 digits", + "Year, last digit": "Year, last digit", + "Month, 2 digits": "Month, 2 digits", + "Day of month, 2 digits": "Day of month, 2 digits", + "Day of year, 3 digits": "Day of year, 3 digits", + "ISO week, 2 digits": "ISO week, 2 digits", + "Weekday, 1 = Monday … 7 = Sunday": "Weekday, 1 = Monday … 7 = Sunday", + "Hour, 24h": "Hour, 24h", + "Date as YYYYMMDD": "Date as YYYYMMDD", + "e.g. :code": "e.g. :code", + "Anything outside the brackets is printed as typed.": "Anything outside the brackets is printed as typed.", + "Unknown tokens: :tokens. Allowed: :allowed.": "Unknown tokens: :tokens. Allowed: :allowed.", + "Pattern must contain exactly one [seq] token.": "Pattern must contain exactly one [seq] token.", + "Token [:token] does not accept a format argument.": "Token [:token] does not accept a format argument.", + "Pattern contains unmatched or malformed brackets.": "Pattern contains unmatched or malformed brackets.", + "The :attribute must be a string.": "The :attribute must be a string.", + "After a failed test": "After a failed test", + "Block the unit": "Block the unit", + "Hold it after the number of failed tests below. A later passed test releases it.": "Hold it after the number of failed tests below. A later passed test releases it.", + "Scrap it": "Scrap it", + "The first failed test scraps the unit. Final.": "The first failed test scraps the unit. Final.", + "Only record the result": "Only record the result", + "The verdict is kept in the history; the unit is not held.": "The verdict is kept in the history; the unit is not held.", + "Failed tests before the unit is blocked": "Failed tests before the unit is blocked", + "1 = the first failed test blocks the unit.": "1 = the first failed test blocks the unit.", + "Counter digits": "Counter digits", + "How many digits [seq] is padded to: 4 gives 0001, 0002…; 1 gives 1, 2… with no leading zeros.": "How many digits [seq] is padded to: 4 gives 0001, 0002…; 1 gives 1, 2… with no leading zeros.", + "Counter starts over": "Counter starts over", + "When [seq] goes back to 1. Daily: the first number each day is 1 again, so a pattern with the date in it stays unique.": "When [seq] goes back to 1. Daily: the first number each day is 1 again, so a pattern with the date in it stays unique.", + "LOT numbers go onto batches when they start or are released. Process and unit serials are issued at the SN Label Station for single units. A product may have one sequence of each kind.": "LOT numbers go onto batches when they start or are released. Process and unit serials are issued at the SN Label Station for single units. A product may have one sequence of each kind.", + "Serial units": "Serial units", + "No serial units yet.": "No serial units yet.", + "Registered": "Registered", + "This product already has a sequence of this kind. Edit that one, or pick another kind under \"Numbers\".": "This product already has a sequence of this kind. Edit that one, or pick another kind under \"Numbers\".", + "Issue number": "Issue number", + "process serial": "process serial", + "unit serial": "unit serial", + "Product :product has no :kind sequence. Add one under LOT sequences (\"Numbers\").": "Product :product has no :kind sequence. Add one under LOT sequences (\"Numbers\").", + "Select a work order first - the :kind sequence belongs to its product. Or add a sequence without a product type to serve every order.": "Select a work order first - the :kind sequence belongs to its product. Or add a sequence without a product type to serve every order.", + "Order :order has no product type, so no :kind sequence applies. Give the order a product, or add a sequence without a product type to serve every order.": "Order :order has no product type, so no :kind sequence applies. Give the order a product, or add a sequence without a product type to serve every order.", + "Unit :sn scrapped.": "Unit :sn scrapped.", + "Scrap unit :sn": "Scrap unit :sn", + "Scrap unit": "Scrap unit", + "Unit label": "Unit label", + "Carton label": "Carton label", + "ZPL for a label printer": "ZPL for a label printer", + "Close carton & print": "Close carton & print", + "Unit :sn is already in carton :no.": "Unit :sn is already in carton :no.", + "Put on pallet :pallet": "Put on pallet :pallet", + "Carton": "Carton", + "Pack unit": "Pack unit", + "Pallet :no is not open.": "Pallet :no is not open.", + "Serial units on the pallet": "Serial units on the pallet", + "Serial units - carton": "Serial units - carton", + "Carton :no is empty.": "Carton :no is empty.", + "No carton - units go straight to the pallet": "No carton - units go straight to the pallet", + "Carton :no put on pallet :pallet": "Carton :no put on pallet :pallet", + "Carton :no is already on pallet :pallet.": "Carton :no is already on pallet :pallet.", + "Carton :no is closed.": "Carton :no is closed.", + "Open carton": "Open carton", + "Pack into :carton": "Pack into :carton", + "Carton :no closed with :count units": "Carton :no closed with :count units", + "Carton number": "Carton number", + "Carton :no opened": "Carton :no opened", + "Scan the process serial number label. The unit is packed into the open carton, or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.": "Scan the process serial number label. The unit is packed into the open carton, or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.", + "New carton": "New carton", + "Carton (units list)": "Carton (units list)", + "Units list": "Units list", + "Packing": "Packing", + "Packing station": "Packing station", + "Fill or close the open carton first.": "Fill or close the open carton first.", + "Pallet label": "Pallet label", + ":count units": ":count units", + "Put down": "Put down", + "Leave the pallet open for another station": "Leave the pallet open for another station", + "Being filled by :name": "Being filled by :name", + "Take over": "Take over", + "Label applied": "Label applied", + "Process serial re-bound": "Process serial re-bound", + "Component :id bound": "Component :id bound", + "Component :id removed": "Component :id removed", + "Packed into :carton": "Packed into :carton", + "Shipped on pallet :pallet": "Shipped on pallet :pallet", + "Test at :station": "Test at :station", + "Step kind": "Step kind", + "Done at the packing station: units are scanned into cartons and onto pallets here.": "Done at the packing station: units are scanned into cartons and onto pallets here.", + "Done at a production workstation.": "Done at a production workstation.", + "Packs into": "Packs into", + "Single unit (label only)": "Single unit (label only)", + "Carton of units": "Carton of units", + "Pallet (cartons or units)": "Pallet (cartons or units)", + "Units per carton": "Units per carton", + "closes the carton when reached": "closes the carton when reached", + "Cartons per pallet": "Cartons per pallet", + "Label template": "Label template", + "Default for the label type": "Default for the label type", + "packing": "packing", + "My carton :carton": "My carton :carton", + "My pallet :pallet": "My pallet :pallet", + "Pack only (no carton, no pallet)": "Pack only (no carton, no pallet)", + "Packing steps": "Packing steps", + "Packs onto pallets": "Packs onto pallets", + "Labels single units": "Labels single units", + "Packs into cartons": "Packs into cartons", + ":n per carton": ":n per carton", + ":n cartons per pallet": ":n cartons per pallet", + "Pack into": "Pack into", + "Pallet :no is full and was closed": "Pallet :no is full and was closed", + "Printed for every unit scanned at the packing station.": "Printed for every unit scanned at the packing station.", + "Printed when a carton closes.": "Printed when a carton closes.", + "Printed when a pallet closes.": "Printed when a pallet closes.", + "Finished Goods": "Finished Goods", + "Workstation Step": "Workstation Step", + "100 × 50 mm (standard)": "100 × 50 mm (standard)", + "80 × 40 mm (small)": "80 × 40 mm (small)", + "62 × 29 mm (Brother DK)": "62 × 29 mm (Brother DK)", + "100 × 100 mm (square)": "100 × 100 mm (square)", + "150 × 100 mm (large)": "150 × 100 mm (large)", + "CODE 128": "CODE 128", + "CODE 39": "CODE 39", + "EAN-13": "EAN-13", + "Work order number": "Work order number", + "Serial number (SN)": "Serial number (SN)", + "Logo": "Logo", + "Production date": "Production date", + "Unit :sn belongs to another work order than :no.": "Unit :sn belongs to another work order than :no.", + "Unit :sn is already on pallet :no.": "Unit :sn is already on pallet :no.", + "The request holds no test-run records.": "The request holds no test-run records.", + "Test log not recognised: :reason": "Test log not recognised: :reason", + "The records do not hold a complete test run.": "The records do not hold a complete test run.", + "Test run already recorded": "Test run already recorded", + "Test run recorded": "Test run recorded", + "Component :id removed from unit :sn.": "Component :id removed from unit :sn.", + ":count numbers issued for :order - print the labels.": ":count numbers issued for :order - print the labels.", + "No units given.": "No units given.", + "Packing list": "Packing list", + "Generated :at": "Generated :at", + "cartons": "cartons", + "scrapped (not shipped)": "scrapped (not shipped)", + "pallet count": "pallet count", + "Serial number": "Serial number", + "Empty carton": "Empty carton", + "Units packed loose on the pallet": "Units packed loose on the pallet", + "No serialised units are recorded on this pallet.": "No serialised units are recorded on this pallet.", + "Packed by": "Packed by", + "Received by": "Received by", + "Remove component :id": "Remove component :id", + "Issue batch…": "Issue batch…", + "Issue a batch of numbers": "Issue a batch of numbers", + "How many units?": "How many units?", + "Issue and print": "Issue and print", + "Select a work order first.": "Select a work order first.", + "Unit labels (:count)": "Unit labels (:count)", + "Numbers issued": "Numbers issued", + "Every carton and serial number on the pallet, as a document": "Every carton and serial number on the pallet, as a document", + "Unit in production": "Unit in production", + "Unit shipped": "Unit shipped", + "Unit scrapped": "Unit scrapped", + "Unit blocked": "Unit blocked", + "Numbers issued ahead of the line: the labels can be printed now and are applied by scanning them.": "Numbers issued ahead of the line: the labels can be printed now and are applied by scanning them.", + "Unknown process serial or serial number": "Unknown process serial or serial number", + "Serial :sn already exists - check the sequence before issuing more numbers.": "Serial :sn already exists - check the sequence before issuing more numbers.", + "Unit :sn is already inside :id - it cannot also contain it.": "Unit :sn is already inside :id - it cannot also contain it.", + "Serialised units built with this lot": "Serialised units built with this lot", + "removed": "removed", + "Sample product": "Sample product", + "Assembly": "Assembly", + "Sample data; the label updates as you change the fields above.": "Sample data; the label updates as you change the fields above.", + ":count more": ":count more", + "Pick the packaging lots first (:materials), then scan.": "Pick the packaging lots first (:materials), then scan.", + "Packing step :name is not ready to start.": "Packing step :name is not ready to start.", + "Packing step :name started - packaging reserved.": "Packing step :name started - packaging reserved.", + "Quantity per": "Quantity per", + "per unit": "per unit", + "per carton": "per carton", + "per pallet": "per pallet", + "How much of this packaging one carton takes (the packing step's carton size converts it per unit).": "How much of this packaging one carton takes (the packing step's carton size converts it per unit).", + "How much of this packaging one pallet takes (the packing step's capacities convert it per unit).": "How much of this packaging one pallet takes (the packing step's capacities convert it per unit).", + "Packaging (BOM)": "Packaging (BOM)", + "Pick packaging": "Pick packaging", + "Pick the lots": "Pick the lots", + "reserved": "reserved", + "used": "used", + "to pick": "to pick", + "lot": "lot", + "avail.": "avail.", + "Start packing": "Start packing", + "Choose a lot for every material.": "Choose a lot for every material.", + "No lot with enough stock - receive the packaging first.": "No lot with enough stock - receive the packaging first.", + "pending": "pending", + "Lot tracking of materials": "Lot tracking of materials", + "When enabled, every reservation names the material lots it comes from: picked by the strategy below, or by the operator at the step start and at the packing station. Off, stock moves without lots.": "When enabled, every reservation names the material lots it comes from: picked by the strategy below, or by the operator at the step start and at the packing station. Off, stock moves without lots.", + "Lot picking strategy": "Lot picking strategy", + "FEFO - first expiring first": "FEFO - first expiring first", + "FIFO - oldest received first": "FIFO - oldest received first", + "LIFO - newest received first": "LIFO - newest received first", + "Manual - the operator always chooses": "Manual - the operator always chooses", + "The proposed lot when a step or the packing station starts; the operator can still pick another.": "The proposed lot when a step or the packing station starts; the operator can still pick another.", + "Default for its type": "Default for its type", + "Not default": "Not default", + "Printed when nothing names a template of this type": "Printed when nothing names a template of this type", + "Filter: :station": "Filter: :station", + "Click a step to show only its station and jump to it; click it again to show everything.": "Click a step to show only its station and jump to it; click it again to show everything.", + "Show routing": "Show routing", + "Hide routing": "Hide routing", + "Click a step to open the order on it.": "Click a step to open the order on it.", + "Tap to cycle status": "Tap to cycle status", + "No matches": "No matches", + "waited :time": "waited :time", + "Lead time :time (first record to shipping)": "Lead time :time (first record to shipping)", + "Lead time :time so far": "Lead time :time so far", + "took :time": "took :time", + "Note (optional)": "Note (optional)", + "Carton :no belongs to another order than pallet :pallet.": "Carton :no belongs to another order than pallet :pallet.", + "Operator screens": "Operator screens", + "The tabs an operator at this workstation sees. Supervisors, admins and the whole-line view always see every tab.": "The tabs an operator at this workstation sees. Supervisors, admins and the whole-line view always see every tab.", + "Automatic (from the steps assigned to this workstation)": "Automatic (from the steps assigned to this workstation)", + "Shown automatically: :screens": "Shown automatically: :screens", + "Print a label for each unit when it is scanned": "Print a label for each unit when it is scanned", + "Off: the label prints only for a unit that has no label yet.": "Off: the label prints only for a unit that has no label yet.", + "Could not start the unit.": "Could not start the unit.", + "Issue a batch of process serials": "Issue a batch of process serials", + "No serial number yet": "No serial number yet", + "Unit number (SN or PSN)": "Unit number (SN or PSN)", + "First station: the unit starts on its process serial (the one in the field, or the next from the sequence) and its PSN label prints; the serial number follows with the product label.": "First station: the unit starts on its process serial (the one in the field, or the next from the sequence) and its PSN label prints; the serial number follows with the product label.", + "Start unit on PSN": "Start unit on PSN", + "Issue PSN batch…": "Issue PSN batch…", + "Started on process serial :psn": "Started on process serial :psn", + "Scan or issue the process serial first.": "Scan or issue the process serial first.", + "Process serial :psn belongs to a unit still waiting for its serial number, and :sn is already another unit.": "Process serial :psn belongs to a unit still waiting for its serial number, and :sn is already another unit.", + "Serial number :sn assigned to unit :psn.": "Serial number :sn assigned to unit :psn.", + "Unknown unit :sn - start it or apply its label first.": "Unknown unit :sn - start it or apply its label first.", + "Unit :psn started - print its label.": "Unit :psn started - print its label.", + "Unit :sn is already blocked.": "Unit :sn is already blocked.", + "Unit :sn is not blocked.": "Unit :sn is not blocked.", + "Unit :sn blocked.": "Unit :sn blocked.", + "Unit :sn released.": "Unit :sn released.", + "Choose the error code.": "Choose the error code.", + "Release unit :sn": "Release unit :sn", + "Failed tests": "Failed tests", + "Block unit :sn": "Block unit :sn", + "A held unit is not packed and fails its pallet’s quality gate until a supervisor releases it.": "A held unit is not packed and fails its pallet’s quality gate until a supervisor releases it.", + "Error code": "Error code", + "Choose…": "Choose…", + "What was found?": "What was found?", + "Blocked: :code :reason": "Blocked: :code :reason", + "Block unit": "Block unit", + "Release unit": "Release unit", + "Unit released": "Unit released", + "Weigh unit :sn: :expected g ± :tolerance g.": "Weigh unit :sn: :expected g ± :tolerance g.", + "Weight :weight g is outside :expected g ± :tolerance g - check unit :sn.": "Weight :weight g is outside :expected g ± :tolerance g - check unit :sn.", + "Weight (g) · :psn": "Weight (g) · :psn", + ":expected g ± :tolerance g": ":expected g ± :tolerance g", + "weight :expected g ± :tolerance g": "weight :expected g ± :tolerance g", + "Expected weight (g)": "Expected weight (g)", + "empty = no weight check": "empty = no weight check", + "Weight tolerance (± g)": "Weight tolerance (± g)", + "Weight :weight g outside :expected ± :tolerance g": "Weight :weight g outside :expected ± :tolerance g", + "Batch created": "Batch created", + "Batch created (LOT: :lot)": "Batch created (LOT: :lot)", + "This production scenario needs the \":mode\" production flow mode. Change it under Settings → Production and import again.": "This production scenario needs the \":mode\" production flow mode. Change it under Settings → Production and import again.", + "user :n": "user :n", + "material lot :n": "material lot :n", + "event :n (:type)": "event :n (:type)", + "Unknown event type \":type\".": "Unknown event type \":type\".", + "The time :time is after the import - scenario events must lie in the past.": "The time :time is after the import - scenario events must lie in the past.", + "The time :time is earlier than the previous event - list events in time order.": "The time :time is earlier than the previous event - list events in time order.", + "Unreadable time \":time\".": "Unreadable time \":time\".", + "Unknown user \":name\" - list the account under scenario.users.": "Unknown user \":name\" - list the account under scenario.users.", + "Unknown work order \":no\".": "Unknown work order \":no\".", + "Unknown workstation \":code\".": "Unknown workstation \":code\".", + "Work order :no has no batch :n.": "Work order :no has no batch :n.", + "Batch :n of :no has no step :step.": "Batch :n of :no has no step :step.", + "Unknown unit \":ref\".": "Unknown unit \":ref\".", + "Unknown pallet \":ref\".": "Unknown pallet \":ref\".", + "Unknown error code \":code\".": "Unknown error code \":code\".", + "An account needs a username.": "An account needs a username.", + "Unknown role \":role\".": "Unknown role \":role\".", + "Unknown material \":code\".": "Unknown material \":code\".", + "Work order \":no\" is missing a number or already exists.": "Work order \":no\" is missing a number or already exists.", + "Unknown product \":code\".": "Unknown product \":code\".", + "Unknown line \":code\".": "Unknown line \":code\".", + "Unit :ref has no serial number yet - a test result is filed under the SN.": "Unit :ref has no serial number yet - a test result is filed under the SN.", + "The packing step weighs every unit - give weight_g.": "The packing step weighs every unit - give weight_g.", + "Work order :no has no open carton.": "Work order :no has no open carton.", + "Imported :count configuration items and replayed :events production events.": "Imported :count configuration items and replayed :events production events.", + "The production scenario was not imported - nothing from the file was saved. :message": "The production scenario was not imported - nothing from the file was saved. :message", + "Which failed tests count": "Which failed tests count", + "Per test, since its last pass": "Per test, since its last pass", + "Failed tests at the same test station after it last passed there. A retest that passes starts the count over.": "Failed tests at the same test station after it last passed there. A retest that passes starts the count over.", + "All failed tests of the unit": "All failed tests of the unit", + "Every failed test at any station counts, and a pass does not reset them.": "Every failed test at any station counts, and a pass does not reset them.", + "Scan the serial number of the sub-assembly.": "Scan the serial number of the sub-assembly.", + "Serial :sn is already registered (:material, order :order).": "Serial :sn is already registered (:material, order :order).", + "Order :order does not make :material.": "Order :order does not make :material.", + "Sub-assembly :sn registered as :material.": "Sub-assembly :sn registered as :material.", + "Choose the work order that makes the sub-assembly.": "Choose the work order that makes the sub-assembly.", + "Choose what was made.": "Choose what was made.", + "Could not register the sub-assembly.": "Could not register the sub-assembly.", + "Sub-assemblies": "Sub-assemblies", + "This order makes no serial-tracked sub-assembly.": "This order makes no serial-tracked sub-assembly.", + "Choose the work order of the sub-assembly station above.": "Choose the work order of the sub-assembly station above.", + "What was made": "What was made", + "Serial number of the sub-assembly": "Serial number of the sub-assembly", + "Scan the sub-assembly…": "Scan the sub-assembly…", + "Register sub-assembly": "Register sub-assembly", + "Sub-assembly registered: :material": "Sub-assembly registered: :material", + "Pallet :pallet received from work order :order": "Pallet :pallet received from work order :order", + "Pallet :pallet shipped": "Pallet :pallet shipped", + "Finished goods by pallet": "Finished goods by pallet", + "A closed pallet is received into the finished-goods warehouse and a shipped one is issued from it, instead of one receipt when the work order completes.": "A closed pallet is received into the finished-goods warehouse and a shipped one is issued from it, instead of one receipt when the work order completes.", + "The work order receipt when it completes, as before.": "The work order receipt when it completes, as before.", + "Drafts": "Drafts", + "Documents per pallet, posted by the warehouse.": "Documents per pallet, posted by the warehouse.", + "Documents per pallet, posted at once: stock follows the pallets.": "Documents per pallet, posted at once: stock follows the pallets.", + "IQC: released": "IQC: released", + "IQC: quarantine": "IQC: quarantine", + "IQC: rejected": "IQC: rejected", + "IQC: :status": "IQC: :status", + "Supplier lot: :lot": "Supplier lot: :lot", + "Sample material": "Sample material", + "IQC label": "IQC label", + "Material lot (IQC)": "Material lot (IQC)", + "Inspection status": "Inspection status", + "missing :qty :unit": "missing :qty :unit", + "Production hold: stock does not cover order :order. Missing: :items.": "Production hold: stock does not cover order :order. Missing: :items.", + "Hold the batch until stock covers the order": "Hold the batch until stock covers the order", + "A batch does not start while stock does not cover its whole work order; the operator sees what is missing, the planner shows it in advance. Steps are blocked as above too.": "A batch does not start while stock does not cover its whole work order; the operator sees what is missing, the planner shows it in advance. Steps are blocked as above too.", + "A workstation account needs its workstation.": "A workstation account needs its workstation.", + "PSN or serial number": "PSN or serial number", + "Scan the PSN or the serial number…": "Scan the PSN or the serial number…", + "PSN": "PSN", + "Last event": "Last event", + "Carton / pallet": "Carton / pallet", + ":shown of :total": ":shown of :total", + "Units in this order": "Units in this order", + "the first bench: the unit starts and its PSN label prints": "the first bench: the unit starts and its PSN label prints", + "Bind the serial label to the PSN": "Bind the serial label to the PSN", + "scan the PSN, then the ready serial label": "scan the PSN, then the ready serial label", + "Issue numbers": "Issue numbers", + "next numbers from the sequences, batches of labels": "next numbers from the sequences, batches of labels", + "scan parts and sub-assemblies into a unit": "scan parts and sub-assemblies into a unit", + "register a sub-assembly made here by its serial": "register a sub-assembly made here by its serial", + "SN label station": "SN label station", + "What the SN label station offers an operator at this workstation. Nothing ticked: everything. Supervisors, admins and the whole-line view always see everything.": "What the SN label station offers an operator at this workstation. Nothing ticked: everything. Supervisors, admins and the whole-line view always see everything.", + "New units": "New units", + "Each unit starts here on its process serial; its PSN label prints for the housing.": "Each unit starts here on its process serial; its PSN label prints for the housing.", + "Scan the PSN or the serial number. The unit is packed into the open carton of its order (a unit of another order gets its own), or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.": "Scan the PSN or the serial number. The unit is packed into the open carton of its order (a unit of another order gets its own), or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.", + "Unit :sn is already packed - nothing changed.": "Unit :sn is already packed - nothing changed.", + ":sn is already packed - nothing changed": ":sn is already packed - nothing changed", + "packed at :time": "packed at :time", + "Unit :sn is blocked - it cannot move on until it passes a retest or a supervisor releases it.": "Unit :sn is blocked - it cannot move on until it passes a retest or a supervisor releases it.", + "Blocked after :count failed tests": "Blocked after :count failed tests", + "Released by a passing retest": "Released by a passing retest", + "carton": "carton", + "pallet": "pallet", + "Carton :no is full (:count units).": "Carton :no is full (:count units).", + "Pallet :no belongs to another work order.": "Pallet :no belongs to another work order.", + "The test run names no serial number.": "The test run names no serial number.", + "Component :id is :status and cannot be installed.": "Component :id is :status and cannot be installed.", + "A scenario file cannot create administrator accounts.": "A scenario file cannot create administrator accounts.", + "This action is not available at this workstation.": "This action is not available at this workstation.", + "Could not take the pallet": "Could not take the pallet", + "Could not close the pallet": "Could not close the pallet", + "No production steps on this order": "No production steps on this order", + "A work order keeps the process configuration it was created with, so steps added to the template afterwards do not appear here. Ask a supervisor to apply a change request to this order, or to raise a new one.": "A work order keeps the process configuration it was created with, so steps added to the template afterwards do not appear here. Ask a supervisor to apply a change request to this order, or to raise a new one.", + "reserved by other batches": "reserved by other batches", "Record at least one measurement before completing this inspection.": "Record at least one measurement before completing this inspection.", "This inspection has already been completed (:status).": "This inspection has already been completed (:status).", "Usage reporting": "Usage reporting", @@ -6384,5 +6870,11 @@ "This stop has no linked downtime record, so it cannot be classified or escalated.": "This stop has no linked downtime record, so it cannot be classified or escalated.", "Stop with no record": "Stop with no record", "NO RECORD": "NO RECORD", - "Sample data is already being loaded. Please wait for it to finish.": "Sample data is already being loaded. Please wait for it to finish." + "Sample data is already being loaded. Please wait for it to finish.": "Sample data is already being loaded. Please wait for it to finish.", + "Additional fields": "Additional fields", + "Enter EAN code": "Enter EAN code", + "Module \":name\" requires OpenMES :required; this installation is :current.": "Module \":name\" requires OpenMES :required; this installation is :current.", + "Module \":name\" could not be uninstalled: :msg": "Module \":name\" could not be uninstalled: :msg", + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.", + "Failed to import settings - nothing from the file was saved. :message": "Failed to import settings - nothing from the file was saved. :message" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index f806b10b4..9985b3b63 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -840,7 +840,7 @@ "Open detail": "Otwórz szczegóły", "grid": "siatka", "REASON": "PRZYCZYNA", - "Hourly": "Godzinowy", + "Hourly": "Co godzinę", "Reporting currency for cost totals (3-letter code).": "Waluta raportowania sum kosztów (kod 3-literowy).", "No active alerts at this time.": "Brak aktywnych alertów.", "Break Windows": "Okna przerw", @@ -1188,7 +1188,6 @@ "Members": "Członkowie", "Meeting": "Spotkanie", "no WO": "brak zlecenia", - "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie.", "No results": "Brak wyników", "Quantity per Unit": "Ilość na jednostkę", "system is configured for": "system jest skonfigurowany na", @@ -2554,7 +2553,7 @@ "Dispatch now": "Wyślij teraz", "View full history": "Zobacz pełną historię", "Sort order": "Kolejność", - "Upload a previously exported configuration file. This will overwrite current configuration including lines, products, templates, materials, and settings. Production data (work orders, batches, issues) is never affected. Database credentials are never imported.": "Wgraj wcześniej wyeksportowany plik konfiguracji. Nadpisze bieżącą konfigurację w tym linie, produkty, szablony, materiały i ustawienia. Dane produkcyjne (zlecenia, partie, zgłoszenia) nie są zmieniane. Dane dostępowe do bazy nigdy nie są importowane.", + "Upload a configuration file: an export, or one prepared for a new plant. Lines, workstations, products, routings, materials, sequences, labels and settings are added or updated by their codes, never duplicated. A file with a production scenario also replays example orders and units on top of it. Database credentials and the production flow mode are never imported.": "Wgraj plik konfiguracji: eksport albo plik przygotowany dla nowego zakładu. Linie, stanowiska, produkty, marszruty, materiały, sekwencje, etykiety i ustawienia są dodawane lub aktualizowane po kodach, bez duplikatów. Plik ze scenariuszem produkcji odtwarza na tej konfiguracji także przykładowe zlecenia i sztuki. Dane dostępowe do bazy i tryb przepływu produkcji nigdy nie są importowane.", "Global kanban statuses available on all production lines. You can also add line-specific statuses from the line detail page.": "Globalne statusy kanban dostępne na wszystkich liniach produkcyjnych. Statusy specyficzne dla linii dodasz na stronie szczegółów linii.", "Maintenance schedule updated successfully.": "Pomyślnie zaktualizowano harmonogram konserwacji.", "Delete this workstation type?": "Usunąć ten typ stanowiska?", @@ -3817,7 +3816,7 @@ "Pack & Ship": "Pakowanie i wysyłka", "Pack {{units}} units of {{each}} each = {{pieces}} pieces": "Spakuj {{units}} jednostek po {{each}} = {{pieces}} sztuk", "Packaging EANs": "EAN-y opakowań", - "Pad size": "Rozmiar wkładki", + "Pad size": "Liczba cyfr licznika", "Perf": "Wyd.", "Pick a crew": "Wybierz brygadę", "Pick a wage group": "Wybierz grupę płacową", @@ -4050,7 +4049,7 @@ "No mappings yet.": "Brak mapowań.", "No supervisor on line yet": "Brak przełożonego na linii", "PREPEND CURRENT YEAR (E.G. 2026LOT0001)": "DODAJ BIEŻĄCY ROK NA POCZĄTKU (NP. 2026LOT0001)", - "Pad Size": "Rozmiar wkładki", + "Pad Size": "Liczba cyfr licznika", "Parameter": "Parametr", "Pass / fail": "Pozytywny / negatywny", "Pending resolution": "Oczekuje na rozwiązanie", @@ -4337,7 +4336,7 @@ "Default": "Domyślny", "closed": "zamkniętych", "overdue": "przeterminowane", - "Daily": "Dzienny", + "Daily": "Codziennie", "Edit: :name": "Edycja: :name", "Back to Settings": "Powrót do ustawień", "Target": "Cel", @@ -6364,6 +6363,493 @@ "Filter by step": "Filtruj według kroku", "All steps": "Wszystkie kroki", "No BOM components assigned to this step.": "Do tego kroku nie przypisano komponentów BOM.", + "Stock receipt": "Przyjęcie magazynowe", + "Stock return": "Zwrot do magazynu", + "Material allocation": "Przydział materiału", + "Consumption": "Zużycie", + "Stock adjustment": "Korekta stanu", + "Stock transfer": "Przesunięcie magazynowe", + "Reclassification": "Przeklasyfikowanie", + "Manual adjustment": "Korekta ręczna", + "Stock document": "Dokument magazynowy", + "ERP sync": "Synchronizacja ERP", + "Allocated to batch #:batch": "Przydzielono do partii #:batch", + "Allocated to batch #:batch (step :step)": "Przydzielono do partii #:batch (krok :step)", + "Batch #:batch completed — leftover returned to stock": "Partia #:batch zakończona — pozostałość zwrócono do magazynu", + "Batch #:batch scrap qty recorded": "Zarejestrowano odpady partii #:batch", + "Batch #:batch cancelled — return to stock": "Partia #:batch anulowana — zwrot do magazynu", + "Adjustment on batch #:batch": "Korekta partii #:batch", + "Batch #:batch — unused material returned to stock": "Partia #:batch — niewykorzystany materiał zwrócono do magazynu", + "Back to the list": "Wróć do listy", + "Identifier to trace": "Identyfikator do prześledzenia", + "Exact number: order, LOT, pallet, serial, supplier LOT, container…": "Dokładny numer: zlecenie, LOT, paleta, numer seryjny, LOT dostawcy, kontener…", + "What to trace": "Co prześledzić", + "Nothing traceable matches": "Brak pasującego obiektu do prześledzenia dla", + "Finished-goods lot": "Partia wyrobu gotowego", + "No production steps recorded.": "Brak zarejestrowanych kroków produkcji.", + "Origin": "Pochodzenie", + "Process serial (PSN)": "Numer seryjny procesu (PSN)", + "Unit completed": "Ukończona", + "SN labels": "Etykiety SN", + "SN Label Station": "Stanowisko etykiet SN", + "Apply the pre-printed serial number label and bind it to the process serial number (PSN).": "Naklej wydrukowaną etykietę z numerem seryjnym i powiąż ją z numerem seryjnym procesu (PSN).", + "Process Serial Number (PSN)": "Numer seryjny procesu (PSN)", + "Serial Number (label)": "Numer seryjny (etykieta)", + "Apply label": "Zastosuj etykietę", + "Unit registered": "Sztuka zarejestrowana", + "Already registered": "Już zarejestrowana", + "Could not apply label.": "Nie udało się zastosować etykiety.", + "Network error, try again.": "Błąd sieci, spróbuj ponownie.", + "Scan or type the serial number first.": "Najpierw zeskanuj lub wpisz numer seryjny.", + "Recent units (last 50)": "Ostatnie sztuki (50)", + "Serial No": "Nr seryjny", + "Applied At": "Naklejono", + "Carton label (scan PSN)": "Etykieta kartonu (skanuj PSN)", + "Print carton label (SN)": "Drukuj etykietę kartonu (SN)", + "Scan the process serial number label. The printed label carries the unit serial number.": "Zeskanuj etykietę z numerem seryjnym procesu. Wydrukowana etykieta zawiera numer seryjny sztuki.", + "Serial Number": "Numer seryjny", + "A process serial number is required.": "Numer seryjny procesu jest wymagany.", + "A unit cannot be registered at a station without its process serial number.": "Sztuki nie można zarejestrować na stanowisku bez jej numeru seryjnego procesu.", + "A unit is blocked after this many failed test verdicts. 0 never blocks.": "Sztuka zostaje zablokowana po tylu nieudanych wynikach testu. 0 = nigdy.", + "Failed tests before a unit is blocked": "Nieudane testy do zablokowania sztuki", + "How serialised units are identified: the serial number on the product, the process serial number the line works with, and what a failed test does to a unit.": "Jak identyfikowane są sztuki seryjne: numer seryjny na produkcie, numer seryjny procesu, którym posługuje się linia, oraz co nieudany test robi ze sztuką.", + "Mark a unit scrapped on its first failed verdict instead of counting attempts.": "Oznacz sztukę jako złom przy pierwszym nieudanym wyniku zamiast liczyć próby.", + "Normalise scanned identifiers": "Normalizuj skanowane identyfikatory", + "Process serial :psn already belongs to unit :sn.": "Numer seryjny procesu :psn należy już do sztuki :sn.", + "Process serial identifies one unit": "Numer seryjny procesu identyfikuje jedną sztukę", + "Process serial pattern": "Wzorzec numeru seryjnego procesu", + "Process serial re-bound.": "Numer seryjny procesu powiązano ponownie.", + "Process serial required": "Numer seryjny procesu wymagany", + "Re-bind": "Powiąż ponownie", + "Re-bind process serial": "Powiąż ponownie numer seryjny procesu", + "Refuse binding a process serial number that already belongs to another unit. Turn off when one process serial covers several units.": "Odrzucaj powiązanie numeru seryjnego procesu, który należy już do innej sztuki. Wyłącz, gdy jeden numer procesu obejmuje kilka sztuk.", + "Regular expression a process serial number must match. Leave empty to accept any.": "Wyrażenie regularne, do którego musi pasować numer seryjny procesu. Puste = dowolny.", + "Regular expression a unit serial number must match. Leave empty to accept any.": "Wyrażenie regularne, do którego musi pasować numer seryjny sztuki. Puste = dowolny.", + "Scan the process serial…": "Zeskanuj numer seryjny procesu…", + "Scan the serial number label…": "Zeskanuj etykietę z numerem seryjnym…", + "Scrap on first failed test": "Złomuj przy pierwszym nieudanym teście", + "Serial number pattern": "Wzorzec numeru seryjnego", + "Strip whitespace and uppercase before storing or matching, so a label printed with spaces and one read by a scanner are the same identifier.": "Usuwaj spacje i zamieniaj na wielkie litery przed zapisem i dopasowaniem, aby etykieta wydrukowana ze spacjami i odczytana skanerem były tym samym identyfikatorem.", + "The :attribute is not a valid regular expression.": "Pole :attribute nie jest poprawnym wyrażeniem regularnym.", + "The process serial number does not match the configured format.": "Numer seryjny procesu nie pasuje do skonfigurowanego formatu.", + "The serial number does not match the configured format.": "Numer seryjny nie pasuje do skonfigurowanego formatu.", + "Unit :sn found - print the carton label.": "Znaleziono sztukę :sn – wydrukuj etykietę kartonu.", + "Unit :sn is :status and can no longer be bound.": "Sztuka :sn ma status :status i nie można jej już powiązać.", + "Unit :sn is :status and cannot be packed.": "Sztuka :sn ma status :status i nie można jej zapakować.", + "Unit :sn is already bound to process serial :psn.": "Sztuka :sn jest już powiązana z numerem seryjnym procesu :psn.", + "Unit already registered, label binding confirmed.": "Sztuka już zarejestrowana, powiązanie etykiety potwierdzone.", + "Unit registered, process serial bound to serial number.": "Sztuka zarejestrowana, numer seryjny procesu powiązany z numerem seryjnym.", + "Unit serialisation": "Serializacja sztuk", + "Unknown process serial number": "Nieznany numer seryjny procesu", + "e.g. ^[0-9]{3}-[0-9]{2}-[0-9]+$": "np. ^[0-9]{3}-[0-9]{2}-[0-9]+$", + "e.g. ^SN-[0-9]{4}-[0-9]{4}$": "np. ^SN-[0-9]{4}-[0-9]{4}$", + "Serial Unit (SN)": "Sztuka seryjna (SN)", + "Test verdict FAIL": "Wynik testu: NIEZALICZONY", + "Test verdict REWORK": "Wynik testu: PRZERÓBKA", + "Test verdict PASS": "Wynik testu: ZALICZONY", + ":count components": ":count komponentów", + "A unit cannot be a component of itself.": "Sztuka nie może być własnym komponentem.", + "Bind component": "Powiąż komponent", + "Bound": "Powiązano", + "Component :id bound to unit :sn.": "Komponent :id powiązano ze sztuką :sn.", + "Component :id is already installed in unit :sn.": "Komponent :id jest już zamontowany w sztuce :sn.", + "Component bound": "Komponent powiązany", + "Component identifier": "Identyfikator komponentu", + "Component material": "Materiał komponentu", + "Could not bind the component.": "Nie udało się powiązać komponentu.", + "From the identifier": "Z identyfikatora", + "Identifier": "Identyfikator", + "Installed components": "Zamontowane komponenty", + "Installed in": "Zamontowany w", + "No components were scanned into this unit.": "Do tej sztuki nie zeskanowano żadnych komponentów.", + "Removed": "Usunięty", + "Resolved as": "Rozpoznano jako", + "Scan the component identifier.": "Zeskanuj identyfikator komponentu.", + "Scan the component…": "Zeskanuj komponent…", + "Scan the unit…": "Zeskanuj sztukę…", + "Serial label": "Etykieta seryjna", + "Serialised unit": "Sztuka seryjna", + "Station mode": "Tryb stanowiska", + "This component has not been scanned into any unit.": "Ten komponent nie został zeskanowany do żadnej sztuki.", + "Unit serial number": "Numer seryjny sztuki", + "Unknown unit :sn - apply its label first.": "Nieznana sztuka :sn – najpierw naklej jej etykietę.", + "A product may have one sequence of each kind; the station issues process and unit serials from them.": "Produkt może mieć po jednej sekwencji każdego rodzaju; stanowisko wydaje z nich numery seryjne procesu i sztuk.", + "Batch LOT numbers": "Numery LOT partii", + "Could not issue a number.": "Nie udało się wydać numeru.", + "Issued :id": "Wydano :id", + "Next number from the product's process serial sequence": "Kolejny numer z sekwencji numerów seryjnych procesu produktu", + "Next number from the product's unit serial sequence": "Kolejny numer z sekwencji numerów seryjnych sztuk produktu", + "No process serial sequence is configured for this product. Add one under LOT sequences.": "Brak sekwencji numerów seryjnych procesu dla tego produktu. Dodaj ją w Sekwencjach LOT.", + "No unit serial sequence is configured for this product. Add one under LOT sequences.": "Brak sekwencji numerów seryjnych sztuk dla tego produktu. Dodaj ją w Sekwencjach LOT.", + "Numbers": "Numeruje", + "Process serial numbers (PSN)": "Numery seryjne procesu (PSN)", + "Unit serial numbers": "Numery seryjne sztuk", + "Counter, zero-padded to the pad size": "Licznik, dopełniany zerami do długości", + "Year, 4 digits": "Rok, 4 cyfry", + "Year, 2 digits": "Rok, 2 cyfry", + "Year, last digit": "Rok, ostatnia cyfra", + "Month, 2 digits": "Miesiąc, 2 cyfry", + "Day of month, 2 digits": "Dzień miesiąca, 2 cyfry", + "Day of year, 3 digits": "Dzień roku, 3 cyfry", + "ISO week, 2 digits": "Tydzień ISO, 2 cyfry", + "Weekday, 1 = Monday … 7 = Sunday": "Dzień tygodnia, 1 = poniedziałek … 7 = niedziela", + "Hour, 24h": "Godzina, 24h", + "Date as YYYYMMDD": "Data jako RRRRMMDD", + "e.g. :code": "np. :code", + "Anything outside the brackets is printed as typed.": "Wszystko poza nawiasami jest drukowane tak, jak wpisano.", + "Unknown tokens: :tokens. Allowed: :allowed.": "Nieznane tokeny: :tokens. Dozwolone: :allowed.", + "Pattern must contain exactly one [seq] token.": "Wzorzec musi zawierać dokładnie jeden token [seq].", + "Token [:token] does not accept a format argument.": "Token [:token] nie przyjmuje argumentu formatu.", + "Pattern contains unmatched or malformed brackets.": "Wzorzec zawiera niedomknięte lub błędne nawiasy.", + "The :attribute must be a string.": "Pole :attribute musi być tekstem.", + "After a failed test": "Po nieudanym teście", + "Block the unit": "Zablokuj sztukę", + "Hold it after the number of failed tests below. A later passed test releases it.": "Wstrzymaj po podanej niżej liczbie nieudanych testów. Późniejszy zaliczony test ją zwalnia.", + "Scrap it": "Złomuj", + "The first failed test scraps the unit. Final.": "Pierwszy nieudany test złomuje sztukę. Nieodwracalne.", + "Only record the result": "Tylko zapisz wynik", + "The verdict is kept in the history; the unit is not held.": "Wynik trafia do historii; sztuka nie jest wstrzymywana.", + "Failed tests before the unit is blocked": "Nieudane testy do zablokowania sztuki", + "1 = the first failed test blocks the unit.": "1 = pierwszy nieudany test blokuje sztukę.", + "Counter digits": "Liczba cyfr licznika", + "How many digits [seq] is padded to: 4 gives 0001, 0002…; 1 gives 1, 2… with no leading zeros.": "Do ilu cyfr dopełniany jest [seq]: 4 daje 0001, 0002…; 1 daje 1, 2… bez zer wiodących.", + "Counter starts over": "Licznik zaczyna od nowa", + "When [seq] goes back to 1. Daily: the first number each day is 1 again, so a pattern with the date in it stays unique.": "Kiedy [seq] wraca do 1. Dziennie: pierwszy numer każdego dnia to znów 1, więc wzorzec zawierający datę pozostaje unikalny.", + "LOT numbers go onto batches when they start or are released. Process and unit serials are issued at the SN Label Station for single units. A product may have one sequence of each kind.": "Numery LOT trafiają na partie przy starcie lub zwolnieniu. Numery seryjne procesu i sztuk wydaje stanowisko etykiet SN dla pojedynczych sztuk. Produkt może mieć po jednej sekwencji każdego rodzaju.", + "Serial units": "Sztuki seryjne", + "No serial units yet.": "Brak sztuk seryjnych.", + "Registered": "Zarejestrowano", + "This product already has a sequence of this kind. Edit that one, or pick another kind under \"Numbers\".": "Ten produkt ma już sekwencję tego rodzaju. Edytuj tamtą albo wybierz inny rodzaj w polu „Numeruje”.", + "Issue number": "Wydaj numer", + "process serial": "numeru seryjnego procesu", + "unit serial": "numeru seryjnego sztuki", + "Product :product has no :kind sequence. Add one under LOT sequences (\"Numbers\").": "Produkt :product nie ma sekwencji :kind. Dodaj ją w Sekwencjach LOT (pole „Numeruje”).", + "Select a work order first - the :kind sequence belongs to its product. Or add a sequence without a product type to serve every order.": "Najpierw wybierz zlecenie – sekwencja :kind należy do jego produktu. Albo dodaj sekwencję bez typu produktu, wspólną dla wszystkich zleceń.", + "Order :order has no product type, so no :kind sequence applies. Give the order a product, or add a sequence without a product type to serve every order.": "Zlecenie :order nie ma typu produktu, więc żadna sekwencja :kind do niego nie pasuje. Przypisz produkt do zlecenia albo dodaj sekwencję bez typu produktu, wspólną dla wszystkich zleceń.", + "Unit :sn scrapped.": "Sztuka :sn zezłomowana.", + "Scrap unit :sn": "Złomuj sztukę :sn", + "Scrap unit": "Złomuj", + "Unit label": "Etykieta sztuki", + "Carton label": "Etykieta kartonu", + "ZPL for a label printer": "ZPL do drukarki etykiet", + "Close carton & print": "Zamknij karton i drukuj", + "Unit :sn is already in carton :no.": "Sztuka :sn jest już w kartonie :no.", + "Put on pallet :pallet": "Połóż na paletę :pallet", + "Carton": "Karton", + "Pack unit": "Zapakuj sztukę", + "Pallet :no is not open.": "Paleta :no nie jest otwarta.", + "Serial units on the pallet": "Sztuki seryjne na palecie", + "Serial units - carton": "Sztuki seryjne – karton", + "Carton :no is empty.": "Karton :no jest pusty.", + "No carton - units go straight to the pallet": "Bez kartonu – sztuki trafiają prosto na paletę", + "Carton :no put on pallet :pallet": "Karton :no położono na paletę :pallet", + "Carton :no is already on pallet :pallet.": "Karton :no leży już na palecie :pallet.", + "Carton :no is closed.": "Karton :no jest zamknięty.", + "Open carton": "Otwarty karton", + "Pack into :carton": "Pakuj do :carton", + "Carton :no closed with :count units": "Zamknięto karton :no z :count szt.", + "Carton number": "Numer kartonu", + "Carton :no opened": "Otwarto karton :no", + "Scan the process serial number label. The unit is packed into the open carton, or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.": "Zeskanuj etykietę z numerem seryjnym procesu. Sztuka trafia do otwartego kartonu albo prosto na aktywną paletę, a jej etykieta pokazuje się do druku. Zamknięcie kartonu drukuje listę sztuk w środku.", + "New carton": "Nowy karton", + "Carton (units list)": "Karton (lista sztuk)", + "Units list": "Lista sztuk", + "Packing": "Pakowanie", + "Packing station": "Stacja pakowania", + "Fill or close the open carton first.": "Najpierw napełnij lub zamknij otwarty karton.", + "Pallet label": "Etykieta palety", + ":count units": ":count szt.", + "Put down": "Odłóż", + "Leave the pallet open for another station": "Zostaw paletę otwartą dla innego stanowiska", + "Being filled by :name": "Napełnia :name", + "Take over": "Przejmij", + "Label applied": "Naklejono etykietę", + "Process serial re-bound": "Powiązano ponownie numer seryjny procesu", + "Component :id bound": "Powiązano komponent :id", + "Component :id removed": "Usunięto komponent :id", + "Packed into :carton": "Zapakowano do :carton", + "Shipped on pallet :pallet": "Wysłano na palecie :pallet", + "Test at :station": "Test na :station", + "Step kind": "Rodzaj kroku", + "Done at the packing station: units are scanned into cartons and onto pallets here.": "Wykonywany na stacji pakowania: sztuki skanuje się tu do kartonów i na palety.", + "Done at a production workstation.": "Wykonywany na stanowisku produkcyjnym.", + "Packs into": "Pakuje do", + "Single unit (label only)": "Pojedyncza sztuka (tylko etykieta)", + "Carton of units": "Karton sztuk", + "Pallet (cartons or units)": "Paleta (kartony lub sztuki)", + "Units per carton": "Sztuk w kartonie", + "closes the carton when reached": "zamyka karton po osiągnięciu", + "Cartons per pallet": "Kartonów na palecie", + "Label template": "Szablon etykiety", + "Default for the label type": "Domyślny dla typu etykiety", + "packing": "pakowanie", + "My carton :carton": "Mój karton :carton", + "My pallet :pallet": "Moja paleta :pallet", + "Pack only (no carton, no pallet)": "Tylko zapakuj (bez kartonu i palety)", + "Packing steps": "Kroki pakowania", + "Packs onto pallets": "Pakuje na palety", + "Labels single units": "Etykietuje pojedyncze sztuki", + "Packs into cartons": "Pakuje do kartonów", + ":n per carton": ":n w kartonie", + ":n cartons per pallet": ":n kartonów na palecie", + "Pack into": "Pakuj do", + "Pallet :no is full and was closed": "Paleta :no jest pełna i została zamknięta", + "Printed for every unit scanned at the packing station.": "Drukowana dla każdej sztuki zeskanowanej na stacji pakowania.", + "Printed when a carton closes.": "Drukowana przy zamknięciu kartonu.", + "Printed when a pallet closes.": "Drukowana przy zamknięciu palety.", + "Finished Goods": "Wyrób gotowy", + "Workstation Step": "Krok na stanowisku", + "100 × 50 mm (standard)": "100 × 50 mm (standard)", + "80 × 40 mm (small)": "80 × 40 mm (mała)", + "62 × 29 mm (Brother DK)": "62 × 29 mm (Brother DK)", + "100 × 100 mm (square)": "100 × 100 mm (kwadrat)", + "150 × 100 mm (large)": "150 × 100 mm (duża)", + "CODE 128": "CODE 128", + "CODE 39": "CODE 39", + "EAN-13": "EAN-13", + "Work order number": "Numer zlecenia", + "Serial number (SN)": "Numer seryjny (SN)", + "Logo": "Logo", + "Production date": "Data produkcji", + "Unit :sn belongs to another work order than :no.": "Sztuka :sn należy do innego zlecenia niż :no.", + "Unit :sn is already on pallet :no.": "Sztuka :sn jest już na palecie :no.", + "The request holds no test-run records.": "Żądanie nie zawiera rekordów przebiegu testu.", + "Test log not recognised: :reason": "Nierozpoznany log testu: :reason", + "The records do not hold a complete test run.": "Rekordy nie zawierają kompletnego przebiegu testu.", + "Test run already recorded": "Przebieg testu był już zapisany", + "Test run recorded": "Przebieg testu zapisany", + "Component :id removed from unit :sn.": "Komponent :id usunięty ze sztuki :sn.", + ":count numbers issued for :order - print the labels.": "Wydano :count numerów dla :order – wydrukuj etykiety.", + "No units given.": "Nie podano sztuk.", + "Packing list": "Lista pakowa", + "Generated :at": "Wygenerowano :at", + "cartons": "kartonów", + "scrapped (not shipped)": "złomowane (nie wysłane)", + "pallet count": "licznik palety", + "Serial number": "Numer seryjny", + "Empty carton": "Pusty karton", + "Units packed loose on the pallet": "Sztuki luzem na palecie", + "No serialised units are recorded on this pallet.": "Na tej palecie nie ma zarejestrowanych sztuk seryjnych.", + "Packed by": "Pakował", + "Received by": "Odebrał", + "Remove component :id": "Usuń komponent :id", + "Issue batch…": "Wydaj partię…", + "Issue a batch of numbers": "Wydaj partię numerów", + "How many units?": "Ile sztuk?", + "Issue and print": "Wydaj i drukuj", + "Select a work order first.": "Najpierw wybierz zlecenie.", + "Unit labels (:count)": "Etykiety sztuk (:count)", + "Numbers issued": "Numery wydane", + "Every carton and serial number on the pallet, as a document": "Każdy karton i numer seryjny na palecie, jako dokument", + "Unit in production": "W produkcji", + "Unit shipped": "Wysłana", + "Unit scrapped": "Złomowana", + "Unit blocked": "Zablokowana", + "Numbers issued ahead of the line: the labels can be printed now and are applied by scanning them.": "Numery wydane z wyprzedzeniem: etykiety można wydrukować teraz, a na linii przykleić i zeskanować.", + "Unknown process serial or serial number": "Nieznany numer procesowy ani numer seryjny", + "Serial :sn already exists - check the sequence before issuing more numbers.": "Numer seryjny :sn już istnieje – sprawdź sekwencję, zanim wydasz kolejne numery.", + "Unit :sn is already inside :id - it cannot also contain it.": "Sztuka :sn jest już wewnątrz :id – nie może jej jednocześnie zawierać.", + "Serialised units built with this lot": "Sztuki seryjne zbudowane z tej partii", + "removed": "usunięty", + "Sample product": "Produkt przykładowy", + "Assembly": "Montaż", + "Sample data; the label updates as you change the fields above.": "Dane przykładowe; etykieta odświeża się przy zmianie pól powyżej.", + ":count more": ":count więcej", + "Pick the packaging lots first (:materials), then scan.": "Najpierw pobierz partie opakowań (:materials), potem skanuj.", + "Packing step :name is not ready to start.": "Krok pakowania :name nie jest gotowy do startu.", + "Packing step :name started - packaging reserved.": "Krok pakowania :name wystartował – opakowania zarezerwowane.", + "Quantity per": "Ilość na", + "per unit": "na sztukę", + "per carton": "na karton", + "per pallet": "na paletę", + "How much of this packaging one carton takes (the packing step's carton size converts it per unit).": "Ile tego opakowania zużywa jeden karton (wielkość kartonu z kroku pakowania przelicza to na sztukę).", + "How much of this packaging one pallet takes (the packing step's capacities convert it per unit).": "Ile tego opakowania zużywa jedna paleta (pojemności z kroku pakowania przeliczają to na sztukę).", + "Packaging (BOM)": "Opakowania (BOM)", + "Pick packaging": "Pobierz opakowania", + "Pick the lots": "Wybierz partie", + "reserved": "zarezerwowano", + "used": "zużyto", + "to pick": "do pobrania", + "lot": "partia", + "avail.": "dost.", + "Start packing": "Rozpocznij pakowanie", + "Choose a lot for every material.": "Wybierz partię dla każdego materiału.", + "No lot with enough stock - receive the packaging first.": "Brak partii z wystarczającym stanem – najpierw przyjmij opakowania.", + "pending": "do pobrania", + "Lot tracking of materials": "Śledzenie partii materiałów", + "When enabled, every reservation names the material lots it comes from: picked by the strategy below, or by the operator at the step start and at the packing station. Off, stock moves without lots.": "Gdy włączone, każda rezerwacja wskazuje partie materiału, z których pochodzi: dobrane strategią poniżej albo wskazane przez operatora przy starcie kroku i na stacji pakowania. Wyłączone: stan schodzi bez partii.", + "Lot picking strategy": "Strategia doboru partii", + "FEFO - first expiring first": "FEFO – najkrótszy termin pierwszy", + "FIFO - oldest received first": "FIFO – najstarsze przyjęcie pierwsze", + "LIFO - newest received first": "LIFO – najnowsze przyjęcie pierwsze", + "Manual - the operator always chooses": "Ręcznie – operator zawsze wybiera", + "The proposed lot when a step or the packing station starts; the operator can still pick another.": "Partia podpowiadana przy starcie kroku lub na stacji pakowania; operator może wybrać inną.", + "Default for its type": "Domyślny dla typu", + "Not default": "Nie domyślny", + "Printed when nothing names a template of this type": "Drukowany, gdy nic nie wskazuje szablonu tego typu", + "Filter: :station": "Filtr: :station", + "Click a step to show only its station and jump to it; click it again to show everything.": "Kliknij krok, aby pokazać tylko jego stanowisko i przejść do niego; kliknij ponownie, aby pokazać wszystko.", + "Show routing": "Pokaż marszrutę", + "Hide routing": "Ukryj marszrutę", + "Click a step to open the order on it.": "Kliknij krok, aby otworzyć zlecenie na tym kroku.", + "Tap to cycle status": "Dotknij, aby zmienić status", + "No matches": "Brak wyników", + "waited :time": "czekał :time", + "Lead time :time (first record to shipping)": "Lead time :time (od pierwszego zapisu do wysyłki)", + "Lead time :time so far": "Lead time :time do teraz", + "took :time": "trwał :time", + "Note (optional)": "Notatka (opcjonalnie)", + "Carton :no belongs to another order than pallet :pallet.": "Karton :no należy do innego zlecenia niż paleta :pallet.", + "Operator screens": "Ekrany operatora", + "The tabs an operator at this workstation sees. Supervisors, admins and the whole-line view always see every tab.": "Zakładki, które widzi operator przy tym stanowisku. Kierownik, administrator i widok całej linii zawsze widzą wszystkie zakładki.", + "Automatic (from the steps assigned to this workstation)": "Automatycznie (według kroków przypisanych do stanowiska)", + "Shown automatically: :screens": "Automatycznie widoczne: :screens", + "Print a label for each unit when it is scanned": "Drukuj etykietę każdej sztuki przy skanie", + "Off: the label prints only for a unit that has no label yet.": "Wyłączone: etykieta drukuje się tylko dla sztuki, która jeszcze jej nie ma.", + "Could not start the unit.": "Nie udało się założyć sztuki.", + "Issue a batch of process serials": "Wydaj partię numerów PSN", + "No serial number yet": "Bez numeru seryjnego", + "Unit number (SN or PSN)": "Numer sztuki (SN lub PSN)", + "First station: the unit starts on its process serial (the one in the field, or the next from the sequence) and its PSN label prints; the serial number follows with the product label.": "Pierwsze stanowisko: sztuka powstaje z numerem PSN (z pola albo kolejnym z sekwencji) i drukuje się jej etykieta PSN; numer seryjny dochodzi później z etykietą produktu.", + "Start unit on PSN": "Nowa sztuka (PSN)", + "Issue PSN batch…": "Wydaj partię PSN…", + "Started on process serial :psn": "Założona z numerem PSN :psn", + "Scan or issue the process serial first.": "Najpierw zeskanuj albo wydaj numer PSN.", + "Process serial :psn belongs to a unit still waiting for its serial number, and :sn is already another unit.": "Numer PSN :psn należy do sztuki, która czeka na numer seryjny, a :sn to już inna sztuka.", + "Serial number :sn assigned to unit :psn.": "Numer seryjny :sn nadany sztuce :psn.", + "Unknown unit :sn - start it or apply its label first.": "Nieznana sztuka :sn: najpierw ją załóż albo naklej etykietę.", + "Unit :psn started - print its label.": "Sztuka :psn założona, wydrukuj jej etykietę.", + "Unit :sn is already blocked.": "Sztuka :sn jest już zablokowana.", + "Unit :sn is not blocked.": "Sztuka :sn nie jest zablokowana.", + "Unit :sn blocked.": "Sztuka :sn zablokowana.", + "Unit :sn released.": "Sztuka :sn odblokowana.", + "Choose the error code.": "Wybierz kod błędu.", + "Release unit :sn": "Odblokuj sztukę :sn", + "Failed tests": "Nieudane testy", + "Block unit :sn": "Zablokuj sztukę :sn", + "A held unit is not packed and fails its pallet’s quality gate until a supervisor releases it.": "Zablokowana sztuka nie zostanie spakowana, a jej paleta nie przejdzie bramki jakości, dopóki kierownik jej nie odblokuje.", + "Error code": "Kod błędu", + "Choose…": "Wybierz…", + "What was found?": "Co stwierdzono?", + "Blocked: :code :reason": "Zablokowana: :code :reason", + "Block unit": "Zablokuj", + "Release unit": "Odblokuj", + "Unit released": "Odblokowana", + "Weigh unit :sn: :expected g ± :tolerance g.": "Zważ sztukę :sn: :expected g ± :tolerance g.", + "Weight :weight g is outside :expected g ± :tolerance g - check unit :sn.": "Waga :weight g poza zakresem :expected g ± :tolerance g, sprawdź sztukę :sn.", + "Weight (g) · :psn": "Waga (g) · :psn", + ":expected g ± :tolerance g": ":expected g ± :tolerance g", + "weight :expected g ± :tolerance g": "waga :expected g ± :tolerance g", + "Expected weight (g)": "Oczekiwana waga (g)", + "empty = no weight check": "puste = bez kontroli wagi", + "Weight tolerance (± g)": "Tolerancja wagi (± g)", + "Weight :weight g outside :expected ± :tolerance g": "Waga :weight g poza zakresem :expected ± :tolerance g", + "Batch created": "Partia utworzona", + "Batch created (LOT: :lot)": "Partia utworzona (LOT: :lot)", + "This production scenario needs the \":mode\" production flow mode. Change it under Settings → Production and import again.": "Ten scenariusz produkcji wymaga trybu przepływu „:mode”. Zmień go w Ustawieniach → Produkcja i zaimportuj plik ponownie.", + "user :n": "konto :n", + "material lot :n": "partia materiału :n", + "event :n (:type)": "zdarzenie :n (:type)", + "Unknown event type \":type\".": "Nieznany typ zdarzenia „:type”.", + "The time :time is after the import - scenario events must lie in the past.": "Czas :time jest późniejszy niż import. Zdarzenia scenariusza muszą leżeć w przeszłości.", + "The time :time is earlier than the previous event - list events in time order.": "Czas :time jest wcześniejszy niż poprzednie zdarzenie. Ułóż zdarzenia w kolejności czasu.", + "Unreadable time \":time\".": "Nie można odczytać czasu „:time”.", + "Unknown user \":name\" - list the account under scenario.users.": "Nieznany użytkownik „:name”. Dodaj konto w scenario.users.", + "Unknown work order \":no\".": "Nieznane zlecenie „:no”.", + "Unknown workstation \":code\".": "Nieznane stanowisko „:code”.", + "Work order :no has no batch :n.": "Zlecenie :no nie ma partii :n.", + "Batch :n of :no has no step :step.": "Partia :n zlecenia :no nie ma kroku :step.", + "Unknown unit \":ref\".": "Nieznana sztuka „:ref”.", + "Unknown pallet \":ref\".": "Nieznana paleta „:ref”.", + "Unknown error code \":code\".": "Nieznany kod błędu „:code”.", + "An account needs a username.": "Konto musi mieć nazwę użytkownika.", + "Unknown role \":role\".": "Nieznana rola „:role”.", + "Unknown material \":code\".": "Nieznany materiał „:code”.", + "Work order \":no\" is missing a number or already exists.": "Zlecenie „:no” nie ma numeru albo już istnieje.", + "Unknown product \":code\".": "Nieznany produkt „:code”.", + "Unknown line \":code\".": "Nieznana linia „:code”.", + "Unit :ref has no serial number yet - a test result is filed under the SN.": "Sztuka :ref nie ma jeszcze numeru seryjnego, a wynik testu zapisuje się pod SN.", + "The packing step weighs every unit - give weight_g.": "Krok pakowania waży każdą sztukę. Podaj weight_g.", + "Work order :no has no open carton.": "Zlecenie :no nie ma otwartego kartonu.", + "Imported :count configuration items and replayed :events production events.": "Zaimportowano :count elementów konfiguracji i odtworzono :events zdarzeń produkcyjnych.", + "The production scenario was not imported - nothing from the file was saved. :message": "Scenariusz produkcji nie został zaimportowany i nic z pliku nie zostało zapisane. :message", + "Which failed tests count": "Które nieudane testy się liczą", + "Per test, since its last pass": "Na test, od jego ostatniego zaliczenia", + "Failed tests at the same test station after it last passed there. A retest that passes starts the count over.": "Nieudane testy na tym samym stanowisku testowym od ostatniego zaliczenia na nim. Zaliczony test powtórny zaczyna liczenie od nowa.", + "All failed tests of the unit": "Wszystkie nieudane testy sztuki", + "Every failed test at any station counts, and a pass does not reset them.": "Liczy się każdy nieudany test na dowolnym stanowisku, a zaliczenie ich nie zeruje.", + "Scan the serial number of the sub-assembly.": "Zeskanuj numer seryjny podzespołu.", + "Serial :sn is already registered (:material, order :order).": "Numer :sn jest już zarejestrowany (:material, zlecenie :order).", + "Order :order does not make :material.": "Zlecenie :order nie wytwarza: :material.", + "Sub-assembly :sn registered as :material.": "Podzespół :sn zarejestrowany jako :material.", + "Choose the work order that makes the sub-assembly.": "Wybierz zlecenie, które wytwarza podzespół.", + "Choose what was made.": "Wybierz, co zostało wytworzone.", + "Could not register the sub-assembly.": "Nie udało się zarejestrować podzespołu.", + "Sub-assemblies": "Podzespoły", + "This order makes no serial-tracked sub-assembly.": "To zlecenie nie wytwarza podzespołu śledzonego po numerze seryjnym.", + "Choose the work order of the sub-assembly station above.": "Wybierz powyżej zlecenie stanowiska podzespołów.", + "What was made": "Co wytworzono", + "Serial number of the sub-assembly": "Numer seryjny podzespołu", + "Scan the sub-assembly…": "Zeskanuj podzespół…", + "Register sub-assembly": "Zarejestruj podzespół", + "Sub-assembly registered: :material": "Zarejestrowano podzespół: :material", + "Pallet :pallet received from work order :order": "Paleta :pallet przyjęta ze zlecenia :order", + "Pallet :pallet shipped": "Paleta :pallet wysłana", + "Finished goods by pallet": "Wyrób gotowy paletami", + "A closed pallet is received into the finished-goods warehouse and a shipped one is issued from it, instead of one receipt when the work order completes.": "Zamknięta paleta jest przyjmowana do magazynu wyrobów gotowych, a wysłana z niego wydawana, zamiast jednego przyjęcia przy zakończeniu zlecenia.", + "The work order receipt when it completes, as before.": "Przyjęcie przy zakończeniu zlecenia, jak dotąd.", + "Drafts": "Szkice", + "Documents per pallet, posted by the warehouse.": "Dokumenty dla każdej palety, zatwierdza je magazyn.", + "Documents per pallet, posted at once: stock follows the pallets.": "Dokumenty dla każdej palety, zatwierdzane od razu: stan podąża za paletami.", + "IQC: released": "IQC: zwolniona", + "IQC: quarantine": "IQC: kwarantanna", + "IQC: rejected": "IQC: odrzucona", + "IQC: :status": "IQC: :status", + "Supplier lot: :lot": "Partia dostawcy: :lot", + "Sample material": "Przykładowy materiał", + "IQC label": "Etykieta IQC", + "Material lot (IQC)": "Partia materiału (IQC)", + "Inspection status": "Status kontroli", + "missing :qty :unit": "brakuje :qty :unit", + "Production hold: stock does not cover order :order. Missing: :items.": "Produkcja wstrzymana: stan nie pokrywa zlecenia :order. Brakuje: :items.", + "Hold the batch until stock covers the order": "Wstrzymaj partię, dopóki stan nie pokryje zlecenia", + "A batch does not start while stock does not cover its whole work order; the operator sees what is missing, the planner shows it in advance. Steps are blocked as above too.": "Partia nie wystartuje, dopóki stan nie pokrywa całego zlecenia. Operator widzi, czego brakuje, a harmonogram pokazuje to wcześniej. Kroki są też blokowane jak wyżej.", + "A workstation account needs its workstation.": "Konto stanowiska musi mieć przypisane stanowisko.", + "PSN or serial number": "PSN lub numer seryjny", + "Scan the PSN or the serial number…": "Zeskanuj PSN albo numer seryjny…", + "PSN": "PSN", + "Last event": "Ostatnie zdarzenie", + "Carton / pallet": "Karton / paleta", + ":shown of :total": ":shown z :total", + "Units in this order": "Sztuki zlecenia", + "the first bench: the unit starts and its PSN label prints": "pierwsze stanowisko: sztuka startuje i drukuje się jej etykieta PSN", + "Bind the serial label to the PSN": "Połącz etykietę SN z PSN", + "scan the PSN, then the ready serial label": "skan PSN, potem gotowej etykiety z numerem seryjnym", + "Issue numbers": "Wydawanie numerów", + "next numbers from the sequences, batches of labels": "kolejne numery z sekwencji, partie etykiet", + "scan parts and sub-assemblies into a unit": "skan części i podzespołów do sztuki", + "register a sub-assembly made here by its serial": "rejestracja podzespołu wykonanego tutaj po jego numerze", + "SN label station": "Stacja etykiet SN", + "What the SN label station offers an operator at this workstation. Nothing ticked: everything. Supervisors, admins and the whole-line view always see everything.": "Co stacja etykiet SN pokazuje operatorowi na tym stanowisku. Nic nie zaznaczone: wszystko. Brygadziści, administratorzy i widok całej linii zawsze widzą wszystko.", + "New units": "Nowe sztuki", + "Each unit starts here on its process serial; its PSN label prints for the housing.": "Każda sztuka startuje tutaj na numerze procesowym; drukuje się etykieta PSN na korpus.", + "Scan the PSN or the serial number. The unit is packed into the open carton of its order (a unit of another order gets its own), or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.": "Zeskanuj PSN albo numer seryjny. Sztuka trafia do otwartego kartonu swojego zlecenia (sztuka z innego zlecenia dostaje własny) albo prosto na aktywną paletę, a jej etykieta pokazuje się do druku. Zamknięcie kartonu drukuje listę sztuk w środku.", + "Unit :sn is already packed - nothing changed.": "Sztuka :sn jest już spakowana - nic nie zmieniono.", + ":sn is already packed - nothing changed": ":sn jest już spakowana - nic nie zmieniono", + "packed at :time": "spakowana o :time", + "Unit :sn is blocked - it cannot move on until it passes a retest or a supervisor releases it.": "Sztuka :sn jest zablokowana - nie przejdzie dalej, dopóki nie zaliczy ponownego testu albo brygadzista jej nie zwolni.", + "Blocked after :count failed tests": "Zablokowano po :count nieudanych testach", + "Released by a passing retest": "Zwolniono po zaliczonym ponownym teście", + "carton": "karton", + "pallet": "paleta", + "Carton :no is full (:count units).": "Karton :no jest pełny (:count szt.).", + "Pallet :no belongs to another work order.": "Paleta :no należy do innego zlecenia.", + "The test run names no serial number.": "Przebieg testu nie podaje numeru seryjnego.", + "Component :id is :status and cannot be installed.": "Komponent :id ma status :status i nie można go zamontować.", + "A scenario file cannot create administrator accounts.": "Plik scenariusza nie może tworzyć kont administratora.", + "This action is not available at this workstation.": "Ta akcja nie jest dostępna na tym stanowisku.", + "Could not take the pallet": "Nie udało się przejąć palety", + "Could not close the pallet": "Nie udało się zamknąć palety", "No production steps on this order": "Brak kroków produkcyjnych w tym zleceniu", "A work order keeps the process configuration it was created with, so steps added to the template afterwards do not appear here. Ask a supervisor to apply a change request to this order, or to raise a new one.": "Zlecenie zachowuje konfigurację procesu, z którą zostało utworzone, więc kroki dodane do szablonu później nie pojawią się tutaj. Poproś przełożonego o zastosowanie wniosku o zmianę do tego zlecenia albo o wystawienie nowego.", "reserved by other batches": "zarezerwowane przez inne partie", @@ -6384,5 +6870,11 @@ "This stop has no linked downtime record, so it cannot be classified or escalated.": "Ten postój nie ma powiązanego rekordu przestoju, więc nie można przypisać mu przyczyny ani go eskalować.", "Stop with no record": "Postój bez rekordu", "NO RECORD": "BRAK REKORDU", - "Sample data is already being loaded. Please wait for it to finish.": "Dane przykładowe są właśnie wczytywane. Poczekaj na zakończenie." + "Sample data is already being loaded. Please wait for it to finish.": "Dane przykładowe są właśnie wczytywane. Poczekaj na zakończenie.", + "Additional fields": "Pola dodatkowe", + "Enter EAN code": "Wpisz kod EAN", + "Module \":name\" requires OpenMES :required; this installation is :current.": "Moduł \":name\" wymaga OpenMES :required; ta instalacja to :current.", + "Module \":name\" could not be uninstalled: :msg": "Nie udało się odinstalować modułu \":name\": :msg", + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Moduł \":name\" odinstalowany. Jego tabele w bazie zostały nienaruszone; ponowna instalacja zachowa dane.", + "Failed to import settings - nothing from the file was saved. :message": "Import ustawień nie powiódł się - nic z pliku nie zostało zapisane. :message" } diff --git a/backend/package-lock.json b/backend/package-lock.json index 77dbd3875..a5d4b89e5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "@dagrejs/dagre": "^3.1.1", "@dnd-kit/react": "^0.5.0", "@inertiajs/react": "^3.3.0", + "@openmes/module": "file:../packages/module", "@openmes/ui": "file:../packages/ui", "@tanstack/db": "^0.6.7", "@tanstack/react-db": "^0.1.85", @@ -34,6 +35,14 @@ "vitest": "^3.2.7" } }, + "../packages/module": { + "name": "@openmes/module", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "../packages/ui": { "name": "@openmes/ui", "version": "0.0.0", @@ -91,7 +100,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -984,6 +992,10 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@openmes/module": { + "resolved": "../packages/module", + "link": true + }, "node_modules/@openmes/ui": { "resolved": "../packages/ui", "link": true @@ -2180,7 +2192,6 @@ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", @@ -2221,7 +2232,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2493,7 +2503,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3590,7 +3599,6 @@ "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz", "integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==", "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -3600,7 +3608,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3649,7 +3656,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4042,7 +4048,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/backend/package.json b/backend/package.json index 5bc9041b8..b706db714 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "@dagrejs/dagre": "^3.1.1", "@dnd-kit/react": "^0.5.0", "@inertiajs/react": "^3.3.0", + "@openmes/module": "file:../packages/module", "@openmes/ui": "file:../packages/ui", "@tanstack/db": "^0.6.7", "@tanstack/react-db": "^0.1.85", diff --git a/backend/resources/js/Pages/admin/lot-sequences/Index.jsx b/backend/resources/js/Pages/admin/lot-sequences/Index.jsx index d0ea0f492..2853b60f7 100644 --- a/backend/resources/js/Pages/admin/lot-sequences/Index.jsx +++ b/backend/resources/js/Pages/admin/lot-sequences/Index.jsx @@ -5,6 +5,20 @@ import ResourceFormDrawer, { useResourceDrawer } from '../../../components/Resou import LotSequenceForm, { lotSequenceInitial } from './LotSequenceForm'; import { __ } from '../../../lib/i18n'; +const RESET_LABEL = { + none: () => __('No reset'), + yearly: () => __('Yearly'), + monthly: () => __('Monthly'), + daily: () => __('Daily'), + hourly: () => __('Hourly'), +}; + +const PURPOSE_LABEL = { + lot: () => __('Batch LOT numbers'), + process_serial: () => __('Process serial numbers (PSN)'), + unit_serial: () => __('Unit serial numbers'), +}; + export default function LotSequencesIndex() { const drawer = useResourceDrawer(); @@ -14,6 +28,7 @@ export default function LotSequencesIndex() { const columns = [ { key: 'name', label: __('Name'), className: 'font-medium text-om-ink', filter: 'text' }, { key: 'product_type', label: __('Product Type'), className: 'text-om-muted', value: (r) => productTypeNames[r.product_type_id] ?? __('Global'), render: (r) => productTypeNames[r.product_type_id] ?? __('Global') }, + { key: 'purpose', label: __('Numbers'), className: 'text-om-muted', value: (r) => PURPOSE_LABEL[r.purpose]?.() ?? r.purpose, render: (r) => PURPOSE_LABEL[r.purpose]?.() ?? r.purpose }, { key: 'format', label: __('Format'), @@ -22,13 +37,13 @@ export default function LotSequencesIndex() { render: (r) => r.pattern || r.prefix, }, { key: 'next_number', label: __('Next #'), className: 'text-om-muted' }, - { key: 'pad_size', label: __('Pad'), className: 'text-om-muted' }, + { key: 'pad_size', label: __('Counter digits'), className: 'text-om-muted' }, { key: 'reset_period', - label: __('Reset'), + label: __('Counter starts over'), className: 'text-om-muted', - - render: (r) => (r.reset_period && r.reset_period !== 'none' ? r.reset_period : '—'), + value: (r) => RESET_LABEL[r.reset_period ?? 'none']?.() ?? r.reset_period, + render: (r) => RESET_LABEL[r.reset_period ?? 'none']?.() ?? r.reset_period, }, ]; diff --git a/backend/resources/js/Pages/admin/lot-sequences/LotSequenceForm.jsx b/backend/resources/js/Pages/admin/lot-sequences/LotSequenceForm.jsx index 8427ba31b..53b465f78 100644 --- a/backend/resources/js/Pages/admin/lot-sequences/LotSequenceForm.jsx +++ b/backend/resources/js/Pages/admin/lot-sequences/LotSequenceForm.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, Fragment } from 'react'; import { Link, useForm, usePage } from '@inertiajs/react'; import { Button, Checkbox, Dropdown } from '@openmes/ui'; import { __ } from '../../../lib/i18n'; @@ -20,6 +20,7 @@ export function lotSequenceInitial(r) { return { name: r?.name ?? '', product_type_id: r?.product_type_id != null ? String(r.product_type_id) : '', + purpose: r?.purpose ?? 'lot', pattern: r?.pattern ?? '', prefix: r?.prefix ?? '', suffix: r?.suffix ?? '', @@ -86,20 +87,22 @@ export default function LotSequenceForm({ action, method, initial, submitLabel, return () => clearTimeout(t); }, [mode, data.pattern, data.pad_size, data.product_type_id, csrf_token]); + // A token dropped straight after another gets a dash between them: two + // dates run together ("[year][month]") are legal but rarely meant, and the + // dash is one keystroke to delete when they are. const insertToken = (token) => { - const tag = `[${token}]`; const el = patternInputRef.current; const current = data.pattern ?? ''; - if (el && document.activeElement === el) { - const start = el.selectionStart ?? current.length; - const end = el.selectionEnd ?? current.length; - setData('pattern', current.slice(0, start) + tag + current.slice(end)); + const focused = el && document.activeElement === el; + const start = focused ? (el.selectionStart ?? current.length) : current.length; + const end = focused ? (el.selectionEnd ?? current.length) : current.length; + const tag = (current.slice(0, start).endsWith(']') ? '-' : '') + `[${token}]`; + setData('pattern', current.slice(0, start) + tag + current.slice(end)); + if (focused) { requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start + tag.length, start + tag.length); }); - } else { - setData('pattern', current + tag); } }; @@ -132,6 +135,23 @@ export default function LotSequenceForm({ action, method, initial, submitLabel, {errors.product_type_id &&

{errors.product_type_id}

} +
+
{__('Numbers')}
+ setData('purpose', v)} + options={[ + { value: 'lot', label: __('Batch LOT numbers') }, + { value: 'process_serial', label: __('Process serial numbers (PSN)') }, + { value: 'unit_serial', label: __('Unit serial numbers') }, + ]} + className="w-full" + /> +

{__('LOT numbers go onto batches when they start or are released. Process and unit serials are issued at the SN Label Station for single units. A product may have one sequence of each kind.')}

+ {errors.purpose &&

{errors.purpose}

} +
+ {/* Mode toggle */}
{[ @@ -171,23 +191,34 @@ export default function LotSequenceForm({ action, method, initial, submitLabel, )}
- {/* Token palette */} + {/* Token palette: click to insert. Each chip shows its meaning + and today's value on hover, and the legend below spells them out. */}
{patternTokens.map((t) => ( ))}

- {__('Click a token to insert it. Pattern must contain exactly one')} [seq]. {__('Use [date:y-m-d] for a custom date format.')} + {__('Click a token to insert it. Pattern must contain exactly one')} [seq]. {__('Anything outside the brackets is printed as typed.')}

+
+ {patternTokens.map((t) => ( + +
[{t.token}]
+
{t.label}
+
{t.example}
+
+ ))} +
{preview && (
@@ -210,9 +241,9 @@ export default function LotSequenceForm({ action, method, initial, submitLabel,
-
{__('Pad Size')}
+
{__('Counter digits')}
setData('pad_size', e.target.value)} className="form-input w-full" /> +

{__('How many digits [seq] is padded to: 4 gives 0001, 0002…; 1 gives 1, 2… with no leading zeros.')}

{errors.pad_size &&

{errors.pad_size}

}
-
{__('Counter Reset')}
+
{__('Counter starts over')}
setData('reset_period', v)} options={RESET_PERIODS.map((o) => ({ value: String(o.value), label: __(o.label) }))} className="w-full" /> +

{__('When [seq] goes back to 1. Daily: the first number each day is 1 again, so a pattern with the date in it stays unique.')}

{errors.reset_period &&

{errors.reset_period}

}
diff --git a/backend/resources/js/Pages/admin/machine-monitor/Index.jsx b/backend/resources/js/Pages/admin/machine-monitor/Index.jsx index b44669d66..725b613c2 100644 --- a/backend/resources/js/Pages/admin/machine-monitor/Index.jsx +++ b/backend/resources/js/Pages/admin/machine-monitor/Index.jsx @@ -10,18 +10,6 @@ import { __, formatNumber } from '../../../lib/i18n'; * since, availability, quality, good, reject, metadata. */ -const BORDER = { - green: 'border-om-running', - amber: 'border-amber-400', - blue: 'border-om-accent', - gray: 'border-om-faintest', - red: 'border-om-blocked', - yellow: 'border-yellow-400', - purple: 'border-purple-400', - orange: 'border-orange-400', - slate: 'border-slate-300', -}; - const BADGE = { green: 'bg-om-running-bg text-om-running', amber: 'bg-om-downtime-bg text-om-downtime', @@ -156,13 +144,12 @@ export default function MachineMonitorIndex() { MachineMonitorIndex.layout = (page) => {page}; function Tile({ t, now, states = [], onSetState }) { - const border = BORDER[t.color] ?? BORDER.slate; const badge = BADGE[t.color] ?? BADGE.slate; const elapsed = timeInState(t.since, now); const metadata = t.metadata && typeof t.metadata === 'object' ? Object.entries(t.metadata) : []; return ( -
+

{t.name}

diff --git a/backend/resources/js/Pages/admin/material-lots/Show.jsx b/backend/resources/js/Pages/admin/material-lots/Show.jsx index 746c2b2a7..4e4c7dd0c 100644 --- a/backend/resources/js/Pages/admin/material-lots/Show.jsx +++ b/backend/resources/js/Pages/admin/material-lots/Show.jsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { DataTable } from '@openmes/ui/table'; import AppLayout from '../../../layouts/AppLayout'; import PageTrail from '../../../components/PageTrail'; +import { __ } from '../../../lib/i18n'; const STATUS_COLORS = { received: 'bg-om-chip text-om-accent', @@ -167,6 +168,8 @@ export default function MaterialLotShow({ lot }) { {ucFirst(lot.status)} + {/* The incoming-inspection label: lot, material and the IQC verdict, for the warehouse shelf. */} + {__('IQC label')} Edit ← Back
diff --git a/backend/resources/js/Pages/admin/material-lots/fields.js b/backend/resources/js/Pages/admin/material-lots/fields.js index 8d402c853..ac8e52a1b 100644 --- a/backend/resources/js/Pages/admin/material-lots/fields.js +++ b/backend/resources/js/Pages/admin/material-lots/fields.js @@ -59,6 +59,24 @@ export function materialLotFields(materials, sources, statuses) { ]; } +/** + * Props for `` from a lot status. Green stays reserved for the one + * happy ending (released for use); a held lot is amber, a spent one grey. + */ +const LOT_STATUS_META = { + received: { tone: 'info', icon: 'inbox' }, + quarantine: { tone: 'warn', icon: 'lock' }, + released: { tone: 'success', icon: 'circle-check' }, + consumed: { tone: 'neutral', icon: 'check' }, + expired: { tone: 'danger', icon: 'clock' }, + rejected: { tone: 'critical', icon: 'x' }, +}; + +export function materialLotStatusBadge(status) { + const meta = LOT_STATUS_META[status] ?? { tone: 'neutral' }; + return { ...meta, label: materialLotStatusLabel(status) }; +} + export const STATUS_STYLES = { received: 'bg-blue-100 text-blue-700', quarantine: 'bg-yellow-100 text-yellow-700', diff --git a/backend/resources/js/Pages/admin/materials/Show.jsx b/backend/resources/js/Pages/admin/materials/Show.jsx index 5237608e0..32280069b 100644 --- a/backend/resources/js/Pages/admin/materials/Show.jsx +++ b/backend/resources/js/Pages/admin/materials/Show.jsx @@ -1,9 +1,10 @@ import { useMemo, useState } from 'react'; import { Head, useForm } from '@inertiajs/react'; -import { Button, Modal, TextField } from '@openmes/ui'; +import { Button, Modal, StatusBadge, TextField } from '@openmes/ui'; import { __ } from '../../../lib/i18n'; import ResourceFormDrawer, { useResourceDrawer } from '../../../components/ResourceFormDrawer'; import { materialFields, materialInitial } from './fields'; +import { materialLotStatusBadge } from '../material-lots/fields'; import AppDataTable from '../../../components/AppDataTable'; import AppLayout from '../../../layouts/AppLayout'; import CustomFieldsDisplay from '../../../components/CustomFieldsDisplay'; @@ -22,12 +23,6 @@ const MOVEMENT_TYPE_COLORS = { adjustment: 'text-purple-700', }; -const LOT_STATUS_COLORS = { - released: 'bg-om-running-bg text-om-running', - quarantine: 'bg-om-blocked-bg text-om-blocked', - expired: 'bg-om-downtime-bg text-om-downtime', -}; - function fmt(val, decimals = 3) { return Number(val ?? 0).toFixed(decimals); } @@ -49,13 +44,13 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'lot_number', accessorKey: 'lot_number', - header: 'Lot', + header: __('Lot'), cell: ({ row }) => {row.original.lot_number}, }, { id: 'supplier_lot_no', accessorKey: 'supplier_lot_no', - header: 'Supplier ref', + header: __('Supplier ref'), cell: ({ row }) => ( {row.original.supplier_lot_no ?? '—'} ), @@ -63,14 +58,14 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'quantity_received', accessorKey: 'quantity_received', - header: 'Received', + header: __('Received'), meta: { align: 'right' }, cell: ({ row }) => {fmt(row.original.quantity_received)}, }, { id: 'quantity_available', accessorKey: 'quantity_available', - header: 'Available', + header: __('Available'), meta: { align: 'right' }, cell: ({ row }) => ( @@ -81,7 +76,7 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'expiry_date', accessorKey: 'expiry_date', - header: 'Expiry', + header: __('Expiry'), cell: ({ row }) => { const lot = row.original; const expiringSoon = lot.expiry_date && isExpiringSoon(lot.expiry_date); @@ -96,15 +91,8 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'status', accessorKey: 'status', - header: 'Status', - cell: ({ row }) => { - const badge = LOT_STATUS_COLORS[row.original.status] ?? 'bg-om-chip text-om-muted'; - return ( - - {ucFirst(row.original.status)} - - ); - }, + header: __('Status'), + cell: ({ row }) => , }, ], []); @@ -112,7 +100,7 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'performed_at', accessorKey: 'performed_at', - header: 'When', + header: __('When'), cell: ({ row }) => ( {row.original.performed_at ? row.original.performed_at.substring(0, 16).replace('T', ' ') : '—'} @@ -122,16 +110,16 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'movement_type', accessorKey: 'movement_type', - header: 'Type', + header: __('Type'), cell: ({ row }) => { const typeColor = MOVEMENT_TYPE_COLORS[row.original.movement_type] ?? 'text-om-muted'; - return {row.original.movement_type}; + return {__(MOVEMENT_LABELS[row.original.movement_type] ?? row.original.movement_type)}; }, }, { id: 'delta', accessorKey: 'quantity', - header: 'Delta', + header: __('Delta'), meta: { align: 'right' }, cell: ({ row }) => { const qty = Number(row.original.quantity ?? 0); @@ -146,34 +134,34 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'balance_after', accessorKey: 'balance_after', - header: 'Balance', + header: __('Balance'), meta: { align: 'right' }, cell: ({ row }) => {fmt(row.original.balance_after)}, }, { id: 'source', - accessorFn: (r) => (r.source_type ? `${r.source_type === 'manual_receipt' ? __('Manual receipt') : r.source_type}${r.source_id == null ? '' : ' #' + r.source_id}` : '—'), - header: 'Source', + accessorFn: (r) => movementSource(r), + header: __('Source'), cell: ({ row }) => ( - {row.original.source_type ? `${row.original.source_type === 'manual_receipt' ? __('Manual receipt') : row.original.source_type}${row.original.source_id == null ? '' : ' #' + row.original.source_id}` : '—'} + {movementSource(row.original)} ), }, { id: 'reason', - accessorKey: 'reason', - header: 'Reason', + accessorFn: (r) => movementReason(r), + header: __('Reason'), cell: ({ row }) => ( - - {(row.original.reason ?? '').substring(0, 60)} + + {movementReason(row.original).substring(0, 60)} ), }, { id: 'performed_by', accessorFn: (r) => r.performed_by?.name ?? '—', - header: 'By', + header: __('By'), cell: ({ row }) => {row.original.performed_by?.name ?? '—'}, }, ], []); @@ -182,26 +170,26 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] { id: 'template', accessorFn: (r) => r.process_template?.name ?? '—', - header: 'Template', + header: __('Template'), cell: ({ row }) => {row.original.process_template?.name ?? '—'}, }, { id: 'product', accessorFn: (r) => r.process_template?.product_type?.name ?? '-', - header: 'Product', + header: __('Product'), cell: ({ row }) => {row.original.process_template?.product_type?.name ?? '-'}, }, { id: 'quantity_per_unit', accessorKey: 'quantity_per_unit', - header: 'Qty/Unit', + header: __('Qty/Unit'), meta: { align: 'right' }, cell: ({ row }) => {row.original.quantity_per_unit}, }, { id: 'scrap_percentage', accessorKey: 'scrap_percentage', - header: 'Scrap %', + header: __('Scrap %'), meta: { align: 'right' }, cell: ({ row }) => {row.original.scrap_percentage}%, }, @@ -209,7 +197,7 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] return ( <> - + setReceiving(false)} title={__('Receive material')}>

{material.name} · {material.unit_of_measure}

@@ -241,9 +229,9 @@ export default function MaterialShow({ material, lots = [], recentMovements = []

{material.name}

{material.is_active ? ( - Active + {__('Active')} ) : ( - Inactive + {__('Inactive')} )}

{material.code}

@@ -258,12 +246,12 @@ export default function MaterialShow({ material, lots = [], recentMovements = []
{/* Details */}
-

Details

+

{__('Details')}

- - - - + + + +
@@ -287,13 +275,13 @@ export default function MaterialShow({ material, lots = [], recentMovements = []
{material.min_stock_level != null && (
-
Min stock level
+
{__('Min stock level')}
{fmt(material.min_stock_level)} {material.unit_of_measure}
)} {material.unit_price != null && (
-
Stock value
+
{__('Stock value')}
{Number(material.stock_quantity * material.unit_price).toFixed(2)} {material.price_currency}
@@ -308,22 +296,22 @@ export default function MaterialShow({ material, lots = [], recentMovements = []
{/* External System */}
-

External System

+

{__('External System')}

{material.external_code ? (
- - {material.external_code}} /> + + {material.external_code}} />
) : ( -

No external system linked.

+

{__('No external system linked.')}

)} {material.sources && material.sources.length > 0 && ( <> -

Additional Sources

+

{__('Additional Sources')}

{material.sources.map((src) => (
- {src.integration_config?.system_name ?? 'Unknown'}:{' '} + {src.integration_config?.system_name ?? __('Unknown')}:{' '} {src.external_code}
))} @@ -341,7 +329,7 @@ export default function MaterialShow({ material, lots = [], recentMovements = [] {lots.length > 0 && (

- Lots ({lots.length}) + {__('Lots')} ({lots.length})

0 && (
-

Recent stock movements

+

{__('Recent stock movements')}

0 && (

- Used in BOM ({material.bom_items.length} templates) + {__('Used in BOM (:count templates)', { count: material.bom_items.length })}

); } + +const MOVEMENT_LABELS = { + receipt: 'Stock receipt', return: 'Stock return', allocation: 'Material allocation', + consume: 'Consumption', scrap: 'Scrap', adjustment: 'Stock adjustment', + transfer: 'Stock transfer', reclassify: 'Reclassification', +}; +const SOURCE_LABELS = { + batch: 'Batch', batch_step: 'Batch step', inspection: 'Inspection', + manual_adjust: 'Manual adjustment', manual_receipt: 'Manual receipt', receipt: 'Stock receipt', + pallet: 'Pallet', stock_document: 'Stock document', erp_sync: 'ERP sync', + reclassification: 'Reclassification', +}; +function movementSource(row) { + if (!row.source_type) return '—'; + return __(SOURCE_LABELS[row.source_type] ?? row.source_type) + (row.source_id == null ? '' : ` #${row.source_id}`); +} +// System-written notes arrive translated by the server (`reason_display`); +// a user's own note is shown as typed. +function movementReason(row) { + return row.reason_display ?? row.reason ?? ''; +} diff --git a/backend/resources/js/Pages/admin/modules/Index.jsx b/backend/resources/js/Pages/admin/modules/Index.jsx index dcad49928..0bb585894 100644 --- a/backend/resources/js/Pages/admin/modules/Index.jsx +++ b/backend/resources/js/Pages/admin/modules/Index.jsx @@ -55,7 +55,7 @@ export default function ModulesIndex() { {modules.map((module) => (
diff --git a/backend/resources/js/Pages/admin/pallets/Edit.jsx b/backend/resources/js/Pages/admin/pallets/Edit.jsx deleted file mode 100644 index 15c8e7b26..000000000 --- a/backend/resources/js/Pages/admin/pallets/Edit.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Head, usePage } from '@inertiajs/react'; -import AppLayout from '../../../layouts/AppLayout'; -import PalletForm, { palletInitial } from './PalletForm'; -import LabelPrintMenu from '../../../components/LabelPrintMenu'; -import { __ } from '../../../lib/i18n'; - -export default function PalletEdit() { - const { pallet, labelTemplates = [] } = usePage().props; - - return ( -
- -
-

- {__('Edit Pallet')} {pallet.pallet_no} -

- -
- -
- ); -} - -PalletEdit.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/pallets/Index.jsx b/backend/resources/js/Pages/admin/pallets/Index.jsx index ebc1fc3c2..7248adf9b 100644 --- a/backend/resources/js/Pages/admin/pallets/Index.jsx +++ b/backend/resources/js/Pages/admin/pallets/Index.jsx @@ -1,7 +1,9 @@ +import { useEffect } from 'react'; import { Head, Link, router, usePage } from '@inertiajs/react'; import AppLayout from '../../../layouts/AppLayout'; import ResourceTable from '../../../components/ResourceTable'; import ResourceFormDrawer, { useResourceDrawer } from '../../../components/ResourceFormDrawer'; +import LabelPrintMenu from '../../../components/LabelPrintMenu'; import PalletForm, { palletInitial } from './PalletForm'; import { __ } from '../../../lib/i18n'; @@ -52,42 +54,32 @@ function NoTemplateBanner() { ); } -/** Inline label buttons for a pallet row — PDF opens in a new tab, ZPL downloads. */ +/** + * The row's label: the same preview modal (print, ZPL, template choice) the + * stations use, instead of PDF and ZPL links that leave the page. + */ function LabelCell({ palletId, templates }) { // No template configured → a muted dash; the page-level banner drives the // "prepare a label" call to action instead of repeating it on every row. if (!templates.length) { return —; } - const tpl = templates.find((t) => t.is_default) ?? templates[0]; - const base = `/packaging/labels/pallet/${palletId}`; - return ( - - ); + return ; } export default function PalletsIndex() { const drawer = useResourceDrawer(); - const { workOrderNumbers = {}, statusLabels = {}, labelTemplates = [], workOrders, statuses } = usePage().props; + const { workOrderNumbers = {}, statusLabels = {}, labelTemplates = [], workOrders, statuses, editPallet = null } = usePage().props; const formReady = workOrders !== undefined && statuses !== undefined; + // A deep link to /admin/pallets/{id}/edit arrives as ?edit={id} with the + // record attached: open the drawer on it, the same way a row's Edit does. + useEffect(() => { + if (editPallet) drawer.edit(editPallet); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editPallet?.id]); + const columns = [ { key: 'pallet_no', label: __('Pallet number'), className: 'font-mono font-medium text-om-ink', filter: 'text' }, { @@ -130,12 +122,14 @@ export default function PalletsIndex() { { key: 'label', label: __('Label'), + filter: false, render: (r) => , }, ]; const actions = (r) => [ { label: __('Edit'), icon: 'edit', onClick: () => drawer.edit(r) }, + { label: __('Packing list'), icon: 'document', onClick: () => window.open(`/packaging/labels/pallet/${r.id}/packing-list`, '_blank', 'noopener') }, { label: 'Delete', icon: 'delete', diff --git a/backend/resources/js/Pages/admin/process-templates/Bom.jsx b/backend/resources/js/Pages/admin/process-templates/Bom.jsx index cb0d01a84..b3267c7bb 100644 --- a/backend/resources/js/Pages/admin/process-templates/Bom.jsx +++ b/backend/resources/js/Pages/admin/process-templates/Bom.jsx @@ -32,6 +32,7 @@ function MaterialForm({ productType, processTemplate, materials, productTypes = material_id: item ? String(item.material_id ?? '') : '', product_type_id: item ? String(item.product_type_id ?? '') : '', quantity_per_unit: item ? String(item.quantity_per_unit ?? '') : '', + per: item ? (item.per ?? 'unit') : 'unit', template_step_id: item && item.template_step_id != null ? String(item.template_step_id) : '', scrap_percentage: item ? String(item.scrap_percentage ?? '0') : '0', consumed_at: item ? (item.consumed_at ?? 'start') : 'start', @@ -40,6 +41,25 @@ function MaterialForm({ productType, processTemplate, materials, productTypes = const { data, setData, errors, processing } = form; + // On a packing step the quantity can be counted per carton or per pallet; + // the step's own capacities turn it into a per-unit figure when the order freezes. + const selectedStep = steps.find((s) => String(s.id) === String(data.template_step_id)); + const packingStep = selectedStep?.kind === 'packing'; + const perOptions = [ + { value: 'unit', label: __('per unit') }, + ...(packingStep ? [{ value: 'carton', label: __('per carton') }, { value: 'pallet', label: __('per pallet') }] : []), + ]; + const onStepChange = (v) => { + const step = steps.find((s) => String(s.id) === String(v)); + setData((d) => ({ + ...d, + template_step_id: v, + // Packaging is taken while the packing step runs; a basis other than + // "per unit" only makes sense there. + ...(step?.kind === 'packing' ? { consumed_at: 'during' } : { per: 'unit' }), + })); + }; + const isProductType = data.component_kind === 'product_type'; const selectedMaterial = isEdit ? null @@ -153,19 +173,34 @@ function MaterialForm({ productType, processTemplate, materials, productTypes =
- {__("Quantity per Unit")}{unit ? ` (${unit})` : ''} * + {packingStep ? __("Quantity per") : __("Quantity per Unit")}{unit ? ` (${unit})` : ''} * +
+
+ setData('quantity_per_unit', e.target.value)} + className={`form-input w-full${errors.quantity_per_unit ? ' border-om-blocked' : ''}`} + /> + {packingStep && ( + setData('per', v)} + options={perOptions} + className="w-40" + /> + )}
- setData('quantity_per_unit', e.target.value)} - className={`form-input w-full${errors.quantity_per_unit ? ' border-om-blocked' : ''}`} - />

- {__("How much of this material is needed per one finished product unit.")} + {data.per === 'carton' + ? __("How much of this packaging one carton takes (the packing step's carton size converts it per unit).") + : data.per === 'pallet' + ? __("How much of this packaging one pallet takes (the packing step's capacities convert it per unit).") + : __("How much of this material is needed per one finished product unit.")}

{errors.quantity_per_unit && (

{errors.quantity_per_unit}

@@ -179,7 +214,7 @@ function MaterialForm({ productType, processTemplate, materials, productTypes = setData('template_step_id', v)} + onChange={onStepChange} options={[ { value: '', label: __('All steps / general') }, ...steps.map((s) => ({ @@ -328,7 +363,10 @@ export default function ProcessTemplatesBom() { label: 'Qty/Unit', align: 'right', render: (row) => ( - {row.quantity_per_unit} {row.unit_of_measure} + + {row.quantity_per_unit} {row.unit_of_measure} + {row.per && row.per !== 'unit' && / {row.per === 'carton' ? __('carton') : __('pallet')}} + ), }, { diff --git a/backend/resources/js/Pages/admin/process-templates/Show.jsx b/backend/resources/js/Pages/admin/process-templates/Show.jsx index 564f5e912..f8d339759 100644 --- a/backend/resources/js/Pages/admin/process-templates/Show.jsx +++ b/backend/resources/js/Pages/admin/process-templates/Show.jsx @@ -8,7 +8,7 @@ import AppLayout from '../../../layouts/AppLayout'; // Explicit extension: the helper module `engineeringDocuments.js` differs only in // case and would resolve wrong on case-insensitive filesystems. import EngineeringDocuments from '../../../components/EngineeringDocuments.jsx'; -import RoutingGraph from './RoutingGraph'; +import RoutingGraph from '../../../components/flow/RoutingGraph'; import { __ } from '../../../lib/i18n'; import Tooltip from '../../../components/Tooltip'; import useConfirm from '../../../components/useConfirm'; @@ -84,6 +84,121 @@ function OptionalVariantFields({ data, setData, errors }) { * the Level-4 standard times (setup + run-per-unit) that flow down from an ERP * BOM. Shared by the add- and edit-step forms. */ +/** + * What kind of step this is. A packing step is executed by the packing + * station rather than a production bench, and says what it packs into what: + * units into cartons, cartons onto pallets, how many fit, which label prints. + */ +/** Which label type each packing level prints, so the template list only offers the fitting ones. */ +const LEVEL_LABEL_TYPE = { unit: 'serial_unit', carton: 'carton', pallet: 'pallet' }; +const LEVEL_LABEL_HINT = { + unit: 'Printed for every unit scanned at the packing station.', + carton: 'Printed when a carton closes.', + pallet: 'Printed when a pallet closes.', +}; + +function StepKindFields({ data, setData, labelTemplates = [] }) { + const cfg = data.config ?? {}; + const setCfg = (key, value) => setData('config', { ...cfg, [key]: value }); + const isPacking = data.kind === 'packing'; + const level = cfg.unit ?? 'carton'; + const applicableTemplates = labelTemplates.filter((t) => t.type === LEVEL_LABEL_TYPE[level]); + return ( +
+
+
{__('Step kind')}
+ setData('kind', v)} + options={[ + { value: 'production', label: __('Production') }, + { value: 'packing', label: __('Packing') }, + ]} + className="w-full" + /> +

+ {isPacking ? __('Done at the packing station: units are scanned into cartons and onto pallets here.') : __('Done at a production workstation.')} +

+
+ {isPacking && ( + <> +
+
{__('Packs into')}
+ setData('config', { ...cfg, unit: v, label_template_id: null })} + options={[ + { value: 'unit', label: __('Single unit (label only)') }, + { value: 'carton', label: __('Carton of units') }, + { value: 'pallet', label: __('Pallet (cartons or units)') }, + ]} + className="w-full" + /> +
+ setCfg('carton_capacity', v)} + placeholder={__('closes the carton when reached')} + /> + setCfg('pallet_capacity', v)} + placeholder={__('optional')} + /> + setCfg('weight_expected_g', v === '' ? null : v)} + placeholder={__('empty = no weight check')} + /> + setCfg('weight_tolerance_g', v === '' ? null : v)} + disabled={!cfg.weight_expected_g} + /> +
+ setCfg('unit_label', on ? null : false)} + label={__('Print a label for each unit when it is scanned')} + /> +

{__('Off: the label prints only for a unit that has no label yet.')}

+
+
+
{__('Label template')}
+ setCfg('label_template_id', v ? Number(v) : null)} + options={[ + { value: '', label: __('Default for the label type') }, + ...applicableTemplates.map((t) => ({ value: String(t.id), label: `${t.name} · ${t.size} mm` })), + ]} + className="w-full md:w-96" + /> +

{__(LEVEL_LABEL_HINT[level])}

+
+ + )} +
+ ); +} + function Isa95StepFields({ data, setData, workstationTypes = [] }) { return (
@@ -258,6 +373,8 @@ function AddStepForm({ productType, processTemplate, processSegments, workstatio is_optional: false, variant_group: '', is_default_variant: false, + kind: 'production', + config: {}, }); const { data, setData, errors, processing } = form; @@ -368,6 +485,7 @@ function AddStepForm({ productType, processTemplate, processSegments, workstatio />
+ setData('parameters', v)} /> @@ -400,6 +518,8 @@ function EditStepForm({ step, productType, processTemplate, processSegments, wor is_optional: !!step.is_optional, variant_group: step.variant_group ?? '', is_default_variant: !!step.is_default_variant, + kind: step.kind ?? 'production', + config: step.config ?? {}, }); const { data, setData, errors, processing } = form; @@ -496,6 +616,7 @@ function EditStepForm({ step, productType, processTemplate, processSegments, wor />
+ setData('parameters', v)} /> @@ -849,6 +970,11 @@ function StepCard({

{step.name} + {step.kind === 'packing' && ( + + {__('Packing')} + + )} {step.is_optional && ( {__('Optional')} @@ -1012,6 +1138,7 @@ export default function ProcessTemplatesShow() { const railSubline = (st) => [ st.workstation?.name ?? st.workstation_type?.name ?? null, st.estimated_duration_minutes != null ? `${st.estimated_duration_minutes}m` : null, + st.kind === 'packing' ? __('packing') : null, st.variant_group ? __('variant') : null, st.is_optional ? __('optional') : null, ].filter(Boolean).join(' · '); diff --git a/backend/resources/js/Pages/admin/traceability/Index.jsx b/backend/resources/js/Pages/admin/traceability/Index.jsx index 9acb512f0..35cb5ab5e 100644 --- a/backend/resources/js/Pages/admin/traceability/Index.jsx +++ b/backend/resources/js/Pages/admin/traceability/Index.jsx @@ -1,741 +1,275 @@ import { useState } from 'react'; import { Head, Link, router, usePage } from '@inertiajs/react'; -import { Button } from '@openmes/ui'; -import { DataTable } from '@openmes/ui/table'; +import { Breadcrumbs, Button, Icon, StatusBadge, Tabs, TextField } from '@openmes/ui'; import AppLayout from '../../../layouts/AppLayout'; -import { __, formatNumber } from '../../../lib/i18n'; +import PageTitle from '../../../components/PageTitle'; +import ResourceTable from '../../../components/ResourceTable'; +import { woColumns } from '../work-orders/columns'; +import { STATUS_STYLES as LOT_STATUS_STYLES, materialLotStatusLabel } from '../material-lots/fields'; +import { serialStatusBadge, serialStatusLabel } from '../../../lib/serialStatus'; +import { __ } from '../../../lib/i18n'; +import { BatchResult, ComponentResult, MaterialLotResult, OrderResult, PalletResult, SerialResult, traceLink } from './results'; /** - * Traceability / genealogy console. Resolves a finished LOT, material lot, - * supplier LOT or serial number (server-side) and renders its genealogy. - * Props: { term, result } — result.type is 'batch' | 'material_lot' | 'serial'. + * Traceability / genealogy console. + * + * With nothing traced it is a place to find the thing: work orders, material + * lots and pallets as searchable tables whose rows open their trace, so nobody + * has to copy a number out of another screen first. The identifier box stays for + * what no table lists - a scanned label, a serial number, a supplier LOT, a + * customer order - and resolves server-side. + * + * Props: { term, result, lineNames, productTypeNames, customerNames, + * materialNames, workOrderNumbers } - result.type is 'work_order' | + * 'customer_order' | 'pallet' | 'batch' | 'material_lot' | 'serial'. */ +const RESULT_TAB = { + component: 'orders', + work_order: 'orders', + customer_order: 'orders', + batch: 'lots', + material_lot: 'lots', + serial: 'units', + pallet: 'pallets', +}; + export default function TraceabilityIndex() { const { term = '', result = null } = usePage().props; const [q, setQ] = useState(term); + const [tab, setTab] = useState(() => { + const param = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('tab') : null; + return ['orders', 'units', 'lots', 'pallets'].includes(param) ? param : 'orders'; + }); + + // The tab lives in the address (?tab=…) so a refresh, a shared link or the + // browser's Back lands on the same list. Replaced in place, not pushed: it is + // a view of one page, not a new one, and the tables must not remount. + const selectTab = (next) => { + setTab(next); + if (typeof window !== 'undefined') { + const url = new URL(window.location.href); + if (next === 'orders') url.searchParams.delete('tab'); else url.searchParams.set('tab', next); + window.history.replaceState(window.history.state, '', url); + } + }; const submit = (e) => { e.preventDefault(); - router.get('/admin/traceability', { q }, { preserveState: true, preserveScroll: true }); + if (q.trim() === '') return; + router.get('/admin/traceability', { q: q.trim() }); }; + const tracing = term !== ''; + return ( <> -
-
-

{__('Traceability')}

-

- {__('Trace a pallet number, customer order, finished LOT, material lot, supplier LOT, source container or serial number through its full genealogy.')} -

-
- - {/* Search */} - -
{__('Search')}
-
- setQ(e.target.value)} - autoFocus - placeholder={__('Pallet no, customer order, finished LOT, material lot, supplier LOT, source container or serial number…')} - className="form-input flex-1" - /> - -
+
+ + {tracing && ( + + + {__('Back to the list')} + + )} + + - {term !== '' && !result && ( -
- - - -

- {__('No finished LOT, material lot or serial number matches')} {term}. -

-
- )} - - {result?.type === 'pallet' && } - {result?.type === 'customer_order' && } - {result?.type === 'batch' && } - {result?.type === 'material_lot' && } - {result?.type === 'serial' && } -
- - ); -} - -TraceabilityIndex.layout = (page) => {page}; - -/* ── helpers ─────────────────────────────────────────────────────────── */ - -function Card({ children, className = '' }) { - return ( -
- {children} -
- ); -} - -function traceLink(lotNumber) { - return `/admin/traceability?q=${encodeURIComponent(lotNumber)}`; -} - -/* ── Finished batch (backward genealogy) ─────────────────────────────── */ - -const INGREDIENT_LOT_COLUMNS = [ - { - id: 'material', - accessorFn: (r) => r.material ?? '', - header: __('Material'), - cell: ({ row }) => ( - - {row.original.material ?? '—'} {row.original.material_code} - - ), - }, - { - id: 'lot_number', - accessorKey: 'lot_number', - header: __('LOT'), - cell: ({ row }) => ( - - {row.original.lot_number} - - ), - }, - { - id: 'supplier_lot_no', - accessorFn: (r) => r.supplier_lot_no ?? '', - header: __('Supplier LOT'), - cell: ({ row }) => {row.original.supplier_lot_no ?? '—'}, - }, - { - id: 'source_container_no', - accessorFn: (r) => r.source_container_no ?? '', - header: __('Source container'), - cell: ({ row }) => {row.original.source_container_no ?? '—'}, - }, - { - id: 'status', - accessorKey: 'status', - header: __('Status'), - cell: ({ row }) => ( - {row.original.status} - ), - }, -]; - -function BatchResult({ data }) { - const b = data.batch; - const lots = data.distinct_input_lots ?? []; - - return ( -
- -
-
- {__('Finished LOT')} -

{b.lot_number}

-

- {__('Work Order')}: {b.work_order?.order_no ?? '—'} - {' · '}{__('Product')}: {b.work_order?.product ?? '—'} - {' · '}{__('Batch')} #{b.batch_number} -

-
- - {__('Backward trace')} - -
-
- - -

{__('Ingredient lots')} ({lots.length})

- {lots.length === 0 ? ( -

{__('No material lots were recorded as consumed for this batch.')}

- ) : ( - )} -
- - -

{__('Process history')}

-
- {b.steps.map((step) => ( -
-
- {__('Step')} {step.step_number}: {step.name} - {step.workstation && {step.workstation}} - {step.completed_by && {__('by')} {step.completed_by}} - {step.completed_at && {step.completed_at}} -
- {step.consumptions.length > 0 && ( -
    - {step.consumptions.map((c, i) => ( -
  • - - {c.lot_number} - {c.material} - — {formatNumber(c.quantity, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
  • - ))} -
- )} -
- ))} -
- - {b.output_lots.length > 0 && ( -
-

{__('Output lots')}

- {b.output_lots.map((out, i) => ( - - {out.lot_number} - - ))} -
- )} -
-
- ); -} - -/* ── Material lot (forward + backward) ───────────────────────────────── */ - -function MaterialLotResult({ forward, backward, recall }) { - return ( -
- - {__('Material lot')} -

{forward.lot.lot_number}

- {backward.supplier_lot_no && ( -

{__('Supplier LOT')}: {backward.supplier_lot_no}

- )} - {backward.source_container_no && ( -

{__('Source container')}: {backward.source_container_no}

- )} -
- - {recall && } - - {/* Forward */} - -
-

{__('Forward trace — where did this lot go?')}

- - {forward.work_orders.length} {__('work orders')} - -
- {forward.work_orders.length === 0 ? ( -

- {forward.is_finished_good ? __('This is a finished-goods lot - it was not consumed further.') : __('This lot has not been consumed yet.')} -

- ) : ( - <> -
    - {forward.work_orders.map((wo, i) => ( -
  • - {wo.order_no} - {wo.product} - {wo.status} -
  • - ))} -
-

- {__('Total consumed')}: {formatNumber(Number(forward.total_consumed), { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -

- - )} -
- - {/* Finished-goods forward leg: output pallet(s) + customer order(s) */} - {forward.is_finished_good && ( - -

{__('Forward trace - packed & shipped')}

+
-
-
-

{__('Output pallets')} ({forward.pallets.length})

- {forward.pallets.length === 0 ? ( -

{__('This finished lot has not been packed onto a pallet yet.')}

- ) : ( -
    - {forward.pallets.map((p, i) => ( -
  • - {p.pallet_no} - {p.status} - {p.qty}{p.location ? ` · ${p.location}` : ''}{p.shipped_at ? ` · ${p.shipped_at}` : ''} -
  • - ))} -
- )} + {!tracing && } + + {tracing && ( +
+ {/* The browse tables put this trail in the header; a result has no table to do it. */} + + + + + {!result && ( +
+ +

+ {__('Nothing traceable matches')} {term}. +

-
-

{__('Customer orders')} ({forward.customer_orders.length})

- {forward.customer_orders.length === 0 ? ( -

—

- ) : ( -
- {forward.customer_orders.map((co, i) => ( - {co} - ))} -
- )} -
-
- - )} - - {/* Backward */} - -

{__('Backward trace — what fed into this lot?')}

- {backward.source_batch_id ? ( - <> -

- {__('Produced by batch')} #{backward.source_batch?.batch_number ?? backward.source_batch_id} - {backward.source_batch?.lot_number && ( - <> ({backward.source_batch.lot_number}) - )} -

- - - ) : ( -
-

{__('Inbound raw lot (terminal).')}

- {backward.supplier_reference &&

{__('Supplier reference')}: {backward.supplier_reference}

} - {backward.inspection_id &&

{__('Inbound inspection')} #{backward.inspection_id}

} -
- )} -
-
- ); -} - -/** Recursive backward genealogy node (mirrors the old _ingredient-tree partial). */ -function IngredientTree({ node }) { - if (!node.ingredients || node.ingredients.length === 0) return null; - - return ( -
    - {node.ingredients.map((child, i) => ( -
  • -
    - - {child.lot?.lot_number ?? '—'} - - {child.material?.name ?? ''} - {child.supplier_lot_no && ( - {__('Supplier LOT')}: {child.supplier_lot_no} - )} - {child.source_container_no && ( - {__('Source container')}: {child.source_container_no} - )} - {child.source_batch_id && ( - {__('semi-finished')} - )} -
    - {child.truncated ? ( -

    {__('Trace truncated (max depth reached).')}

    - ) : ( - )} -
  • - ))} -
- ); -} - -/* ── Pallet (pallet → batch → lots → machine → operator → controls) ──── */ - -function PalletResult({ data }) { - const p = data.pallet; - const wo = data.work_order; - const batch = data.batch; - return ( -
- -
-
- {__('Pallet')} -

{p.pallet_no}

-

- {__('Work Order')}: {wo?.order_no ?? '—'} - {' · '}{__('Product')}: {wo?.product ?? '—'} - {wo?.customer_order_no && <> {' · '}{__('Customer Order No')}: {wo.customer_order_no}} -

-

- {__('Quantity')}: {p.qty}{p.location ? ` · ${p.location}` : ''}{p.created_at ? ` · ${p.created_at}` : ''} -

-
- {p.status} + {result?.type === 'pallet' && } + {(result?.type === 'work_order' || result?.type === 'customer_order') && } + {result?.type === 'batch' && } + {result?.type === 'material_lot' && } + {result?.type === 'serial' && } + {result?.type === 'component' && }
-
- - {!batch ? ( - -

{__('This pallet is not linked to a batch, so no genealogy is available.')}

-
- ) : ( - <> - -
-
- {__('Batch')} -

- #{batch.batch_number} - {batch.lot_number && {batch.lot_number}} -

- {batch.machine &&

{__('Machine')}: {batch.machine}

} -
- {batch.status} -
-
- - -

{__('Process history')}

-
- {batch.steps.map((step, idx) => ( -
-
- {__('Step')} {step.step_number}: {step.name} - {step.machine && {__('Machine')}: {step.machine}} - {step.line && {__('Line')}: {step.line}} - {step.operator && {__('Operator')}: {step.operator}} - {step.completed_at && {step.completed_at}} -
- {step.consumptions.length > 0 && ( -
    - {step.consumptions.map((c, i) => ( -
  • - - {c.lot_number} - {c.material} - — {formatNumber(c.quantity, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
  • - ))} -
- )} -
- ))} -
-
- - -

{__('Quality controls')} ({batch.quality_checks.length})

- {batch.quality_checks.length === 0 ? ( -

{__('No quality controls recorded for this batch.')}

- ) : ( -
- {batch.quality_checks.map((qc, i) => ( -
-
- - {qc.all_passed ? __('Passed') : __('Failed')} - - {qc.checked_by && {__('by')} {qc.checked_by}} - {qc.checked_at && {qc.checked_at}} -
- {qc.samples.length > 0 && ( -
- {qc.samples.map((s, si) => ( - - {s.parameter}: {s.value == null ? '—' : String(s.value)} - - ))} -
- )} -
- ))} -
- )} -
- )} -
+ ); } -/* ── Customer order (aggregate of work orders) ───────────────────────── */ +/* ── Browse: find the thing to trace ─────────────────────────────────── */ -function CustomerOrderResult({ data }) { - const wos = data.work_orders ?? []; +const WO_BROWSE_COLUMNS = ['order_no', 'customer', 'line', 'product', 'qty', 'status', 'due_date', 'completed_at', 'customer_order_no', 'created_at']; - return ( -
- - {__('Customer order')} -

{data.customer_order_no}

-

{wos.length} {__('work orders')}

-
- - {wos.length === 0 ? ( -

{__('No work orders match this customer order.')}

- ) : ( - wos.map((wo, i) => ( - -
- {wo.order_no} - {wo.product} - {wo.status} -
- -
-
-

{__('Pallets')} ({wo.pallets.length})

- {wo.pallets.length === 0 ? ( -

—

- ) : wo.pallets.map((p, pi) => ( -
- {p.pallet_no} - {p.status} - {p.batch_lot && {p.batch_lot}} -
- ))} -
-
-

{__('Batches')} ({wo.batches.length})

- {wo.batches.length === 0 ? ( -

—

- ) : wo.batches.map((b, bi) => ( -
-
- #{b.batch_number} - {b.lot_number && {b.lot_number}} - {b.status} -
- {b.output_lots?.length > 0 && ( -
- {__('Output lots')}: - {b.output_lots.map((o, oi) => ( - {o.lot_number} - ))} -
- )} - {b.components?.length > 0 && ( -
- {__('Components used')}: - {b.components.map((c, ci) => ( - {c.lot_number} - ))} -
- )} -
- ))} -
-
-
- )) - )} -
- ); -} - -/* ── Recall impact (reverse trace — affected finished goods) ─────────── */ - -const SERIAL_STATUS_BADGE = { - scrapped: 'bg-om-blocked-bg text-om-blocked', - shipped: 'bg-om-done-bg text-om-done', - completed: 'bg-om-done-bg text-om-done', +const PALLET_STATUS_STYLES = { + open: 'bg-om-running-bg text-om-running', + closed: 'bg-om-chip text-om-accent', + shipped: 'bg-om-chip text-om-muted', }; -function RecallImpact({ recall }) { - const workOrders = recall.work_orders ?? []; - const totals = recall.totals ?? { work_orders: 0, finished_serials: 0, quantity_consumed: 0 }; - - return ( - -
-

{__('Recall impact')}

-
- - {totals.work_orders} {__('work orders')} - - - {totals.finished_serials} {__('units')} - -
-
-

{__('Finished work orders and units that contain this component.')}

- - {workOrders.length === 0 ? ( -

{__('No downstream consumption recorded yet.')}

- ) : ( -
- {workOrders.map((wo, i) => ( -
-
- {wo.order_no} - {wo.product ?? '—'} - {wo.status} - - {__('Consumed')}: {formatNumber(Number(wo.quantity_consumed), { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - - {wo.batches?.length > 0 && ( - {__('Batches')}: #{wo.batches.join(', #')} - )} -
- {wo.finished_serials?.length > 0 && ( -
- {__('Units')} ({wo.finished_serials.length}): - {wo.finished_serials.map((u, j) => ( - - {u.serial_no} - - ))} -
- )} -
- ))} - {recall.truncated && ( -

{__('Trace truncated (max depth reached).')}

- )} -
- )} -
- ); -} - -/* ── Component journeys (finished unit → component → lines passed through) ─ */ - -const COMPONENT_STATUS_DONE = 'DONE'; - -function ComponentJourneys({ components }) { - const list = components ?? []; +/** + * One table per kind of traceable thing. Each is the same synced collection its + * own admin list shows, so it is live and needs no endpoint of its own; the row's + * identifier and its Trace action both open the trace. + */ +function BrowseTable({ tab }) { + const { + lineNames = {}, productTypeNames = {}, customerNames = {}, materialNames = {}, workOrderNumbers = {}, + } = usePage().props; + + const traceAction = (identifier) => (r) => [{ label: 'Trace', icon: 'open', href: traceLink(identifier(r)) }]; + + if (tab === 'units') { + return ( + traceLink(r.serial_no ?? r.psn)} + columns={[ + { key: 'serial_no', label: __('Serial No'), className: 'font-mono', filter: 'text', link: true }, + { key: 'psn', label: __('Process serial (PSN)'), className: 'font-mono text-om-muted', filter: 'text', render: (r) => r.psn || '—' }, + { key: 'work_order', label: __('Work Order'), className: 'font-mono text-om-muted', value: (r) => workOrderNumbers[r.work_order_id] ?? '', render: (r) => workOrderNumbers[r.work_order_id] ?? '—' }, + { key: 'material', label: __('Material'), className: 'text-om-muted', value: (r) => materialNames[r.material_id] ?? '', render: (r) => materialNames[r.material_id] ?? '—' }, + { + key: 'status', + label: __('Status'), + value: (r) => serialStatusLabel(r.status), + render: (r) => , + }, + { key: 'produced_at', label: __('Produced'), filter: 'date', className: 'text-om-muted', render: (r) => (r.produced_at ? String(r.produced_at).slice(0, 10) : '—') }, + { key: 'created_at', label: __('Registered'), filter: 'date', className: 'text-om-muted', render: (r) => (r.created_at ? String(r.created_at).slice(0, 10) : '—') }, + ]} + orderBy="created_at" + orderDir="desc" + actions={traceAction((r) => r.serial_no ?? r.psn)} + enableSelection={false} + emptyText="No serial units yet." + /> + ); + } + + if (tab === 'lots') { + return ( + traceLink(r.lot_number)} + columns={[ + { key: 'lot_number', label: __('Lot Number'), className: 'font-mono', filter: 'text', link: true }, + { key: 'material', label: __('Material'), className: 'text-om-muted', value: (r) => materialNames[r.material_id] ?? '', render: (r) => materialNames[r.material_id] ?? '—' }, + { key: 'supplier_lot_no', label: __('Supplier LOT'), className: 'font-mono text-om-muted', render: (r) => r.supplier_lot_no || '—' }, + { key: 'source_container_no', label: __('Source container'), className: 'font-mono text-om-muted', render: (r) => r.source_container_no || '—' }, + { key: 'received_at', label: __('Received'), filter: 'date', className: 'text-om-muted', render: (r) => (r.received_at ? String(r.received_at).slice(0, 10) : '—') }, + { + key: 'status', + label: __('Status'), + value: (r) => r.status, + render: (r) => ( + + {materialLotStatusLabel(r.status)} + + ), + }, + ]} + orderBy="received_at" + orderDir="desc" + actions={traceAction((r) => r.lot_number)} + enableSelection={false} + emptyText="No material lots yet." + /> + ); + } + + if (tab === 'pallets') { + return ( + traceLink(r.pallet_no)} + columns={[ + { key: 'pallet_no', label: __('Pallet number'), className: 'font-mono', filter: 'text', link: true }, + { key: 'work_order', label: __('Work Order'), className: 'font-mono text-om-muted', value: (r) => workOrderNumbers[r.work_order_id] ?? '', render: (r) => workOrderNumbers[r.work_order_id] ?? '—' }, + { key: 'qty', label: __('Quantity') }, + { + key: 'status', + label: __('Status'), + value: (r) => r.status, + render: (r) => ( + {r.status} + ), + }, + { key: 'destination', label: __('Destination'), className: 'text-om-muted', render: (r) => r.destination || '—' }, + { key: 'created_at', label: __('Created'), filter: 'date', className: 'text-om-muted', render: (r) => (r.created_at ? String(r.created_at).slice(0, 10) : '—') }, + ]} + orderBy="pallet_no" + orderDir="desc" + actions={traceAction((r) => r.pallet_no)} + enableSelection={false} + emptyText="No pallets yet." + /> + ); + } + + // The work orders list's own column definitions, narrowed to what helps pick + // an order out - so status pills, quantities and dates read the same here. + const columns = woColumns({ lineNames, productTypeNames, customerNames, detailHref: (r) => traceLink(r.order_no) }) + .filter((c) => WO_BROWSE_COLUMNS.includes(c.key)); return ( - -

{__('Components & production lines')}

-

- {__('Lines and workstations each component passed through during its own production.')} -

- - {list.length === 0 ? ( -

{__('No components recorded for this unit.')}

- ) : ( -
- {list.map((c, i) => ( -
-
- - {c.lot_number ?? '—'} - - {c.material ?? ''} - {c.material_code && {c.material_code}} - {c.lines?.length > 0 && ( - - {c.lines.map((ln, j) => ( - - {ln.name} - - ))} - - )} -
- - {c.is_raw ? ( -

- {__('Raw material (supplied) - no internal production line.')} - {c.supplier_lot_no && <> · {__('Supplier LOT')}: {c.supplier_lot_no}} -

- ) : c.steps?.length > 0 ? ( -
    - {c.steps.map((s, j) => ( -
  • - - {__('Step')} {s.step_number}: {s.name} - {s.line && {s.line}} - {s.workstation && {s.workstation}} - {s.completed_by && {__('by')} {s.completed_by}} - {s.completed_at && {s.completed_at}} -
  • - ))} -
- ) : ( -

{__('No production steps recorded for this component.')}

- )} -
- ))} -
- )} -
+ traceLink(r.order_no)} + columns={columns} + orderBy="created_at" + orderDir="desc" + actions={traceAction((r) => r.order_no)} + enableSelection={false} + emptyText="No work orders yet." + /> ); } -/* ── Serial unit (per-unit history) ──────────────────────────────────── */ - -function SerialResult({ unit, recall, components }) { - const RESULT_BADGE = { - pass: 'bg-om-done-bg text-om-done', - fail: 'bg-om-blocked-bg text-om-blocked', - }; - - return ( -
- - {__('Serial unit')} -

{unit.serial_no}

-

- {__('Product')}: {unit.product ?? '—'} - {unit.work_order && <> · {__('Work Order')}: {unit.work_order}} - {' · '}{unit.status} -

-
- - - - {recall && } - - -

{__('Process history')} ({unit.history.length})

- {unit.history.length === 0 ? ( -

{__('No processing steps recorded for this unit yet.')}

- ) : ( -
- {unit.history.map((h, i) => ( -
-
- {h.workstation ?? __('Unknown')} - {h.line && {h.line}} - {h.step && {h.step}} - {h.operator && {__('by')} {h.operator}} - {h.processed_at && {h.processed_at}} - {h.result && ( - - {h.result} - - )} -
- {h.parameters && Object.keys(h.parameters).length > 0 && ( -
- {Object.entries(h.parameters).map(([pk, pv]) => ( - - {pk}: {typeof pv === 'object' ? JSON.stringify(pv) : String(pv)} - - ))} -
- )} -
- ))} -
- )} -
-
- ); -} +TraceabilityIndex.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/traceability/results.jsx b/backend/resources/js/Pages/admin/traceability/results.jsx new file mode 100644 index 000000000..c7d381b06 --- /dev/null +++ b/backend/resources/js/Pages/admin/traceability/results.jsx @@ -0,0 +1,795 @@ +import { useMemo } from 'react'; +import { Link } from '@inertiajs/react'; +import { Badge, Button, Icon, StatusBadge, Stepper } from '@openmes/ui'; +import { DataTable } from '@openmes/ui/table'; +import { __, formatNumber } from '../../../lib/i18n'; +import { woStatusBadge, woStatusLabel } from '../work-orders/fields'; +import { materialLotStatusBadge } from '../material-lots/fields'; +import { serialStatusBadge, serialStatusLabel } from '../../../lib/serialStatus'; + +/** + * The trace result views, one per kind of thing the console resolves. They + * share the work-order detail page's vocabulary - a mono identifier with its + * status chip on top, titled cards below, mono for anything you would read + * out - so a trace reads like the rest of admin rather than a report of its own. + */ + +export function traceLink(identifier) { + return `/admin/traceability?q=${encodeURIComponent(identifier)}`; +} + +const qty = (n) => formatNumber(Number(n ?? 0), { maximumFractionDigits: 2 }); + +/* ── status chips ────────────────────────────────────────────────────── */ + +// Batch statuses share the work-order vocabulary, so their labels come from the +// same map - a batch chip next to an order chip reads 'Done' / 'Done', not +// 'Done' / 'DONE'. +const BATCH_META = { + PENDING: { tone: 'neutral', icon: 'clock', label: () => woStatusLabel('PENDING') }, + IN_PROGRESS: { tone: 'active', icon: 'play', label: () => woStatusLabel('IN_PROGRESS') }, + DONE: { tone: 'success', icon: 'circle-check', label: () => woStatusLabel('DONE') }, + CANCELLED: { tone: 'ghost', icon: 'slash', label: () => woStatusLabel('CANCELLED') }, +}; + +const PALLET_META = { + open: { tone: 'active', icon: 'package-open', label: () => __('Pallet open') }, + closed: { tone: 'info', icon: 'package', label: () => __('Closed') }, + shipped: { tone: 'success', icon: 'truck', label: () => __('Shipped') }, +}; + + +const RESULT_META = { + pass: { tone: 'success', icon: 'circle-check', label: () => __('Passed') }, + fail: { tone: 'critical', icon: 'x', label: () => __('Failed') }, + rework: { tone: 'warn', icon: 'rotate-ccw', label: () => __('Rework') }, +}; + +// A skipped step was passed deliberately: it draws as travelled (done), and its +// caption says so, rather than as a hollow 'not yet' that reads as stuck. +const STEP_STATUS = { DONE: 'done', SKIPPED: 'done', IN_PROGRESS: 'active', READY: 'active', BLOCKED: 'blocked' }; +const stepCaption = (status) => (status === 'SKIPPED' ? __('Skipped') : __(status)); + +function chip(meta, status, size = 'sm') { + const m = meta[status]; + return ; +} + +const OrderChip = ({ status, size = 'sm' }) => ; +const LotChip = ({ status, size = 'sm' }) => ; +const BatchChip = ({ status, size = 'sm' }) => chip(BATCH_META, status, size); +const PalletChip = ({ status, size = 'sm' }) => chip(PALLET_META, status, size); +const SerialChip = ({ status, size = 'sm' }) => ; + +/* ── building blocks (the work-order page's Card / Field / Row) ──────── */ + +/** Mono identifier, its status, and one line of facts - the page's headline. */ +function Header({ kind, id, status, facts = [], children }) { + return ( +
+
+
{kind}
+
+

{id}

+ {status} +
+ {facts.filter(Boolean).length > 0 && ( +

{facts.filter(Boolean).join(' · ')}

+ )} +
+ {children} +
+ ); +} + +/** A titled panel; `action` sits on the title row (a counter, a link). */ +function Card({ title, action, children, bodyClassName = 'px-[22px] pb-5' }) { + return ( +
+
+

{title}

+ {action} +
+
{children}
+
+ ); +} + +/** One labelled fact. Mono for anything you would read out or compare. */ +function Field({ label, mono = false, children, className = '' }) { + return ( +
+
{label}
+
{children ?? '—'}
+
+ ); +} + +function Row({ label, value, last = false }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Empty({ children }) { + return

{children}

; +} + +/** An identifier that opens its own trace. */ +function Ref({ id, className = '' }) { + if (!id) return —; + return {id}; +} + +/** + * Identifiers as a chip row - for a handful of things, not a list. Items are + * strings, or `{ id, tone?, title? }` when a chip carries its own state. + */ +function RefChips({ items = [] }) { + if (items.length === 0) return —; + return ( +
+ {items.map((item, i) => { + const { id, tone = 'neutral', title } = typeof item === 'string' ? { id: item } : item; + return ( + + {id} + + ); + })} +
+ ); +} + +const dash = (v) => (v == null || v === '' ? '—' : v); + +/** + * A span of seconds as the two units that matter at that size — "42s", + * "5m 10s", "1h 12m", "3d 4h" — in the language-neutral suffixes the rest of + * the app uses for elapsed time. + */ +function span(seconds) { + if (seconds == null || Number.isNaN(Number(seconds))) return null; + const s = Math.max(0, Math.round(Number(seconds))); + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + if (m > 0) return sec > 0 ? `${m}m ${sec}s` : `${m}m`; + return `${sec}s`; +} +const spanMin = (minutes) => (minutes == null ? null : span(minutes * 60)); + +/* ── shared sections ─────────────────────────────────────────────────── */ + +/** + * Batch routing as the same Stepper the work-order page draws, with the lots + * consumed at each step listed under it - that is the genealogy, step by step. + */ +function ProcessHistory({ steps = [], station = (s) => s.workstation, operator = (s) => s.completed_by }) { + if (steps.length === 0) return {__('No production steps recorded.')}; + return ( + ({ + key: step.id ?? step.step_number, + label: step.step_number, + title: step.name, + status: STEP_STATUS[step.status] ?? 'pending', + description: [ + step.status === 'SKIPPED' ? stepCaption(step.status) : null, + station(step), operator(step), + // Start → end when the start is known; the end alone otherwise. + step.started_at && step.completed_at ? `${step.started_at} → ${step.completed_at}` : (step.completed_at ?? (step.started_at ? `${__('Started')} ${step.started_at}` : null)), + ].filter(Boolean).join(' · ') || stepCaption(step.status), + meta: (step.duration_minutes != null || step.waited_minutes != null) ? ( + + {step.duration_minutes != null && {spanMin(step.duration_minutes)}} + {step.waited_minutes != null && {__('waited :time', { time: spanMin(step.waited_minutes) })}} + + ) : null, + // Lots consumed at this step, under the caption rather than in the + // Stepper's action slot: that slot cannot shrink, and a step with + // several long material names would squeeze the title to an ellipsis. + body: step.consumptions?.length > 0 ? : null, + }))} + /> + ); +} + +function Consumptions({ items }) { + return ( +
    + {items.map((c, i) => ( +
  • + + {c.material} + {qty(c.quantity)} +
  • + ))} +
+ ); +} + +// Built per render, not at module load: `__()` needs the catalogue, which is +// not there yet when this file is first imported. +const lotColumns = () => [ + { id: 'lot_number', accessorKey: 'lot_number', header: __('LOT'), cell: ({ row }) => }, + { id: 'material', accessorFn: (r) => r.material ?? '', header: __('Material'), cell: ({ row }) => {row.original.material ?? '—'} {row.original.material_code} }, + { id: 'supplier_lot_no', accessorFn: (r) => r.supplier_lot_no ?? '', header: __('Supplier LOT'), cell: ({ row }) => {dash(row.original.supplier_lot_no)} }, + { id: 'source_container_no', accessorFn: (r) => r.source_container_no ?? '', header: __('Source container'), cell: ({ row }) => {dash(row.original.source_container_no)} }, + { id: 'status', accessorKey: 'status', header: __('Status'), cell: ({ row }) => }, +]; + +function LotTable({ lots }) { + const columns = useMemo(lotColumns, []); + return ; +} + +function QualityControls({ checks = [] }) { + if (checks.length === 0) return {__('No quality controls recorded for this batch.')}; + return ( +
    + {checks.map((qc, i) => ( +
  • + + + {qc.checked_by && <>{__('by')} {qc.checked_by}} + {qc.checked_at && {qc.checked_at}} + + {qc.samples?.length > 0 && ( +
    + {qc.samples.map((s, si) => ( + {s.parameter}: {s.value == null ? '—' : String(s.value)} + ))} +
    + )} +
  • + ))} +
+ ); +} + +/** Reverse trace: the finished orders and units this component ended up in. */ +function RecallImpact({ recall }) { + const workOrders = recall.work_orders ?? []; + const totals = recall.totals ?? { work_orders: 0, finished_serials: 0 }; + return ( + + 0 ? 'danger' : 'neutral'}>{totals.work_orders} {__('work orders')} + {totals.finished_serials} {__('units')} +
+ } + > + {workOrders.length === 0 ? ( + {__('No downstream consumption recorded yet.')} + ) : ( +
    + {workOrders.map((wo, i) => ( +
  • +
    + + {wo.product ?? '—'} + + + {__('Consumed')}: {qty(wo.quantity_consumed)} + {wo.batches?.length > 0 && <> · {__('Batches')}: #{wo.batches.join(', #')}} + +
    + {wo.finished_serials?.length > 0 && ( +
    + ({ + id: u.serial_no ?? u.psn, + tone: u.status === 'scrapped' ? 'danger' : 'neutral', + title: u.status ? serialStatusLabel(u.status) : undefined, + }))} + /> +
    + )} +
  • + ))} + {recall.truncated &&
  • {__('Trace truncated (max depth reached).')}
  • } +
+ )} + + ); +} + +/* ── results ─────────────────────────────────────────────────────────── */ + +export function BatchResult({ data }) { + const b = data.batch; + const lots = data.distinct_input_lots ?? []; + return ( + <> +
} + facts={[b.work_order?.product, b.work_order?.order_no, __('Batch #:number', { number: b.batch_number })]} + /> +
+ + {lots.length === 0 ? {__('No material lots were recorded as consumed for this batch.')} : } + + + + {b.output_lots?.length > 0 && ( +
+ o.lot_number)} /> +
+ )} +
+
+ + ); +} + +export function MaterialLotResult({ forward, backward, recall, units = [] }) { + const lot = forward.lot; + const installedIn = units.filter((u) => u.installed); + return ( + <> +
} + facts={[ + backward.material?.name, + backward.supplier_lot_no && `${__('Supplier LOT')} ${backward.supplier_lot_no}`, + backward.source_container_no && `${__('Source container')} ${backward.source_container_no}`, + ]} + /> +
+ {recall && } + + {units.length > 0 && ( + {installedIn.length} {__('units')}} + bodyClassName="" + > + }, + { id: 'psn', accessorFn: (r) => r.psn ?? '', header: __('Process serial (PSN)'), cell: ({ row }) => {dash(row.original.psn)} }, + { id: 'work_order', accessorFn: (r) => r.work_order ?? '', header: __('Work Order'), cell: ({ row }) => {dash(row.original.work_order)} }, + { id: 'product', accessorFn: (r) => r.product ?? '', header: __('Product'), cell: ({ row }) => dash(row.original.product) }, + { id: 'bound_at', accessorFn: (r) => r.bound_at ?? '', header: __('Bound'), cell: ({ row }) => {dash(row.original.bound_at)}{row.original.unbound_at ? ` → ${row.original.unbound_at}` : ''} }, + { id: 'status', accessorKey: 'status', header: __('Status'), cell: ({ row }) => {!row.original.installed && {__('removed')}} }, + ]} + searchable={false} + columnToggle={false} + paginated={units.length > 25} + /> + + )} + + {forward.work_orders.length} {__('work orders')}} + > + {forward.work_orders.length === 0 ? ( + {forward.is_finished_good ? __('This is a finished-goods lot - it was not consumed further.') : __('This lot has not been consumed yet.')} + ) : ( +
+ {forward.work_orders.map((wo, i) => ( +
+ + {wo.product} + +
+ ))} + +
+ )} +
+ + {forward.is_finished_good && ( + +
+ + {forward.pallets.length === 0 ? ( + {__('This finished lot has not been packed onto a pallet yet.')} + ) : ( +
    + {forward.pallets.map((p, i) => ( +
  • + + + {p.qty}{p.location ? ` · ${p.location}` : ''}{p.shipped_at ? ` · ${p.shipped_at}` : ''} +
  • + ))} +
+ )} +
+ +
+
+ )} + + + {backward.source_batch_id ? ( + <> +

+ {__('Produced by batch')} #{backward.source_batch?.batch_number ?? backward.source_batch_id} + {backward.source_batch?.lot_number && <> ()} +

+ + + ) : ( +
+ {__('Inbound raw lot (terminal).')} + {dash(backward.supplier_reference)} + {backward.inspection_id ? `#${backward.inspection_id}` : '—'} +
+ )} +
+
+ + ); +} + +/** Recursive backward genealogy: each ingredient lot, then what fed *it*. */ +function IngredientTree({ node }) { + if (!node.ingredients || node.ingredients.length === 0) return null; + return ( +
    + {node.ingredients.map((child, i) => ( +
  • +
    + + {child.material?.name ?? ''} + {child.lot?.status && } + {child.source_batch_id && {__('semi-finished')}} + {(child.supplier_lot_no || child.source_container_no) && ( + + {child.supplier_lot_no && <>{__('Supplier LOT')} {child.supplier_lot_no}} + {child.supplier_lot_no && child.source_container_no && ' · '} + {child.source_container_no && <>{__('Source container')} {child.source_container_no}} + + )} +
    + {child.truncated ?

    {__('Trace truncated (max depth reached).')}

    : } +
  • + ))} +
+ ); +} + +export function PalletResult({ data }) { + const p = data.pallet; + const wo = data.work_order; + const batch = data.batch; + return ( + <> +
} + facts={[wo?.product, wo?.order_no, `${qty(p.qty)} ${__('pcs')}`, p.location, p.created_at]} + /> +
+ +
+ + + {batch ? #{batch.batch_number}{batch.lot_number && } : '—'} + {dash(batch?.machine)} +
+
+ + {!batch ? ( + {__('This pallet is not linked to a batch, so no genealogy is available.')} + ) : ( + <> + + s.machine} operator={(s) => s.operator} /> + + + {batch.input_lots?.length ? : {__('No material lots were recorded as consumed for this batch.')}} + + + + + + )} + + {data.units?.length > 0 && ( + } onClick={() => window.open(`/packaging/labels/pallet/${p.id}/packing-list`, '_blank', 'noopener')}>{__('Packing list')}} + > + }, + { id: 'psn', accessorFn: (r) => r.psn ?? '', header: __('Process serial (PSN)'), cell: ({ row }) => {dash(row.original.psn)} }, + { id: 'carton_no', accessorFn: (r) => r.carton_no ?? '', header: __('Carton'), cell: ({ row }) => {dash(row.original.carton_no)} }, + { id: 'packed_at', accessorFn: (r) => r.packed_at ?? '', header: __('Packed'), cell: ({ row }) => {dash(row.original.packed_at)} }, + { id: 'status', accessorKey: 'status', header: __('Status'), cell: ({ row }) => }, + ]} + searchable={false} + columnToggle={false} + paginated={false} + /> + + )} +
+ + ); +} + +/** One work order (by its number) or every order under a customer order. */ +export function OrderResult({ data }) { + const wos = data.work_orders ?? []; + const single = Boolean(data.order_no); + return ( + <> +
: {wos.length} {__('work orders')}} + facts={single ? [wos[0]?.product, data.customer_order_no && `${__('Customer Order No')} ${data.customer_order_no}`] : []} + /> +
+ {wos.length === 0 && {__('No work orders match this customer order.')}} + {wos.map((wo, i) => ( + {wo.product}} + action={single ? null : } + > +
+ + {wo.batches.length === 0 ? '—' : ( +
    + {wo.batches.map((b, bi) => ( +
  • +
    + #{b.batch_number} + + +
    + {b.output_lots?.length > 0 &&
    {__('Output lots')} o.lot_number)} />
    } + {b.components?.length > 0 &&
    {__('Components used')} ({ id: c.lot_number, title: c.material }))} />
    } +
  • + ))} +
+ )} +
+ + {wo.pallets.length === 0 ? '—' : ( +
    + {wo.pallets.map((p, pi) => ( +
  • + + + {p.batch_lot && {p.batch_lot}} +
  • + ))} +
+ )} +
+
+
+ ))} +
+ + ); +} + +/** + * A history row's headline, from the event the station recorded: "Label + * applied" reads better than the bench's name, and a row with no station at + * all (an import, a supervisor's action from the office) still says what happened. + */ +const EVENT_TITLE = { + issued: () => __('Numbers issued'), + started: (p) => __('Started on process serial :psn', { psn: p.psn ?? '' }), + subassembly_registered: (p) => __('Sub-assembly registered: :material', { material: p.material ?? '' }), + // A hold the tests put on names the count; a hand-made one its error code. + blocked: (p) => (p.hold_source === 'test' + ? __('Blocked after :count failed tests', { count: p.failed_attempts ?? '' }) + : __('Blocked: :code :reason', { code: p.reason_code ?? '', reason: p.reason ?? '' })), + unblocked: (p) => (p.released_by === 'test' ? __('Released by a passing retest') : __('Unit released')), + weight_check: (p) => __('Weight :weight g outside :expected ± :tolerance g', { weight: p.weight_g, expected: p.expected_g, tolerance: p.tolerance_g }), + label_applied: () => __('Label applied'), + process_serial_rebound: () => __('Process serial re-bound'), + component_bound: (p) => __('Component :id bound', { id: p.identifier ?? '' }), + component_unbound: (p) => __('Component :id removed', { id: p.identifier ?? '' }), + packed: (p) => (p.carton_no ? __('Packed into :carton', { carton: p.carton_no }) : __('Packed')), + palletised: (p) => __('Put on pallet :pallet', { pallet: p.pallet_no ?? '' }), + shipped: (p) => __('Shipped on pallet :pallet', { pallet: p.pallet_no ?? '' }), + scrapped: () => __('Scrapped'), + test: (p) => __('Test at :station', { station: p.station ?? '—' }), +}; +const HIDDEN_PARAMS = new Set(['event', 'steps', 'source', 'run_id', 'psn', 'carton_no', 'pallet_no', 'identifier', 'started_at', 'ended_at', 'reason_code', 'reason', 'category', 'hold_source', 'released_by', 'failed_attempts', 'verdict', 'expected_g', 'tolerance_g']); + +/** A history row's headline (the event, at its station) - also used by the operator's order view. */ +export function historyTitle(h) { + const p = h.parameters ?? {}; + const byEvent = EVENT_TITLE[p.event]?.(p); + if (byEvent) return h.workstation ? `${byEvent} · ${h.workstation}` : byEvent; + return h.workstation ?? h.step ?? __('Unknown'); +} + +export function SerialResult({ unit, recall, components, installed = [], installedIn = [] }) { + const history = unit.history ?? []; + return ( + <> +
} + facts={[ + unit.psn && `${__('Process serial (PSN)')} ${unit.psn}`, + unit.product, + unit.work_order && `${__('Work Order')} ${unit.work_order}`, + unit.carton_no && `${__('Carton')} ${unit.carton_no}`, + unit.pallet_no && `${__('Pallet')} ${unit.pallet_no}`, + unit.shipped_at && `${__('Shipped')} ${unit.shipped_at}`, + unit.lead_time_seconds != null && (unit.lead_time_to_shipping + ? __('Lead time :time (first record to shipping)', { time: span(unit.lead_time_seconds) }) + : __('Lead time :time so far', { time: span(unit.lead_time_seconds) })), + ]} + /> +
+ + {history.length === 0 ? {__('No processing steps recorded for this unit yet.')} : ( + ({ + key: i, + // The result sits by the title, the way a batch row carries + // its status: the caption below stays one full line and the + // parameters on the right keep one shared edge. + title: ( + // Wraps rather than truncates: a tester's long step name would + // otherwise hide the result chip on a narrow screen. + + {historyTitle(h)} + {h.result && h.parameters?.event !== 'scrapped' && chip(RESULT_META, h.result)} + + ), + status: h.result === 'fail' || h.parameters?.event === 'blocked' ? 'blocked' : 'done', + description: [ + h.line, h.step, h.operator && `${__('by')} ${h.operator}`, h.processed_at, + // How long after the previous record, and a test's own run time. + h.since_previous_seconds != null && `+${span(h.since_previous_seconds)}`, + h.duration_seconds != null && __('took :time', { time: span(h.duration_seconds) }), + h.notes, + ].filter(Boolean).join(' · '), + // Measurements and the like; the identifiers already sit in the title. + // Under the caption, not in the action slot: that slot cannot + // shrink, and a test's parameters would cut the title and date off. + body: h.parameters && Object.keys(h.parameters).some((k) => !HIDDEN_PARAMS.has(k)) ? ( +
+ {Object.entries(h.parameters).filter(([k]) => !HIDDEN_PARAMS.has(k)).map(([k, v]) => {k}: {typeof v === 'object' ? JSON.stringify(v) : String(v)})} +
+ ) : null, + }))} + /> + )} +
+ + {/* A sub-assembly registered by its serial: which products it went into. */} + {installedIn.length > 0 && } + + + {(components?.length > 0 || installed.length === 0) && } + + {recall && } +
+ + ); +} + +const COMPONENT_KIND = { + serial_unit: () => __('Serialised unit'), + material_lot: () => __('Material lot'), + identifier: () => __('Identifier'), +}; + +/** What was scanned into the unit, as built and as it is now. */ +function InstalledComponents({ items }) { + const columns = useMemo(() => [ + { id: 'identifier', accessorKey: 'identifier', header: __('Component'), cell: ({ row }) => }, + { id: 'material', accessorFn: (r) => r.material ?? '', header: __('Material'), cell: ({ row }) => {row.original.material ?? '—'} {row.original.material_code} }, + { id: 'kind', accessorKey: 'kind', header: __('Resolved as'), cell: ({ row }) => {COMPONENT_KIND[row.original.kind]?.() ?? row.original.kind} }, + { id: 'quantity', accessorFn: (r) => Number(r.quantity ?? 1), header: __('Quantity'), meta: { align: 'right' }, cell: ({ row }) => {qty(row.original.quantity)} }, + { id: 'bound_at', accessorKey: 'bound_at', header: __('Bound'), cell: ({ row }) => {row.original.bound_at ?? '—'}{row.original.workstation ? ` · ${row.original.workstation}` : ''}{row.original.bound_by ? ` · ${row.original.bound_by}` : ''} }, + { id: 'installed', accessorKey: 'installed', header: __('Status'), cell: ({ row }) => row.original.installed + ? + : }, + ], []); + + return ( + i.installed).length})`} bodyClassName={items.length ? '' : 'px-[22px] pb-5'}> + {items.length === 0 + ? {__('No components were scanned into this unit.')} + : } + + ); +} + +/** The units a component went into: installed now, or taken out since. */ +function InstalledInList({ units }) { + return units.length === 0 ? {__('This component has not been scanned into any unit.')} : ( +
    + {units.map((u, i) => ( +
  • + + {u.psn && {u.psn}} + {u.product ?? ''} + {u.work_order && } + {u.status && } + + {u.installed ? `${__('Bound')} ${u.bound_at ?? ''}` : `${__('Removed')} ${u.unbound_at ?? ''}`} + {u.workstation ? ` · ${u.workstation}` : ''} + +
  • + ))} +
+ ); +} + +/** A component known by the serial on its label: every unit it was scanned into. */ +export function ComponentResult({ data }) { + const units = data.units ?? []; + const installedIn = units.filter((u) => u.installed); + return ( + <> +
{installedIn.length} {__('units')}} + facts={[data.material?.name, data.material?.code]} + /> +
+ + + +
+ + ); +} + +/** Each component of a unit, and the lines it passed through being made. */ +function ComponentJourneys({ components }) { + const list = components ?? []; + return ( + + {list.length === 0 ? {__('No components recorded for this unit.')} : ( +
    + {list.map((c, i) => ( +
  • +
    + + {c.material ?? ''} + {c.material_code && {c.material_code}} + {c.lines?.map((ln, j) => {ln.name})} +
    + {c.is_raw ? ( +

    + {__('Raw material (supplied) - no internal production line.')} + {c.supplier_lot_no && <> · {__('Supplier LOT')} {c.supplier_lot_no}} +

    + ) : c.steps?.length > 0 ? ( +
    [s.line, s.workstation].filter(Boolean).join(' / ')} />
    + ) : ( +

    {__('No production steps recorded for this component.')}

    + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/backend/resources/js/Pages/admin/users/Edit.jsx b/backend/resources/js/Pages/admin/users/Edit.jsx index 4f8526d49..ea3bd2c5f 100644 --- a/backend/resources/js/Pages/admin/users/Edit.jsx +++ b/backend/resources/js/Pages/admin/users/Edit.jsx @@ -2,9 +2,10 @@ import { Head, Link, useForm, usePage } from '@inertiajs/react'; import { __ } from '../../../lib/i18n'; import AppLayout from '../../../layouts/AppLayout'; import UserForm from './UserForm'; +import { moduleFieldInitial } from '../../../lib/moduleFields'; export default function UserEdit() { - const { user, assignments = {}, roles = [], workstations = [], crews = [], wageGroups = [], skills = [] } = usePage().props; + const { user, assignments = {}, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], hooks = {} } = usePage().props; const w = user.worker; const form = useForm({ @@ -21,6 +22,7 @@ export default function UserEdit() { worker_crew_id: w?.crew_id != null ? String(w.crew_id) : '', worker_wage_group_id: w?.wage_group_id != null ? String(w.wage_group_id) : '', skills: w?.skills ?? [], + ...moduleFieldInitial(hooks, 'display.admin.users.form.fields'), }); const submit = (e) => { @@ -39,7 +41,7 @@ export default function UserEdit() {
{__('Regular workstation')}: {assignments.station ? {assignments.station.name} : {__('Create a worker profile below, then assign it to a workstation.')}}

{__('The operator selects the working station after login using Change line.')}

} - +
); } diff --git a/backend/resources/js/Pages/admin/users/UserForm.jsx b/backend/resources/js/Pages/admin/users/UserForm.jsx index 7843244a7..432c2744f 100644 --- a/backend/resources/js/Pages/admin/users/UserForm.jsx +++ b/backend/resources/js/Pages/admin/users/UserForm.jsx @@ -3,6 +3,7 @@ import { __ } from '../../../lib/i18n'; import { Button, Checkbox, Dropdown, RadioGroup } from '@openmes/ui'; import { useState } from 'react'; import { nameControl } from '../../../lib/fieldName'; +import ModuleFields from '../../../components/ModuleFields'; /** * Bespoke create/edit form for user accounts. Conditional on `account_type`: @@ -13,7 +14,7 @@ import { nameControl } from '../../../lib/fieldName'; * password is confirmed (password + password_confirmation). On edit, leaving * password blank keeps the current one. */ -export default function UserForm({ form, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], isEdit, onSubmit, bare = false, onCancel }) { +export default function UserForm({ form, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], hooks, isEdit, onSubmit, bare = false, onCancel }) { const { data, setData, errors, processing } = form; const isUser = data.account_type === 'user'; @@ -176,6 +177,14 @@ export default function UserForm({ form, roles = [], workstations = [], crews =

)} + +
- - {/* Detail arrow */} - router.visit(`/operator/work-order/${wo.id}`)}> - - - - - - ); -} - // ─── ACTIVE WORK ORDER CARD ─────────────────────────────────────────────────── function ActiveWoCard({ wo, lineStatuses, workflowMode, doneStatusIds, onReport, onDoneQty }) { @@ -651,6 +562,111 @@ const completedColumns = [ }, ]; +// The active orders table: the app's DataTable (search, per-column filters, +// column picker) instead of a hand-rolled . Columns depend on the line's +// board statuses and the modal openers, so they are built inside the page. +function activeColumns({ lineStatuses, cycleStatus, onReport }) { + // String headers (built per render, so the catalogue is loaded): the column + // picker names a column by its header only when that is a string. + const link = (wo, children, cls = '') => {children}; + return [ + { + id: 'order_no', + accessorKey: 'order_no', + header: __("Order No"), + cell: ({ row }) => link(row.original, row.original.order_no, 'font-mono text-[13px] font-semibold text-om-ink whitespace-nowrap'), + }, + { + id: 'status', + accessorFn: (r) => woStatusLabel(r.status), + header: __("Status"), + cell: ({ row }) => , + }, + ...(lineStatuses.length > 0 ? [{ + id: 'board_status', + accessorFn: (r) => r.line_status?.name ?? '—', + header: __("Board Status"), + cell: ({ row }) => ( + + ), + }] : []), + { + id: 'product', + accessorFn: (r) => r.product_type?.name ?? '—', + header: __("Product"), + cell: ({ row }) => link(row.original, row.original.product_type?.name ?? '—', 'text-[13.5px] font-medium text-om-ink'), + }, + { + id: 'qty', + accessorFn: (r) => parseFloat(r.produced_qty) || 0, + header: __("Qty (done / planned)"), + meta: { filter: false }, + cell: ({ row }) => { + const wo = row.original; + const planned = parseFloat(wo.planned_qty) || 0; + const produced = parseFloat(wo.produced_qty) || 0; + const pct = planned > 0 ? Math.min((produced / planned) * 100, 100) : 0; + return link(wo, ( + <> + {fmtQty(produced)} / {fmtQty(planned)} + {planned > 0 && ( + <> + ({fmtQty(pct)}%) + + + )} + + ), 'block'); + }, + }, + { + id: 'batches', + accessorFn: (r) => (r.batches ? r.batches.length : 0), + header: __("Batches"), + meta: { align: 'center', filter: false }, + cell: ({ row }) => {row.original.batches ? row.original.batches.length : 0}, + }, + { + id: 'priority', + accessorFn: (r) => r.priority ?? 0, + header: __("Priority"), + meta: { align: 'center', filter: false }, + cell: ({ row }) => {row.original.priority || '—'}, + }, + { + id: 'due_date', + accessorKey: 'due_date', + header: __("Due"), + meta: { filter: 'date' }, + cell: ({ row }) => {fmtDate(row.original.due_date, 'short')}, + }, + { + id: '_actions', + header: __("Actions"), + enableSorting: false, + enableHiding: false, + meta: { align: 'right', chrome: true }, + cell: ({ row }) => { + const wo = row.original; + return ( +
+ + +
+ ); + }, + }, + ]; +} + // ─── PAGE ──────────────────────────────────────────────────────────────────── export default function Queue() { @@ -697,6 +713,22 @@ export default function Queue() { const openDoneQty = ({ woId, woNo, statusId }) => setDoneQtyModal({ open: true, woId, woNo, statusId }); const closeDoneQty = () => setDoneQtyModal((s) => ({ ...s, open: false })); + // Tapping the board-status badge walks the line's statuses in order (none → first → … → none); + // a "done" status asks for the quantity first, or opens the order when it keeps a step ledger. + const cycleStatus = (wo) => { + if (!lineStatuses.length) return; + const currentId = wo.line_status_id ? parseInt(wo.line_status_id) : null; + const ids = [null, ...lineStatuses.map((s) => parseInt(s.id))]; + const nextId = ids[(ids.indexOf(currentId) + 1) % ids.length]; + if (workflowMode === 'board_status' && nextId !== null && doneStatusIds.map(Number).includes(nextId)) { + if (wo.uses_step_ledger) { router.visit(`/operator/work-order/${wo.id}`); return; } + openDoneQty({ woId: wo.id, woNo: wo.order_no, statusId: nextId }); + return; + } + router.post(`/operator/work-order/${wo.id}/line-status`, { line_status_id: nextId ?? '' }); + }; + const activeCols = useMemo(() => activeColumns({ lineStatuses, cycleStatus, onReport: openReport }), [lineStatuses, workflowMode, doneStatusIds]); // eslint-disable-line react-hooks/exhaustive-deps + const showWorkstationFilter = trackingMode !== 'cumulative' && lineWorkstations.length > 0; @@ -881,9 +913,7 @@ export default function Queue() { return ( + className="block p-4 rounded-om border border-om-line bg-om-card hover:bg-om-panel transition-colors group">
{wo.order_no} + className="block p-4 rounded-om border border-om-line bg-om-card hover:bg-om-panel transition-colors">
{wo.order_no} @@ -981,43 +1011,13 @@ export default function Queue() { <> {/* Table view */} {view === 'table' && ( -
-
-
- - - - - {lineStatuses.length > 0 && ( - - )} - - - - - - - - - - {activeWorkOrders.map((wo) => ( - - ))} - -
{__("Order No")}{__("Status")} - {__("Board Status")} - - ↻ - - {__("Product")}{__("Qty (done / planned)")}{__("Batches")}{__("Priority")}{__("Due")}{__("Actions")} -
-
-
+ String(wo.id)} + pageSize={10} + onRowDoubleClick={(wo) => router.visit(`/operator/work-order/${wo.id}`)} + /> )} {/* Card view */} diff --git a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx index 01f6d7bff..ccf870755 100644 --- a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx +++ b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx @@ -1,17 +1,24 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; -import { Badge, Button, Checkbox, Dropdown, Icon, ProgressBar, StatusPill } from '@openmes/ui'; +import { Badge, Button, Checkbox, Dropdown, Icon, ProgressBar, StatusBadge, StatusPill } from '@openmes/ui'; import { DataTable } from '@openmes/ui/table'; import OperatorLayout from '../../layouts/OperatorLayout'; +import AppDataTable from '../../components/AppDataTable'; import LineSync from '../../components/LineSync'; import LabelPrintMenu from '../../components/LabelPrintMenu'; import CustomFields from '../../components/CustomFields'; import Tooltip from '../../components/Tooltip'; +import RoutingGraph from '../../components/flow/RoutingGraph'; +import QuantityField from '../../components/QuantityField'; +import { Hook } from '../../lib/hooks'; import EngineeringViewerModal from '../../components/EngineeringViewerModal'; import { packageMeta, isInteractive, formatBytes } from '../../components/engineeringDocuments'; import { apiGet, apiCall } from '../../lib/http'; import { customFieldInitial, customFieldProps, submitForm } from '../../lib/customFieldForm'; import { __, formatDate, formatDateTime, formatNumber } from '../../lib/i18n'; +import { readParam, writeParams } from '../../lib/urlState'; +import { serialStatusBadge, serialStatusLabel } from '../../lib/serialStatus'; +import { historyTitle } from '../admin/traceability/results'; // Geist White restyle: light-only v1 — former `dark:` variants removed. @@ -62,6 +69,40 @@ function bomTypeBadge(type) { // Shared Geist White idiom classes const cardCls = 'bg-om-card border border-om-line rounded-om p-6'; const sectionLabelCls = 'font-mono text-[10px] uppercase tracking-[0.12em] text-om-faint'; + +/** + * The order's serialised units: PSN, serial number, status and the last thing + * that happened to each (started at the first bench, label applied, tested, packed ...). + * The step counters above are quantities the benches log; this is the pieces. + */ +function SerialUnitsSection({ data }) { + const units = data?.units ?? []; + const columns = useMemo(() => [ + { id: 'serial_no', accessorKey: 'serial_no', header: __('Serial number'), cell: ({ row }) => {row.original.serial_no ?? '—'} }, + { id: 'psn', accessorKey: 'psn', header: __('PSN'), cell: ({ row }) => {row.original.psn ?? '—'} }, + { id: 'status', accessorFn: (r) => serialStatusLabel(r.status), header: __('Status'), cell: ({ row }) => }, + { id: 'last', accessorFn: (r) => (r.last ? historyTitle(r.last) : ''), header: __('Last event'), cell: ({ row }) => { + const l = row.original.last; + return l ? ( + + {historyTitle(l)} + {formatDateTime(l.at)} + + ) : '—'; + } }, + { id: 'packed', accessorFn: (r) => [r.carton, r.pallet].filter(Boolean).join(' · '), header: __('Carton / pallet'), cell: ({ row }) => {[row.original.carton, row.original.pallet].filter(Boolean).join(' · ') || '—'} }, + ], []); + if (!data?.total) return null; + return ( +
+
+

{__('Units in this order')}

+ {data.total > units.length ? __(':shown of :total', { shown: units.length, total: data.total }) : data.total} +
+ +
+ ); +} const fieldLabelCls = 'block mb-[7px] font-mono text-[9.5px] uppercase tracking-[0.08em] text-om-faint'; const inputCls = 'w-full text-[13px] text-om-ink placeholder:text-om-faint bg-om-bg border border-om-line rounded-om-sm px-3 py-2.5 outline-none transition-colors focus:border-om-accent focus:shadow-[0_0_0_3px_rgba(234,90,43,0.12)]'; @@ -395,12 +436,11 @@ function QualityCheckForm({ batch, onClose }) {
{__('Production Quantity')}
- setProductionQty(e.target.value)} + onChange={setProductionQty} className={`${inputCls} font-mono`} placeholder={__('Current production qty')} /> @@ -445,7 +485,7 @@ function QualityCheckForm({ batch, onClose }) { {__('Submit QC')}
@@ -495,7 +535,7 @@ function PackagingChecklistForm({ batch, onClose }) { {__('Submit Checklist')}
@@ -523,13 +563,12 @@ function ReleaseForm({ batch, onClose }) {
{__('Scrap quantity (optional)')}
- form.setData('scrap_qty', e.target.value)} + onChange={(v) => form.setData('scrap_qty', v)} className={`${inputCls} w-32 font-mono`} placeholder="0" /> @@ -815,7 +854,7 @@ function QuantityCorrection({ step }) { {__('Correct good quantity')}
{ e.preventDefault(); form.post(`/operator/batch-step/${step.id}/quantity-correction`, { preserveScroll: true }); }}>

{__('Enter the corrected total, not an increment. The original and corrected values are retained in the audit history.')}

- + {Object.entries(form.errors).map(([key, value]) =>

{value}

)} @@ -846,11 +885,11 @@ function QuantityLogForm({ step, throughStation = false, inflight, error, onSubm )} + {focusStep && ( + + )}
+ {steps.length > 1 && ( +
+ +

{__('Click a step to show only its station and jump to it; click it again to show everything.')}

+
+ )}
- {groups.filter(group => !stationScoped || showAll || group.workstationId === selectedWorkstation.id).map((group, gi) => { + {groups.filter(groupVisible).map((group, gi) => { const mine = stationScoped && group.workstationId === selectedWorkstation.id; const last = group.steps[group.steps.length - 1]; const done = group.steps.filter((st) => st.status === 'DONE' || st.status === 'SKIPPED').length; @@ -1742,13 +1814,12 @@ function LotPickModal({ step, materials, onClose }) {
- setLineQty(m.material_id, idx, e.target.value)} + onChange={(v) => setLineQty(m.material_id, idx, v)} className="text-[12px] text-om-ink bg-om-bg border border-om-line rounded-om-sm px-2 py-1 outline-none w-20 text-right focus:border-om-accent transition-colors font-mono" /> @@ -1843,14 +1914,13 @@ function CreateBatchModal({ workOrder, workstations, defaultWorkstationId, onClo
{__('Quantity')}
- form.setData('target_qty', e.target.value)} + onChange={(v) => form.setData('target_qty', v)} className={`${inputCls} font-mono text-[15px]`} required /> @@ -2085,13 +2155,12 @@ function ReportScrapModal({ workOrder, scrapReasons, onClose }) {
{__('Quantity')} *
- form.setData('quantity', e.target.value)} + onChange={(v) => form.setData('quantity', v)} className={`${inputCls} font-mono text-[15px]`} placeholder="0" required @@ -2207,10 +2276,16 @@ function EngineeringDocsSection({ docs = [], onView }) { // --------------------------------------------------------------------------- export default function WorkOrderDetail() { - const { workOrder, issueTypes = [], scrapReasons = [], workstations = [], issueCustomFields = [], defaultWorkstationId, line, labelTemplates = [], processPhotos = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, stepOutputs = {}, engineeringDocuments = [], materialShortages = [], flowMode = 'whole_batch', selectedWorkstation = null } = usePage().props; + const { workOrder, issueTypes = [], scrapReasons = [], workstations = [], issueCustomFields = [], defaultWorkstationId, line, labelTemplates = [], processPhotos = [], stepPhotos = {}, stepMedia = {}, stepChecklists = {}, stepOutputs = {}, engineeringDocuments = [], materialShortages = [], flowMode = 'whole_batch', selectedWorkstation = null, serialUnits = { total: 0, units: [] }, hooks = {} } = usePage().props; const [engViewer, setEngViewer] = useState(null); // { url, title } for the sandboxed viewer + // A link carrying `?step=` opens the batch that owns that step. + const [urlStepBatchId] = useState(() => { + const id = Number(readParam('step')); + return id ? (workOrder.batches ?? []).find((b) => (b.steps ?? []).some((st) => st.id === id))?.id ?? null : null; + }); + async function openEngViewer(doc) { try { const res = await apiGet(`/api/v1/engineering-documents/${doc.document_id}/viewer-url`); @@ -2382,6 +2457,9 @@ export default function WorkOrderDetail() { {/* Recipe / BOM */} + {/* Sections an installed module adds after the BOM — nothing on a community install */} + + {/* Process reference photos (work instructions) */} @@ -2414,7 +2492,7 @@ export default function WorkOrderDetail() { )}
+ + {/* The pieces this order is made of: each unit's numbers and where it was last seen. */} +
{/* Sidebar */} diff --git a/backend/resources/js/Pages/operator/Workstation.jsx b/backend/resources/js/Pages/operator/Workstation.jsx index ff385769b..bceb168cc 100644 --- a/backend/resources/js/Pages/operator/Workstation.jsx +++ b/backend/resources/js/Pages/operator/Workstation.jsx @@ -1,13 +1,18 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { __ } from '../../lib/i18n'; import { Head, Link, router, usePage } from '@inertiajs/react'; -import { Button, Checkbox, Dropdown, IconButton, StatusPill } from '@openmes/ui'; +import { Button, Dropdown, IconButton, StatusPill } from '@openmes/ui'; import OperatorLayout from '../../layouts/OperatorLayout'; import LineSync from '../../components/LineSync'; import LabelPrintMenu from '../../components/LabelPrintMenu'; import Tooltip from '../../components/Tooltip'; +import AppDataTable from '../../components/AppDataTable'; +import usePrompt from '../../components/usePrompt'; +import RoutingGraph from '../../components/flow/RoutingGraph'; import DueCountdown, { SETTLED_STATUSES } from '../../components/DueCountdown'; import { formatDate, formatNumber } from '../../lib/i18n'; +import { Hook, hasRenderableHook } from '../../lib/hooks'; +import QuantityField from '../../components/QuantityField'; // Geist White restyle: light-only v1 — former `dark:` variants removed. @@ -79,44 +84,6 @@ const inputCls = 'w-full text-[13px] text-om-ink placeholder:text-om-faint bg-om-bg border border-om-line rounded-om-sm px-3 py-2.5 outline-none transition-colors focus:border-om-accent focus:shadow-[0_0_0_3px_rgba(234,90,43,0.12)]'; const modalFooterCls = 'flex gap-3 border-t border-om-line2 bg-om-panel px-[18px] py-[14px]'; -// ─── column visibility hook ────────────────────────────────────────────────── - -function useVisibleColumns(allColumns, lineId) { - const storageKey = `ws_cols_${lineId}`; - const defaultVisible = allColumns.filter((c) => c.default).map((c) => c.key); - - const [visibleKeys, setVisibleKeys] = useState(() => { - try { - const saved = localStorage.getItem(storageKey); - return saved ? JSON.parse(saved) : defaultVisible; - } catch { - return defaultVisible; - } - }); - - const toggleColumn = useCallback( - (key) => { - setVisibleKeys((prev) => { - const next = prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]; - try { - localStorage.setItem(storageKey, JSON.stringify(next)); - } catch {} - return next; - }); - }, - [storageKey], - ); - - const resetColumns = useCallback(() => { - setVisibleKeys(defaultVisible); - try { - localStorage.removeItem(storageKey); - } catch {} - }, [storageKey]); // eslint-disable-line react-hooks/exhaustive-deps - - return { visibleKeys, toggleColumn, resetColumns }; -} - // ─── timed-correction link ─────────────────────────────────────────────────── function TimedCorrectLink({ entry, qtyEditPolicy, qtyEditWindowMinutes }) { @@ -155,7 +122,12 @@ function TimedCorrectLink({ entry, qtyEditPolicy, qtyEditWindowMinutes }) { // ─── shift cell ───────────────────────────────────────────────────────────── +const SHIFT_CELL_HOOK = 'display.operator.workstation.shift_cell'; + function ShiftCell({ wo, shift, shiftEntries, qtyEditPolicy, qtyEditWindowMinutes }) { + // Read page-wide rather than threaded through every row: the controller + // resolved this page's hook points into the `hooks` prop. + const { hooks } = usePage().props; const isDone = wo.status === 'DONE'; const entryKey = `${wo.id}_${shift.id}`; const entriesForCell = shiftEntries[entryKey] ?? []; @@ -182,9 +154,9 @@ function ShiftCell({ wo, shift, shiftEntries, qtyEditPolicy, qtyEditWindowMinute if (isDone || wo.uses_step_ledger) { return ( - e.stopPropagation()}> +
e.stopPropagation()}> {entryQty > 0 ? Math.round(entryQty) : 0} - +
); } @@ -196,8 +168,27 @@ function ShiftCell({ wo, shift, shiftEntries, qtyEditPolicy, qtyEditWindowMinute (qtyEditPolicy === 'timed' && new Date(firstEntry.updated_at).getTime() + qtyEditWindowMinutes * 60 * 1000 > Date.now())); + // A module that contributes to this point replaces the whole editable cell — + // input and correction link — so core draws neither. Read-only cells above + // (done / step ledger) stay core's, and so does this one when the module's + // component is not in this build. + if (hasRenderableHook(hooks, SHIFT_CELL_HOOK)) { + return ( + e.stopPropagation()}> + + + ); + } + return ( - e.stopPropagation()}> +
e.stopPropagation()}>
)}
- +
); } @@ -339,11 +330,11 @@ function CompleteModal({ modal, onClose }) {
Quantity *
- setQty(e.target.value)} + onChange={setQty} className="w-full bg-om-bg border border-om-line rounded-om-sm px-3 py-4 font-mono text-[30px] font-medium tracking-[-0.02em] text-center text-om-ink placeholder:text-om-faintest outline-none transition-colors focus:border-om-accent focus:shadow-[0_0_0_3px_rgba(234,90,43,0.12)]" placeholder="0" min="0" @@ -552,85 +543,6 @@ function ReportModal({ report, issueTypes, onClose }) { ); } -// ─── column picker dropdown ─────────────────────────────────────────────────── - -function ColumnPicker({ allColumns, visibleKeys, toggleColumn, resetColumns }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e) => { - if (ref.current && !ref.current.contains(e.target)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const systemCols = allColumns.filter((c) => c.source !== 'extra_data'); - const extraCols = allColumns.filter((c) => c.source === 'extra_data'); - - return ( -
- - - - - {open && ( -
-
- {__("Columns")} - -
- -
{__('System fields')}
- {systemCols.map((col) => ( -
- toggleColumn(col.key)} - label={__(col.label)} - /> -
- ))} - - {extraCols.length > 0 && ( - <> -
{__('Import data')}
- {extraCols.map((col) => ( -
- toggleColumn(col.key)} - label={__(col.label)} - /> - {col.key} -
- ))} - - )} -
- )} -
- ); -} - // ─── status badge ───────────────────────────────────────────────────────────── function StatusBadge({ status }) { @@ -646,7 +558,7 @@ function StatusBadge({ status }) { return ; } -// ─── row ───────────────────────────────────────────────────────────────────── +// ─── quick step count ─────────────────────────────────────────────────────── function QuickStepCount({ order }) { const targets = order.quick_count_targets ?? []; @@ -672,170 +584,165 @@ function QuickStepCount({ order }) {
; } -function WorkOrderRow({ wo, allColumns, visibleKeys, lineShifts, shiftEntries, qtyEditPolicy, qtyEditWindowMinutes, onStart, onComplete, onInfo, onReport, labelTemplates = [] }) { +// ─── table columns ─────────────────────────────────────────────────────────── + +/** Whole-row tint for the order's state (the design's done / running / blocked surfaces). */ +function rowClassFor(wo) { + if (wo.status === 'DONE') return 'bg-om-done-bg/60'; + if (wo.status === 'IN_PROGRESS') return 'bg-om-running-bg/50'; + if (wo.status === 'BLOCKED') return 'bg-om-blocked-bg/50'; + return ''; +} + +/** The plain text behind a configurable column — what search, filters and sorting see. */ +function textValue(wo, col) { + if (col.source === 'extra_data') return formatExtraValue(wo.extra_data?.[col.key]) || ''; + if (col.source === 'product_type') return wo.product_type?.name ?? ''; + if (col.key === 'status') return statusLabel(wo.status); + if (col.key === 'week_number') return wo.week_number ? weekLabel(wo.week_number) : ''; + const v = wo[col.key]; + return v == null ? '' : String(v); +} + +function RowActions({ wo, labelTemplates, routingOpen, onToggleRouting, onComplete, onInfo, onReport }) { const isDone = wo.status === 'DONE'; - const isActive = wo.status === 'IN_PROGRESS'; const planned = parseFloat(wo.planned_qty ?? 0); const produced = parseFloat(wo.produced_qty ?? 0); const remaining = Math.max(0, planned - produced); - - const handleRowClick = () => { - if (wo.uses_step_ledger) { - router.visit(`/operator/work-order/${wo.id}`); - return; - } - if (isDone) return; - if (!isActive) { - onStart({ - open: true, - id: wo.id, - orderNo: wo.order_no, - product: wo.product_type?.name ?? wo.order_no, - qty: planned, - }); - } else { - onComplete({ - open: true, - id: wo.id, - orderNo: wo.order_no, - product: wo.product_type?.name ?? wo.order_no, - planned, - produced, - }); - } - }; - - const rowClass = [ - 'border-b border-om-line2 transition-colors border-l-[3px]', - isDone - ? 'bg-om-done-bg/60 border-l-om-done' - : isActive - ? 'bg-om-running-bg/50 border-l-om-running' - : wo.status === 'BLOCKED' - ? 'bg-om-blocked-bg/50 border-l-om-blocked' - : 'hover:bg-om-panel border-l-transparent', - !isDone ? 'cursor-pointer active:bg-om-chip' : '', - ] - .join(' ') - .trim(); - return ( - - {allColumns.map((col) => - visibleKeys.includes(col.key) ? ( - - {col.key === 'status' ? ( - - ) : ( - getCellValue(wo, col) - )} - - ) : null, + // The actions are not the row: a click here never starts or completes the order. +
e.stopPropagation()}> + {wo.uses_step_ledger && } + {wo.uses_step_ledger && ( + + {__('Record output')} + )} + {!isDone && !wo.uses_step_ledger && ( + + onComplete({ open: true, id: wo.id, orderNo: wo.order_no, product: wo.product_type?.name ?? wo.order_no, planned, produced })} + className="bg-om-accent hover:bg-om-accent hover:brightness-95" + aria-label="Add produced quantity" + > + + + + + )} + + onReport({ woId: wo.id, woNo: wo.order_no })} aria-label="Report problem"> + ! + + + + + onInfo({ + orderNo: wo.order_no, + product: wo.product_type?.name ?? '-', + line: wo.line?.name ?? '-', + status: statusLabel(wo.status), + planned: fmt(planned), + produced: fmt(produced), + remaining: fmt(remaining), + priority: wo.priority ?? '-', + dueDate: wo.due_date ? wo.due_date.substring(0, 10) : '-', + // Raw value too, so the details modal can put + // the countdown beside the date it prints. + dueRaw: wo.due_date ?? null, + settled: SETTLED_STATUSES.includes(wo.status), + description: wo.description ?? '-', + }) + } + aria-label="Details" + > + ? + + + {labelTemplates.some((t) => t.type === 'work_order') && ( + + )} + {(wo.routing ?? []).length > 0 && ( + + onToggleRouting(wo.id)} + aria-label={routingOpen ? __('Hide routing') : __('Show routing')} + aria-expanded={routingOpen} + data-testid={`routing-toggle-${wo.id}`} + > + ⇢ + + + )} +
+ ); +} - {/* To Produce */} - - {fmt(planned)} - - - {/* Produced */} - - {fmt(produced)} - - - {/* Remaining */} - - {fmt(remaining)} - - - {/* Shift cells */} - {lineShifts.map((shift) => ( - ({ + id: col.key, + accessorFn: (wo) => textValue(wo, col), + // A string header, not a render function: the column picker names the column by it. + header: __(col.label), + meta: col.key === 'due_date' ? { filter: 'date' } : col.key === 'status' ? { filter: 'select' } : {}, + cell: ({ row }) => (col.key === 'status' ? : getCellValue(row.original, col)), + })); + const qty = (accessor, cls) => ({ cell: ({ getValue }) => {fmt(getValue())} }); + const fixed = [ + { id: 'to_produce', accessorFn: (wo) => parseFloat(wo.planned_qty ?? 0) || 0, header: __('To Produce'), enableHiding: false, meta: { align: 'center', filter: false }, ...qty('planned', 'font-medium text-om-ink') }, + { id: 'produced', accessorFn: (wo) => parseFloat(wo.produced_qty ?? 0) || 0, header: __('Produced'), enableHiding: false, meta: { align: 'center', filter: false }, ...qty('produced', 'text-om-muted') }, + { + id: 'remaining', + accessorFn: (wo) => Math.max(0, (parseFloat(wo.planned_qty ?? 0) || 0) - (parseFloat(wo.produced_qty ?? 0) || 0)), + header: __('Remaining'), + enableHiding: false, + meta: { align: 'center', filter: false }, + cell: ({ getValue }) => { + const r = getValue(); + return ( + + {fmt(r)} + + ); + }, + }, + ...lineShifts.map((shift) => ({ + id: `shift_${shift.id}`, + accessorFn: (wo) => { const e = shiftEntries[`${wo.id}_${shift.id}`]?.[0]; return e ? parseFloat(e.quantity) || 0 : 0; }, + header: () => {shift.code}, + enableSorting: false, + enableHiding: false, + meta: { align: 'center', filter: false }, + cell: ({ row }) => , + })), + { + id: '_actions', + header: __('Actions'), + enableSorting: false, + enableHiding: false, + meta: { align: 'right', chrome: true, filter: false }, + cell: ({ row }) => ( + - ))} - - {/* Actions */} - e.stopPropagation()}> -
- {wo.uses_step_ledger && } - {wo.uses_step_ledger && ( - - {__('Record output')} - - )} - {!isDone && !wo.uses_step_ledger && ( - - - onComplete({ - open: true, - id: wo.id, - orderNo: wo.order_no, - product: wo.product_type?.name ?? wo.order_no, - planned, - produced, - }) - } - className="bg-om-accent hover:bg-om-accent hover:brightness-95" - aria-label="Add produced quantity" - > - + - - - )} - - onReport({ woId: wo.id, woNo: wo.order_no })} - aria-label="Report problem" - > - ! - - - - - onInfo({ - orderNo: wo.order_no, - product: wo.product_type?.name ?? '-', - line: wo.line?.name ?? '-', - status: statusLabel(wo.status), - planned: fmt(planned), - produced: fmt(produced), - remaining: fmt(remaining), - priority: wo.priority ?? '-', - dueDate: wo.due_date ? wo.due_date.substring(0, 10) : '-', - // Raw value too, so the details modal can put - // the countdown beside the date it prints. - dueRaw: wo.due_date ?? null, - settled: SETTLED_STATUSES.includes(wo.status), - description: wo.description ?? '-', - }) - } - aria-label="Details" - > - ? - - - {labelTemplates.some((t) => t.type === 'work_order') && ( - - )} -
- - - ); + ), + }, + ]; + return [...configurable, ...fixed]; } // ─── main page ─────────────────────────────────────────────────────────────── @@ -857,30 +764,62 @@ export default function Workstation() { machineStates = [], machineStateOptions = [], selectedWorkstation = null, + hooks = {}, } = usePage().props; - const { visibleKeys, toggleColumn, resetColumns } = useVisibleColumns(allColumns, line?.id ?? 0); - - const [searchVal, setSearchVal] = useState(searchProp ?? ''); + // Which configurable columns the reader keeps on, remembered per line in + // the browser (as the list of visible keys; `default` decides the first time). + const storageKey = `ws_cols_${line?.id ?? 0}`; + const [savedVisibility] = useState(() => { + try { + const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); + if (Array.isArray(saved)) return Object.fromEntries(allColumns.map((c) => [c.key, saved.includes(c.key)])); + } catch { /* fall through to the defaults */ } + return Object.fromEntries(allColumns.filter((c) => !c.default).map((c) => [c.key, false])); + }); + const rememberColumns = (visibility) => { + try { + localStorage.setItem(storageKey, JSON.stringify(allColumns.filter((c) => visibility[c.key] !== false).map((c) => c.key))); + } catch { /* private mode etc. — the choice just doesn't survive a reload */ } + }; // Modals const [startModal, setStartModal] = useState({ open: false }); const [completeModal, setCompleteModal] = useState({ open: false }); const [infoModal, setInfoModal] = useState(null); // null = closed, obj = open const [reportModal, setReportModal] = useState(null); // null = closed, obj = open + // Routing graphs folded out under orders: the header button opens every + // ledger order's, the row button just that one. + const [openRouting, setOpenRouting] = useState(() => new Set()); + const routingIds = workOrders.filter((wo) => (wo.routing ?? []).length > 0).map((wo) => wo.id); + const anyRouting = routingIds.length > 0; + const showRouting = anyRouting && routingIds.every((id) => openRouting.has(id)); + const toggleRouting = (id) => setOpenRouting((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + const toggleAllRouting = () => setOpenRouting(showRouting ? new Set() : new Set(routingIds)); + const openStep = (wo, stepId) => { + const params = new URLSearchParams(window.location.search); + const ctx = ['line', 'workstation'].filter((k) => params.has(k)).map((k) => `${k}=${encodeURIComponent(params.get(k))}`); + router.visit(`/operator/work-order/${wo.id}?${[...ctx, `step=${stepId}`].join('&')}`); + }; + + // Clicking a row: an order with a step ledger opens its page; otherwise + // the start / add-quantity dialog, as before. + const handleRowClick = (wo) => { + if (wo.uses_step_ledger) { router.visit(`/operator/work-order/${wo.id}`); return; } + if (wo.status === 'DONE') return; + const planned = parseFloat(wo.planned_qty ?? 0); + const produced = parseFloat(wo.produced_qty ?? 0); + if (wo.status !== 'IN_PROGRESS') { + setStartModal({ open: true, id: wo.id, orderNo: wo.order_no, product: wo.product_type?.name ?? wo.order_no, qty: planned }); + } else { + setCompleteModal({ open: true, id: wo.id, orderNo: wo.order_no, product: wo.product_type?.name ?? wo.order_no, planned, produced }); + } + }; // Only show shift columns for shifts belonging to this line const lineShifts = shifts.filter((s) => s.line_id === line?.id || String(s.line_id) === String(line?.id)); const hasShifts = lineShifts.length > 0; - const handleSearch = (e) => { - e.preventDefault(); - const params = {}; - if (searchVal) params.search = searchVal; - if (weekFilter && weekFilter !== 'all') params.week = weekFilter; - router.get('/operator/workstation', params); - }; - const weekUrl = (wk) => { const params = new URLSearchParams(); if (wk && wk !== 'all') params.set('week', wk); @@ -897,6 +836,11 @@ export default function Workstation() { return `/operator/workstation?${params}`; }; + const columns = useMemo(() => buildColumns({ + allColumns, lineShifts: hasShifts ? lineShifts : [], shiftEntries, qtyEditPolicy, qtyEditWindowMinutes, labelTemplates, + openRouting, onToggleRouting: toggleRouting, onComplete: setCompleteModal, onInfo: setInfoModal, onReport: setReportModal, + }), [allColumns, lineShifts, hasShifts, shiftEntries, qtyEditPolicy, qtyEditWindowMinutes, labelTemplates, openRouting]); // eslint-disable-line react-hooks/exhaustive-deps + return ( <> @@ -940,12 +884,11 @@ export default function Workstation() {
- + {anyRouting && ( + + )}
+ {/* Anything an installed module contributes to this screen. + Renders nothing on a community install. */} + + {/* Week filter */} {availableWeeks.length > 0 && (
@@ -992,106 +944,34 @@ export default function Workstation() {
)} - {/* Action buttons (disabled stubs) */} -
- - -
-

{__('Click a row to change production status. Use "Z1" or "Z2" columns to enter produced quantities per shift.')}

- {/* Search */} - - setSearchVal(e.target.value)} - placeholder={__('Search by order number, product or data...')} - className={`${inputCls} sm:w-96`} - autoComplete="off" - /> - - {/* Table */} {workOrders.length === 0 ? (

{__('No work orders found')}

) : ( -
-
- - - - {allColumns.map((col) => - visibleKeys.includes(col.key) ? ( - - ) : null, - )} - - - - {hasShifts && - lineShifts.map((shift) => ( - - ))} - - - - {workOrders.map((wo) => ( - - ))} - -
- {__(col.label)} - - {__('To Produce')} - - {__('Produced')} - - {__('Remaining')} - - {shift.code} - -
-
-
+ String(wo.id)} + columnVisibility={savedVisibility} + onColumnVisibilityChange={rememberColumns} + paginated={false} + striped={false} + rowClassName={rowClassFor} + onRowClick={handleRowClick} + rowDetail={(wo) => (openRouting.has(wo.id) && (wo.routing ?? []).length > 0 ? ( +
+ openStep(wo, id)} /> +

{__('Click a step to open the order on it.')}

+
+ ) : null)} + /> )}
@@ -1122,9 +1002,22 @@ const MACHINE_STATE_DOT = { WAITING: 'bg-yellow-400', CLEANING: 'bg-purple-400', MAINTENANCE: 'bg-orange-400', }; +// States that open a downtime — the ones worth a word of explanation. +const DOWNTIME_STATES = ['STOPPED', 'FAULT', 'WAITING', 'CLEANING', 'MAINTENANCE']; + function MachineStatePanel({ machines, options, label }) { - const setState = (workstationId, state) => { - router.post(`/operator/workstation/machine-state/${workstationId}`, { state }, { preserveScroll: true }); + const { prompt, dialog } = usePrompt(); + const post = (workstationId, state, note = '') => { + router.post(`/operator/workstation/machine-state/${workstationId}`, note ? { state, note } : { state }, { preserveScroll: true }); + }; + // A stop gets an optional note (what happened), stored with the state and + // shown on the shift monitor; running/idle/setup post straight away. + const setState = (machine, state) => { + if (!DOWNTIME_STATES.includes(state)) { post(machine.id, state); return; } + prompt( + { title: `${machine.name}: ${MACHINE_STATE_LABELS[state] ? __(MACHINE_STATE_LABELS[state]) : state}`, label: __('Note (optional)'), placeholder: __('What happened?'), required: false, maxLength: 255 }, + (note) => post(machine.id, state, note), + ); }; return ( @@ -1139,7 +1032,7 @@ function MachineStatePanel({ machines, options, label }) { className="min-w-[140px]" value={m.state ?? undefined} placeholder="—" - onChange={(state) => state !== m.state && setState(m.id, state)} + onChange={(state) => state !== m.state && setState(m, state)} aria-label={`${label}: ${m.name}`} options={[ // A state outside the settable list (e.g. from a machine feed) stays visible. @@ -1150,6 +1043,7 @@ function MachineStatePanel({ machines, options, label }) {
))} + {dialog} ); } diff --git a/backend/resources/js/Pages/operator/unit-labels/Station.jsx b/backend/resources/js/Pages/operator/unit-labels/Station.jsx new file mode 100644 index 000000000..422c72cb8 --- /dev/null +++ b/backend/resources/js/Pages/operator/unit-labels/Station.jsx @@ -0,0 +1,668 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Head, usePage } from '@inertiajs/react'; +import { Badge, Button, Dropdown, Icon, InlineAlert, Modal, SegmentedControl, StatusBadge, TextField } from '@openmes/ui'; +import AppDataTable from '../../../components/AppDataTable'; +import usePrompt from '../../../components/usePrompt'; +import LabelPreviewModal from '../../../components/LabelPreviewModal'; +import OperatorLayout from '../../../layouts/OperatorLayout'; +import { __, formatDateTime } from '../../../lib/i18n'; +import { readParam, writeParams } from '../../../lib/urlState'; +import { serialStatusBadge, serialStatusLabel } from '../../../lib/serialStatus'; + +function csrf() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.content : ''; +} + +/** + * The label station: the operator scans the unit's process serial, then the + * serial number on the label that goes onto the unit, and the two are bound. + * A wedge scanner sends Enter after each code, so Enter in the first field + * moves to the second and Enter in the second submits - no clicking between + * scans. What the identifiers must look like, and whether the process serial + * is required at all, is configured in Settings, not here. + */ +export default function Station() { + const { workOrders = [], materials = [], holdReasons = [], canUnblock = false, labelActions = ['start', 'issue', 'label', 'components', 'subassembly'], auth } = usePage().props; + // What this bench does here: the first bench starts units, the labelling bench binds the ready label, ... + const can = (action) => labelActions.includes(action); + const canRebind = (auth?.user?.roles ?? []).some((r) => r === 'Supervisor' || r === 'Admin'); + + // 'label': bind the unit's own serial to its process serial. + // 'component': scan a part (sub-assembly serial, lot, vendor serial) into a unit. + // 'subassembly': register a sub-assembly this order makes by its own serial. + const [mode, setMode] = useState(() => (can('label') || can('start') || can('issue') ? 'label' : can('components') ? 'component' : 'subassembly')); + const [subMaterialId, setSubMaterialId] = useState(''); + const [subSerial, setSubSerial] = useState(''); + const subSerialRef = useRef(null); + const [materialId, setMaterialId] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [components, setComponents] = useState({ unit: null, items: [] }); + const identifierRef = useRef(null); + + // Start on the order that is running: that is what the operator is + // labelling, and the issued numbers come from its product's sequences. + // A `?work_order=` in the address wins (the choice is written back there, + // so a reload or a shared link keeps it); otherwise the running order. + const [woId, setWoIdState] = useState(() => { + const fromUrl = readParam('work_order'); + if (fromUrl != null && workOrders.some((wo) => String(wo.id) === fromUrl)) return fromUrl; + if (fromUrl === '') return ''; + return String(workOrders.find((wo) => wo.status === 'IN_PROGRESS')?.id ?? ''); + }); + const setWoId = (v) => { setWoIdState(v); writeParams({ work_order: v }); }; + // What the selected order makes that is registered by its own serial. + const produces = workOrders.find((wo) => String(wo.id) === woId)?.produces ?? []; + const anyProduces = workOrders.some((wo) => (wo.produces ?? []).length > 0); + const modeOptions = [ + (can('label') || can('start') || can('issue')) && { value: 'label', label: can('label') ? __('Serial label') : __('New units') }, + can('components') && { value: 'component', label: __('Components') }, + can('subassembly') && anyProduces && { value: 'subassembly', label: __('Sub-assemblies') }, + ].filter(Boolean); + useEffect(() => { + // One thing made: nothing to choose. + setSubMaterialId(produces.length === 1 ? String(produces[0].id) : ''); + }, [woId]); // eslint-disable-line react-hooks/exhaustive-deps + const [psn, setPsn] = useState(''); + const [serialNo, setSerialNo] = useState(''); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); // { severity, title, body?, rebindable? } + const [units, setUnits] = useState([]); + const serialRef = useRef(null); + const psnRef = useRef(null); + const { prompt, dialog: promptDialog } = usePrompt(); + + const fetchUnits = useCallback(async () => { + try { + const qs = woId ? `?work_order_id=${encodeURIComponent(woId)}` : ''; + const res = await fetch(`/operator/unit-labels/units${qs}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (res.ok) setUnits((await res.json()).units ?? []); + } catch { + // Non-critical: the table keeps its last state. + } + }, [woId]); + + useEffect(() => { fetchUnits(); }, [fetchUnits]); + + const applyLabel = useCallback(async (extra = {}) => { + const sn = serialNo.trim(); + if (!sn) { + setResult({ severity: 'error', title: __('Scan or type the serial number first.') }); + serialRef.current?.focus(); + return; + } + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ serial_no: sn, psn: psn.trim() || null, work_order_id: woId ? Number(woId) : null, ...extra }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setResult({ + severity: 'success', + title: data.message, + body: [data.unit?.serial_no, data.unit?.psn && `${__('Process serial (PSN)')} ${data.unit.psn}`, data.unit?.work_order].filter(Boolean).join(' · '), + }); + setPsn(''); + setSerialNo(''); + psnRef.current?.focus(); + } else { + const msg = data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Could not apply label.')); + // The service refuses a unit that already carries another process + // serial; a supervisor can override that with a reason. + setResult({ severity: 'error', title: msg, rebindable: canRebind && res.status === 422 && !extra.force && data.rebindable === true }); + } + fetchUnits(); + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, [psn, serialNo, woId, fetchUnits, canRebind]); + + const loadComponents = useCallback(async (sn) => { + if (!sn) { setComponents({ unit: null, items: [] }); return; } + try { + const res = await fetch(`/operator/unit-labels/components?serial_no=${encodeURIComponent(sn)}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (res.ok) { + const data = await res.json(); + setComponents({ unit: data.unit, items: data.components ?? [] }); + } + } catch { + // Non-critical. + } + }, []); + + const bindComponent = useCallback(async () => { + const sn = serialNo.trim(); + const id = identifier.trim(); + if (!sn) { setResult({ severity: 'error', title: __('Scan or type the serial number first.') }); serialRef.current?.focus(); return; } + if (!id) { setResult({ severity: 'error', title: __('Scan the component identifier.') }); identifierRef.current?.focus(); return; } + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/component', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ serial_no: sn, identifier: id, material_id: materialId ? Number(materialId) : null }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setResult({ severity: 'success', title: data.message, body: data.component?.material ? `${data.component.material.code} · ${data.component.material.name}` : null }); + // The unit stays: the next scan is its next component. + setIdentifier(''); + identifierRef.current?.focus(); + loadComponents(sn); + } else { + const msg = data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Could not bind the component.')); + setResult({ severity: 'error', title: msg }); + } + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, [serialNo, identifier, materialId, loadComponents]); + + const registerSubassembly = useCallback(async () => { + const sn = subSerial.trim(); + if (!woId) { setResult({ severity: 'error', title: __('Choose the work order that makes the sub-assembly.') }); return; } + if (!subMaterialId) { setResult({ severity: 'error', title: __('Choose what was made.') }); return; } + if (!sn) { setResult({ severity: 'error', title: __('Scan the serial number of the sub-assembly.') }); subSerialRef.current?.focus(); return; } + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/subassembly', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ work_order_id: Number(woId), material_id: Number(subMaterialId), serial_no: sn }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setResult({ severity: 'success', title: data.message }); + // Next piece: the same material, a new serial. + setSubSerial(''); + subSerialRef.current?.focus(); + fetchUnits(); + } else { + const msg = data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Could not register the sub-assembly.')); + setResult({ severity: 'error', title: msg }); + } + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, [subSerial, subMaterialId, woId, fetchUnits]); + + // The next number from the product's sequence, put into the field as a scan + // would be - for lines that print their own labels instead of receiving them. + const issue = useCallback(async (purpose) => { + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/issue', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ purpose, work_order_id: woId ? Number(woId) : null }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setResult({ severity: 'error', title: data.message || __('Could not issue a number.') }); + return; + } + if (purpose === 'process_serial') { setPsn(data.identifier); serialRef.current?.focus(); } + else { setSerialNo(data.identifier); } + setResult({ severity: 'info', title: __('Issued :id', { id: data.identifier }) }); + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, [woId]); + + // The line's first station: the unit starts on its process serial (the one + // in the field, scanned off a label, or the next from the sequence) and its + // PSN label opens for printing. The SN comes later, with the product label. + const startUnit = useCallback(async () => { + if (!woId && !psn.trim()) { setResult({ severity: 'error', title: __('Select a work order first.') }); return; } + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ psn: psn.trim() || null, work_order_id: woId ? Number(woId) : null }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setResult({ severity: 'error', title: data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Could not start the unit.')) }); + return; + } + setResult({ severity: 'success', title: data.message }); + setPsn(''); + setLabelFor(data.unit); + fetchUnits(); + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + psnRef.current?.focus(); + } + }, [psn, woId, fetchUnits]); + + // Row actions: print the unit's label, continue with its components, or scrap it. + const [labelFor, setLabelFor] = useState(null); // unit whose label is previewed + const printLabel = (unit) => setLabelFor(unit); + const openComponents = (unit) => { + setMode('component'); + setSerialNo(unit.serial_no ?? unit.psn); + setResult(null); + loadComponents(unit.serial_no ?? unit.psn); + setTimeout(() => identifierRef.current?.focus(), 0); + }; + // Never throws: a dropped connection comes back as a refusal the screen can show. + const post = async (url, body) => { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify(body), + }); + return { res, data: await res.json().catch(() => ({})) }; + } catch { + return { res: { ok: false }, data: { message: __('Connection error') } }; + } + }; + const scrapUnit = (unit) => prompt( + { title: __('Scrap unit :sn', { sn: unit.serial_no ?? unit.psn }), label: __('Reason'), confirmLabel: __('Scrap it') }, + async (reason) => { + const { res, data } = await post(`/operator/unit-labels/${unit.id}/scrap`, { reason }); + setResult({ severity: res.ok ? 'warning' : 'error', title: data.message || (res.ok ? __('Saved') : __('Error')) }); + fetchUnits(); + }, + ); + + // Hold a non-conforming unit with an error code; a supervisor releases it. + const [blockFor, setBlockFor] = useState(null); // { unit, reasonId, note, error } + const confirmBlock = async () => { + if (!blockFor?.reasonId) { setBlockFor((b) => ({ ...b, error: __('Choose the error code.') })); return; } + const { res, data } = await post(`/operator/unit-labels/${blockFor.unit.id}/block`, { scrap_reason_id: Number(blockFor.reasonId), note: blockFor.note || null }); + if (!res.ok) { setBlockFor((b) => ({ ...b, error: data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Error')) })); return; } + setBlockFor(null); + setResult({ severity: 'warning', title: data.message }); + if (components.unit?.id === data.unit?.id) setComponents((c) => ({ ...c, unit: data.unit })); + fetchUnits(); + }; + const unblockUnit = (unit) => prompt( + { title: __('Release unit :sn', { sn: unit.serial_no ?? unit.psn }), label: __('Reason'), confirmLabel: __('Release unit') }, + async (note) => { + const { res, data } = await post(`/operator/unit-labels/${unit.id}/unblock`, { note }); + setResult({ severity: res.ok ? 'success' : 'error', title: data.message || (res.ok ? __('Saved') : __('Error')) }); + if (res.ok && components.unit?.id === data.unit?.id) setComponents((c) => ({ ...c, unit: data.unit })); + fetchUnits(); + }, + ); + + // A component comes out (repair, wrong scan): the reason stays on the unit's history. + const unbindComponent = (c) => prompt( + { title: __('Remove component :id', { id: c.identifier }), label: __('Reason (optional)'), confirmLabel: __('Remove'), required: false }, + async (reason) => { + const { res, data } = await post(`/operator/unit-labels/components/${c.id}/unbind`, { reason }); + setResult({ severity: res.ok ? 'warning' : 'error', title: data.message || (res.ok ? __('Saved') : __('Error')) }); + if (res.ok) setComponents({ unit: data.unit, items: data.components ?? [] }); + }, + ); + + // Numbers ahead of the line: N units registered on the order, their labels in one file. + const [batchLabel, setBatchLabel] = useState(null); // { count, pdf, zpl } + // psnOnly: the units start on their process serial alone; the SN comes later. + const issueBatch = (psnOnly = false) => { + if (!woId) { setResult({ severity: 'error', title: __('Select a work order first.') }); return; } + prompt( + { title: psnOnly ? __('Issue a batch of process serials') : __('Issue a batch of numbers'), label: __('How many units?'), confirmLabel: __('Issue and print'), type: 'number', min: 1, max: 500, defaultValue: 10 }, + async (quantity) => { + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/issue-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ work_order_id: Number(woId), quantity: Number(quantity), psn_only: psnOnly }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setResult({ severity: 'error', title: data.errors ? Object.values(data.errors).flat().join(' ') : (data.message || __('Could not issue a number.')) }); + return; + } + setResult({ severity: 'success', title: data.message }); + setBatchLabel({ count: data.count, pdf: data.label_pdf, zpl: data.label_zpl }); + fetchUnits(); + } catch { + setResult({ severity: 'error', title: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, + ); + }; + + const rebind = () => prompt( + { title: __('Re-bind process serial'), label: __('Reason'), confirmLabel: __('Re-bind') }, + (reason) => applyLabel({ force: true, reason }), + ); + + const unitColumns = useMemo(() => [ + // The serial is the row's identity: clicking it (or double-clicking the row) shows the label. + { id: 'serial_no', accessorKey: 'serial_no', header: __('Serial No'), cell: ({ row }) => ( + // A unit still waiting for its SN is named by its PSN until the label goes on. + row.original.serial_no + ? + : + ) }, + { id: 'psn', accessorKey: 'psn', header: __('Process serial (PSN)'), cell: ({ row }) => {row.original.psn || '—'} }, + { id: 'work_order', accessorKey: 'work_order', header: __('Work Order'), cell: ({ row }) => {row.original.work_order || '—'} }, + // The accessor is the translated label, so the filter's options and search read the same as the chips. + { id: 'status', accessorFn: (r) => serialStatusLabel(r.status), header: __('Status'), cell: ({ row }) => ( + // A held unit says why: the error code, or the failed tests. + + + {row.original.hold && {row.original.hold.source === 'manual' ? `${row.original.hold.reason_code} · ${row.original.hold.reason}` : __('Failed tests')}} + + ) }, + { id: 'applied_at', accessorKey: 'applied_at', header: __('Applied At'), cell: ({ row }) => {row.original.applied_at ? formatDateTime(row.original.applied_at) : '—'} }, + { + id: '_actions', + header: __('Actions'), + enableSorting: false, + meta: { align: 'right', chrome: true }, + cell: ({ row }) => { + const u = row.original; + const live = !['scrapped', 'shipped'].includes(u.status); + return ( +
+ + {live && can('components') && } + {live && u.status !== 'blocked' && holdReasons.length > 0 && } + {u.status === 'blocked' && canUnblock && } + {live && } +
+ ); + }, + }, + ], [printLabel, openComponents, scrapUnit, holdReasons, canUnblock, labelActions]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + +
+
+
+

{__('SN Label Station')}

+

{__('Apply the pre-printed serial number label and bind it to the process serial number (PSN).')}

+
+
+ {modeOptions.length > 1 && { setMode(m); setResult(null); }} + options={modeOptions} + />} +
+ ({ value: String(wo.id), label: `${wo.order_no} - ${wo.product || wo.status}` })), + ]} + /> +
+
+
+ + {mode === 'subassembly' && ( +
{ e.preventDefault(); if (!busy) registerSubassembly(); }} + > +
+ {produces.length === 0 ? ( + + {__('Choose the work order of the sub-assembly station above.')} + + ) : ( + <> +
+ + ({ value: String(m.id), label: `${m.code} · ${m.name}` }))} + /> +
+ +
+ +
+ + )} +
+ {result && {result.body}} +
+ )} + + {mode === 'component' && ( +
{ e.preventDefault(); if (!busy) bindComponent(); }} + > + {/* Unit scan, then the component (material beside its identifier), then the button. */} +
+ loadComponents(serialNo.trim())} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); loadComponents(serialNo.trim()); identifierRef.current?.focus(); } }} + /> +
+
+ + ({ value: String(m.id), label: `${m.code} · ${m.name}` })), + ]} + /> +
+ +
+
+ +
+
+ + {result && {result.body}} + + {components.unit && ( +
+
+ {components.unit.serial_no ?? __('No serial number yet')} + {components.unit.psn && {components.unit.psn}} + + {components.unit.status !== 'blocked' && !['scrapped', 'shipped'].includes(components.unit.status) && holdReasons.length > 0 && ( + + )} + {__(':count components', { count: components.items.length })} +
+ {components.items.length > 0 && ( +
    + {components.items.map((c) => ( +
  • + + {c.material?.code ? `${c.material.code}: ` : ''}{c.identifier} + + +
  • + ))} +
+ )} +
+ )} +
+ )} + + {mode === 'label' &&
{ e.preventDefault(); if (!busy) applyLabel(); }} + > + {/* The two scans stacked, each with its issue button; the apply + button closes the form under them. */} + {can('label') &&
+ { if (e.key === 'Enter') { e.preventDefault(); serialRef.current?.focus(); } }} + /> + {can('issue') && } + + {can('issue') && } +
+ +
+
} + {(can('start') || can('issue')) &&
+ {can('start') && } + {can('issue') && <> + + + {__('Numbers issued ahead of the line: the labels can be printed now and are applied by scanning them.')} + } + {!can('label') && can('start') && {__('Each unit starts here on its process serial; its PSN label prints for the housing.')}} +
} + + {result && ( + + {result.body} + {result.rebindable && ( +
+ +
+ )} +
+ )} +
} + +
+
+

{__('Recent units (last 50)')}

+
+ printLabel(u)} /> +
+
+ {promptDialog} + setBlockFor(null)} + title={__('Block unit :sn', { sn: blockFor?.unit?.serial_no ?? blockFor?.unit?.psn ?? '' })} + subtitle={__('A held unit is not packed and fails its pallet’s quality gate until a supervisor releases it.')} + closeLabel={__('Close')} + footer={( +
+ + +
+ )} + > +
+
+
{__('Error code')}
+ setBlockFor((b) => ({ ...b, reasonId: v, error: null }))} + placeholder={__('Choose…')} + options={holdReasons.map((r) => ({ value: String(r.id), label: `${r.code} · ${r.name}` }))} + className="w-full" + /> +
+ setBlockFor((b) => ({ ...b, note: v }))} placeholder={__('What was found?')} /> + {blockFor?.error && } +
+
+ setLabelFor(null)} + title={__('Unit label')} + subtitle={labelFor?.serial_no ?? labelFor?.psn} + pdfUrl={labelFor ? `/packaging/labels/serial-unit/${labelFor.id}/pdf` : null} + zplUrl={labelFor ? `/packaging/labels/serial-unit/${labelFor.id}/zpl` : null} + /> + setBatchLabel(null)} + title={__('Unit labels (:count)', { count: batchLabel?.count ?? 0 })} + subtitle={workOrders.find((wo) => String(wo.id) === String(woId))?.order_no} + pdfUrl={batchLabel?.pdf ?? null} + zplUrl={batchLabel?.zpl ?? null} + /> + + ); +} + +Station.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/packaging/Station.jsx b/backend/resources/js/Pages/packaging/Station.jsx index a01d18eac..e44d279c0 100644 --- a/backend/resources/js/Pages/packaging/Station.jsx +++ b/backend/resources/js/Pages/packaging/Station.jsx @@ -1,11 +1,15 @@ // Geist White restyle: light-only v1 — om-* tokens, @openmes/ui controls (scanning logic untouched). import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Head, usePage } from '@inertiajs/react'; -import { Badge, Button, Dropdown, Icon, StatusPill } from '@openmes/ui'; +import { Badge, Button, Dropdown, Icon, InlineAlert, Modal, ProgressBar, StatusPill, TextField, useToast } from '@openmes/ui'; import AppDataTable from '../../components/AppDataTable'; import AppLayout from '../../layouts/AppLayout'; -import { __, formatTime } from '../../lib/i18n'; +import OperatorLayout from '../../layouts/OperatorLayout'; +import LabelPreviewModal from '../../components/LabelPreviewModal'; +import useScanBuffer from '../../lib/useScanBuffer'; import LabelPrintMenu from '../../components/LabelPrintMenu'; +import { __, formatTime } from '../../lib/i18n'; +import { readParam, writeParams } from '../../lib/urlState'; function csrf() { const meta = document.querySelector('meta[name="csrf-token"]'); @@ -23,39 +27,180 @@ function groupByLine(pallets) { return Array.from(groups.entries()); } -function ProgressBar({ pct, done }) { - const color = done ? 'bg-om-running' : pct >= 50 ? 'bg-om-downtime' : 'bg-om-accent'; - return ( -
-
-
-
- {pct}% -
- ); -} - function ShiftLabel() { const h = new Date().getHours(); return h >= 6 && h < 18 ? '06:00 – 18:00' : '18:00 – 06:00'; } export default function Station() { - const { auth, labelTemplates = [], currentShift = null } = usePage().props; + const { auth, labelTemplates = [], currentShift = null, packingSteps: initialPackingSteps = [], scannerMode = 'hid' } = usePage().props; + // Staff get the shift and login line under the title; the operator shell + // already names the line and the user in its header. + const staff = (auth?.user?.roles ?? []).some((r) => r === 'Admin' || r === 'Supervisor'); const [items, setItems] = useState([]); + const [palletOrders, setPalletOrders] = useState([]); // every packable order, for the pallet picker const [history, setHistory] = useState([]); const [stats, setStats] = useState({ today_packed: 0, plan: 0, backlog: 0 }); const [lastScan, setLastScan] = useState(null); + // EAN packing (products without serial numbers): its cards show only when it is in use. + const usesEan = items.length > 0 || history.length > 0 || lastScan != null; + const [psn, setPsn] = useState(''); + const [psnBusy, setPsnBusy] = useState(false); + const [psnResult, setPsnResult] = useState(null); // { success, unit?, error? } + // Weight check: the step wants the unit weighed before it goes in the box. + // The scan comes back asking for the weight; the scale (or the operator) + // types it here and Enter sends the same scan again with it. + const [weighing, setWeighing] = useState(null); // { psn, expected, tolerance } + const [weight, setWeight] = useState(''); + const weightRef = useRef(null); + const [cartonLabel, setCartonLabel] = useState(null); // { unit?, pdf, zpl?, title?, subtitle? } shown in the preview modal + // Labels arrive faster than they are printed: the unit's, then the carton it + // just filled, then the pallet that carton filled. Each waits its turn + // behind the one on screen, so none is lost. + const labelQueueRef = useRef([]); + const showLabel = useCallback((label) => { + setCartonLabel((current) => { + if (current) { labelQueueRef.current.push(label); return current; } + return label; + }); + }, []); + const closeLabel = useCallback(() => setCartonLabel(labelQueueRef.current.shift() ?? null), []); + const onPalletClosedRef = useRef(null); // closeCarton is declared before the pallet helpers; it reaches them through this + const [packingSteps, setPackingSteps] = useState(initialPackingSteps); // the routing's packing steps this station works on + const toast = useToast(); + const [scanTarget, setScanTarget] = useState('auto'); // 'auto' | 'carton:' | 'pallet:' | 'none' + const [cartons, setCartons] = useState([]); // open cartons + const [activeCarton, setActiveCarton] = useState(null); + const [cartonUnits, setCartonUnits] = useState([]); + const [cartonBusy, setCartonBusy] = useState(false); + + // Packaging from the BOM: with lot tracking on, the carton's lot is picked + // here before the first unit goes into the box. + const [pickStep, setPickStep] = useState(null); // { step, materials, candidates, picks: {material_id: lot_id}, error } + const openPicks = useCallback(async (step) => { + setPickStep({ step, materials: step.materials ?? [], candidates: [], picks: {}, error: null, loading: true }); + try { + const res = await fetch(`/packaging/packing-steps/${step.id}/materials`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + const data = await res.json(); + const picks = {}; + (data.candidates ?? []).forEach((c) => { picks[c.material_id] = String(c.proposed?.[0]?.material_lot_id ?? c.candidates?.[0]?.id ?? ''); }); + setPickStep({ step, materials: data.materials ?? [], candidates: data.candidates ?? [], picks, error: null, loading: false }); + } catch { + setPickStep((p) => (p ? { ...p, loading: false, error: __('Network error, try again.') } : p)); + } + }, []); + const confirmPicks = useCallback(async () => { + if (!pickStep) return; + const picks = {}; + for (const c of pickStep.candidates) { + const lotId = pickStep.picks[c.material_id]; + if (!lotId) { setPickStep((p) => ({ ...p, error: __('Choose a lot for every material.') })); return; } + picks[c.material_id] = [{ material_lot_id: Number(lotId), picked_qty: c.required_qty }]; + } + setPickStep((p) => ({ ...p, loading: true, error: null })); + try { + const res = await fetch(`/packaging/packing-steps/${pickStep.step.id}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ picks }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { setPickStep((p) => ({ ...p, loading: false, error: data.message || __('Error') })); return; } + setPickStep(null); + loadPackingSteps(); + } catch { + setPickStep((p) => ({ ...p, loading: false, error: __('Network error, try again.') })); + } + }, [pickStep]); // eslint-disable-line react-hooks/exhaustive-deps + + const loadPackingSteps = useCallback(async () => { + try { + const res = await fetch('/packaging/packing-steps', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (res.ok) setPackingSteps((await res.json()).steps ?? []); + } catch { /* keeps the last list */ } + }, []); + + const loadCartons = useCallback(async () => { + try { + const res = await fetch('/packaging/cartons', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (!res.ok) return; + const list = (await res.json()).cartons ?? []; + setCartons(list); + setActiveCarton(list.find((c) => c.active_by_id === auth?.user?.id) ?? null); + } catch { /* keeps the last list */ } + }, [auth?.user?.id]); + const loadCartonUnits = useCallback(async (carton) => { + if (!carton) { setCartonUnits([]); return; } + try { + const res = await fetch(`/packaging/cartons/${carton.id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (res.ok) { + const data = await res.json(); + setCartonUnits(data.units ?? []); + setActiveCarton((c) => (c && c.id === carton.id ? { ...c, ...data.carton } : c)); + } + } catch { /* keeps the last list */ } + }, []); + useEffect(() => { loadCartons(); }, [loadCartons]); + useEffect(() => { loadCartonUnits(activeCarton); }, [activeCarton?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const cartonCall = useCallback(async (url, body = {}) => { + setCartonBusy(true); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setPsnResult({ success: false, error: data.message || __('Error'), scanned_at: formatTime(new Date()) }); + return null; + } + return data; + } catch { + setPsnResult({ success: false, error: __('Connection error'), scanned_at: formatTime(new Date()) }); + return null; + } finally { + setCartonBusy(false); + } + }, []); + const openCarton = useCallback(async () => { + const active = activePalletRef.current; + const data = await cartonCall('/packaging/cartons', { work_order_id: active?.work_order_id ?? null, pallet_id: active?.id ?? null }); + if (data?.carton) { setActiveCarton(data.carton); loadCartons(); } + }, [cartonCall, loadCartons]); + // The bench's box by default; a scan that filled a box names that one (it may + // be a box the server just opened for the unit's order). + const closeCarton = useCallback(async (target) => { + const box = target?.carton_no ? target : activeCarton; + if (!box) return; + const data = await cartonCall(`/packaging/cartons/${box.id}/close`); + if (data?.carton) { + showLabel({ unit: null, pdf: data.label_pdf, zpl: null, title: __('Carton label'), subtitle: data.carton.carton_no }); + setActiveCarton((current) => (current?.id === box.id ? null : current)); + loadCartons(); + onPalletClosedRef.current?.(data.pallet_closed); + } + }, [activeCarton, cartonCall, loadCartons, showLabel]); + const putCartonOnPallet = useCallback(async () => { + const active = activePalletRef.current; + if (!activeCarton || !active) return; + const data = await cartonCall(`/packaging/cartons/${activeCarton.id}/pallet`, { pallet_id: active.id }); + if (data?.carton) { setActiveCarton(data.carton); loadCartons(); } + }, [activeCarton, cartonCall, loadCartons]); const [flash, setFlash] = useState(null); // 'success' | 'error' | null - const [activePallet, setActivePallet] = useState(null); // { id, pallet_no, work_order_id, order_no, qty } + // The pallet and carton being filled are this operator's bench - state the + // server holds (active_by), so a refresh, another device, or another + // station's screen all agree on who is filling what. + const [activePallet, setActivePallet] = useState(null); // { id, pallet_no, work_order_id, order_no, qty, active_by_id } const [openPallets, setOpenPallets] = useState([]); // all currently open pallets (persist across shifts) - const [palletWoId, setPalletWoId] = useState(''); // selected work order for a new pallet + const [palletWoId, setPalletWoIdState] = useState(() => readParam('work_order') ?? ''); // selected work order for a new pallet, kept in the address + const setPalletWoId = useCallback((v) => { setPalletWoIdState(v); writeParams({ work_order: v }); }, []); const [palletBatchId, setPalletBatchId] = useState(''); // selected batch (when the WO has several) const [palletBusy, setPalletBusy] = useState(false); const lastHistoryIdRef = useRef(0); - const bufferRef = useRef(''); - const bufferTimerRef = useRef(null); + const [manualCode, setManualCode] = useState(''); const activePalletRef = useRef(null); useEffect(() => { activePalletRef.current = activePallet; }, [activePallet]); @@ -81,9 +226,7 @@ export default function Station() { accessorFn: (r) => (r.eans ?? []).join(' '), header: 'EAN', cell: ({ row }) => (row.original.eans ?? []).map((ean) => ( - - {ean} - + {ean} )), }, { @@ -104,7 +247,12 @@ export default function Station() { id: 'progress', accessorKey: 'progress', header: __('Progress'), - cell: ({ row }) => , + cell: ({ row }) => ( +
+ + {row.original.progress}% +
+ ), }, ], []); @@ -136,6 +284,7 @@ export default function Station() { if (res.ok) { const data = await res.json(); setItems(data.items ?? []); + setPalletOrders(data.pallet_orders ?? data.items ?? []); } } catch {} }, []); @@ -172,14 +321,20 @@ export default function Station() { setOpenPallets(list); // Keep the active pallet's qty in sync if another shift/device changed it, // and drop it if it was closed elsewhere. - const active = activePalletRef.current; - if (active) { - const fresh = list.find((p) => p.id === active.id); - if (fresh) setActivePallet((p) => ({ ...p, ...fresh })); - else setActivePallet(null); - } + // Mine is whichever open pallet the server says I am filling. + const mine = list.find((p) => p.active_by_id === auth?.user?.id) ?? null; + setActivePallet(mine); } catch {} - }, []); + }, [auth?.user?.id]); + // A pallet that closed itself because it is full: the bench drops it, its label queues behind the current one. + const onPalletClosed = useCallback((closed) => { + if (!closed) return; + setActivePallet(null); + setPalletWoId(''); + fetchOpenPallets(); + showLabel({ unit: null, pdf: closed.label_pdf, zpl: closed.label_zpl, title: __('Pallet label'), subtitle: closed.message }); + }, [fetchOpenPallets, showLabel]); + onPalletClosedRef.current = onPalletClosed; const poll = useCallback(async () => { try { @@ -247,6 +402,89 @@ export default function Station() { setTimeout(() => setFlash(null), 2000); }, [fetchItems, fetchStats, fetchOpenPallets]); + // Where the next scanned unit goes. 'auto' follows the bench (my carton, + // else my pallet); an explicit target lets a unit be packed elsewhere - + // topping up another pallet - without switching the bench. + const scanDestination = () => { + if (scanTarget.startsWith('carton:')) return { carton_id: Number(scanTarget.slice(7)), pallet_id: null }; + if (scanTarget.startsWith('pallet:')) return { carton_id: null, pallet_id: Number(scanTarget.slice(7)) }; + if (scanTarget === 'none') return { carton_id: null, pallet_id: null }; + return { carton_id: activeCarton?.id ?? null, pallet_id: activeCarton ? null : (activePalletRef.current?.id ?? null) }; + }; + const scanTargetOptions = () => [ + { value: 'auto', label: activeCarton ? __('My carton :carton', { carton: activeCarton.carton_no }) : activePallet ? __('My pallet :pallet', { pallet: activePallet.pallet_no }) : __('Pack only (no carton, no pallet)') }, + ...cartons.filter((c) => c.id !== activeCarton?.id).map((c) => ({ value: `carton:${c.id}`, label: `${__('Carton')} ${c.carton_no} · ${c.qty} ${__('pcs')}${c.active_by ? ` · ${c.active_by}` : ''}` })), + ...openPallets.filter((p) => p.id !== activePallet?.id).map((p) => ({ value: `pallet:${p.id}`, label: `${__('Pallet')} ${p.pallet_no} · ${p.order_no} · ${p.qty} ${__('pcs')}${p.active_by ? ` · ${p.active_by}` : ''}` })), + ...((activeCarton || activePallet) ? [{ value: 'none', label: __('Pack only (no carton, no pallet)') }] : []), + ]; + // A target that closed (by its size, by hand, at another station) is no longer + // offered: the bench goes back to "auto" instead of refusing every next scan. + useEffect(() => { + const gone = (scanTarget.startsWith('carton:') && !cartons.some((c) => `carton:${c.id}` === scanTarget)) + || (scanTarget.startsWith('pallet:') && !openPallets.some((p) => `pallet:${p.id}` === scanTarget)); + if (gone) setScanTarget('auto'); + }, [scanTarget, cartons, openPallets]); + + // The packing step of the scanned unit's order says how many go in a box. + // Scan the process serial number to open the matching carton label for printing. + const submitPsnLabel = useCallback(async (e) => { + e?.preventDefault(); + const term = (weighing?.psn ?? psn).trim(); + if (!term || psnBusy) return; + if (weighing && weight.trim() === '') { weightRef.current?.focus(); return; } + setPsnBusy(true); + try { + const res = await fetch('/packaging/scan-unit', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + // With the bench on "auto" and no box open, the server opens a carton + // for an order that packs into cartons. + // On "auto" the server may also swap the bench's box for one of the unit's own + // order (the open carton belongs to another order) instead of refusing the unit. + body: JSON.stringify({ psn: term, ...scanDestination(), auto_carton: scanTarget === 'auto', weight_g: weighing ? Number(weight.replace(',', '.')) : undefined }), + }); + const data = await res.json(); + if (!res.ok && data.needs_weight) { + setWeighing({ psn: term, expected: data.expected_g, tolerance: data.tolerance_g }); + setWeight(''); + setTimeout(() => weightRef.current?.focus(), 0); + } + if (res.ok) { setWeighing(null); setWeight(''); } + if (!res.ok && data.needs_picks) { + const step = packingSteps.find((st) => st.id === data.packing_step_id); + if (step) openPicks(step); + } + if (res.ok) { + setPsnResult({ success: true, alreadyPacked: data.already_packed === true, unit: data.unit, scanned_at: formatTime(new Date()) }); + setFlash('success'); + // A unit labelled upstream only needs the confirmation; the print + // dialog shows when the label is still missing or the step prints one. + if (data.label_needed !== false) showLabel({ unit: data.unit, pdf: data.label_pdf, zpl: data.label_zpl, title: __('Unit label'), subtitle: data.unit?.serial_no }); + onPalletClosed(data.pallet_closed); + setPsn(''); + if (activeCarton) loadCartonUnits(activeCarton); + loadCartons(); // picks up a carton the scan just opened for this bench + fetchOpenPallets(); + loadPackingSteps(); + // A box that just reached its configured size closes itself and prints + // its list; the server says so, whichever box the unit went into. + if (data.carton_full && data.unit?.carton) { + const full = data.unit.carton; + setTimeout(() => closeCarton(full), 400); + } + } else { + setPsnResult({ success: false, error: data.message, scanned_at: formatTime(new Date()) }); + setFlash('error'); + } + } catch { + setPsnResult({ success: false, error: __('Connection error'), scanned_at: formatTime(new Date()) }); + setFlash('error'); + } finally { + setPsnBusy(false); + } + setTimeout(() => setFlash(null), 2000); + }, [psn, psnBusy, weighing, weight, activeCarton, activePallet, scanTarget, cartons, openPallets, packingSteps, loadCartonUnits, loadCartons, fetchOpenPallets, loadPackingSteps, closeCarton, onPalletClosed, showLabel, openPicks]); + const createPallet = useCallback(async () => { if (!palletWoId || palletBusy) return; setPalletBusy(true); @@ -283,9 +521,26 @@ export default function Station() { // Resume an already-open pallet (e.g. one started on a previous shift) so the // next scans keep filling it instead of creating a new pallet. - const resumePallet = useCallback((pallet) => { - setActivePallet(pallet); - }, []); + const resumePallet = useCallback(async (pallet) => { + try { + const res = await fetch(`/packaging/pallets/${pallet.id}/activate`, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' } }); + const data = await res.json().catch(() => ({})); + if (res.ok) { setActivePallet(data.pallet); fetchOpenPallets(); } + else toast({ severity: 'error', title: data.message || __('Could not take the pallet') }); + } catch { + toast({ severity: 'error', title: __('Connection error') }); + } + }, [fetchOpenPallets, toast]); + + const releasePallet = useCallback(async () => { + const pallet = activePalletRef.current; + if (!pallet) return; + try { + await fetch(`/packaging/pallets/${pallet.id}/release`, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' } }); + } catch { /* best effort */ } + setActivePallet(null); + fetchOpenPallets(); + }, [fetchOpenPallets]); const closePallet = useCallback(async () => { const pallet = activePalletRef.current; @@ -303,348 +558,515 @@ export default function Station() { setActivePallet(null); setPalletWoId(''); fetchOpenPallets(); + } else { + // Closed meanwhile at another station, or refused: say so and refresh the list. + const data = await res.json().catch(() => ({})); + toast({ severity: 'error', title: data.message || __('Could not close the pallet') }); + fetchOpenPallets(); } } catch { - /* ignore — best effort */ + toast({ severity: 'error', title: __('Connection error') }); } finally { setPalletBusy(false); } - }, [palletBusy, fetchOpenPallets]); - - const onKey = useCallback((e) => { - const tag = e.target.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if (e.key === 'Enter') { - const ean = bufferRef.current.trim(); - bufferRef.current = ''; - if (bufferTimerRef.current) clearTimeout(bufferTimerRef.current); - if (ean) handleScan(ean); - } else if (e.key.length === 1) { - bufferRef.current += e.key; - if (bufferTimerRef.current) clearTimeout(bufferTimerRef.current); - bufferTimerRef.current = setTimeout(() => { bufferRef.current = ''; }, 500); - } - }, [handleScan]); + }, [palletBusy, fetchOpenPallets, toast]); useEffect(() => { Promise.all([fetchItems(), fetchHistory(), fetchStats(), fetchOpenPallets()]); const interval = setInterval(poll, 3000); // Refresh the open-pallets list on a slower cadence so a pallet opened on // another device/shift shows up here too. - const palletInterval = setInterval(fetchOpenPallets, 5000); - document.addEventListener('keydown', onKey); + const palletInterval = setInterval(() => { fetchOpenPallets(); loadPackingSteps(); }, 5000); return () => { clearInterval(interval); clearInterval(palletInterval); - document.removeEventListener('keydown', onKey); }; - }, [fetchItems, fetchHistory, fetchStats, fetchOpenPallets, poll, onKey]); + }, [fetchItems, fetchHistory, fetchStats, fetchOpenPallets, loadPackingSteps, poll]); - const flashBg = - flash === 'success' - ? 'bg-om-running-bg border-om-running/30' - : flash === 'error' - ? 'bg-om-blocked-bg border-om-blocked/30' - : 'bg-om-card border-om-line'; + // A reader in `hid` mode types into the page; in `manual` mode the operator + // types into the field below, so the global listener would only get in the + // way. Settings → System decides which. + useScanBuffer(handleScan, { enabled: scannerMode !== 'manual' }); + + const submitManualCode = useCallback((e) => { + e.preventDefault(); + const code = manualCode.trim(); + if (! code) return; + setManualCode(''); + handleScan(code); + }, [manualCode, handleScan]); // Work order selected for a new pallet + its batches (one lookup, reused by // the batch picker and the create-button guard). A single batch auto-links // server-side, so the picker only shows when there are 2+. - const palletWo = items.find((it) => String(it.id) === String(palletWoId)); + const palletWo = palletOrders.find((it) => String(it.id) === String(palletWoId)); const palletBatches = palletWo?.batches ?? []; + const completionTone = realizacja >= 100 ? 'text-om-running' : realizacja >= 50 ? 'text-om-downtime' : 'text-om-blocked'; + return ( <> -
+
{/* Header */} -
+
-

- - - +

+ {__('Packing Station')}

-

+ {staff &&

{__('Shift')}:{' '} - - {currentShift - ? `${currentShift.name} (${currentShift.start}–${currentShift.end})` - : } + + {currentShift ? `${currentShift.name} (${currentShift.start}–${currentShift.end})` : } -  ·  {__('Logged in')}: {auth?.user?.name} -

-
-
- + {' · '}{__('Logged in')}: {auth?.user?.name} +

}
+
- {/* Stats row */} -
-
-

{stats.today_packed ?? '—'}

-

{__('Packed (shift)')}

-
-
-

{stats.plan ?? '—'}

-

{__('Total plan')}

-
-
-

0 ? 'text-om-blocked' : 'text-om-running'}`}> - {stats.backlog ?? '—'} -

-

{__('Station backlog')}

-
-
-

= 100 ? 'text-om-running' : realizacja >= 50 ? 'text-om-downtime' : 'text-om-blocked'}`}> - {realizacja}% -

-

{__('Completion')}

-
+ {/* KPIs */} +
+ + + 0 ? 'text-om-blocked' : 'text-om-running'} /> + + = 100 ? 'var(--color-om-running)' : undefined} /> +
- {/* Active pallet */} -
- {!activePallet ? ( -
-
-
- {__('Create pallet for order')} +
+ {/* Pallet: the one being filled, or the form to start one */} + : {__(':count open', { count: openPallets.length })}} + bodyClassName="" + > + {activePallet ? ( +
+
+
+
{activePallet.pallet_no}
+

+ {activePallet.order_no} + {' · '}{activePallet.qty ?? 0} {__('pcs')} + {activePallet.location ? ` · ${activePallet.location}` : ''} +

+
+
+ + + + +
- { setPalletWoId(v); setPalletBatchId(''); }} - placeholder={__('— Select order —')} - options={items.map((it) => ({ - value: String(it.id), - label: `${it.order_no} — ${it.product}`, - }))} - className="w-full" - />
- {palletBatches.length >= 2 && ( -
-
- {__('Batch')} + ) : ( +
+
+
+
+
{__('Create pallet for order')}
+ { setPalletWoId(v); setPalletBatchId(''); }} + placeholder={__('— Select order —')} + options={palletOrders.map((it) => ({ value: String(it.id), label: `${it.order_no} — ${it.product}` }))} + className="w-full" + /> +
+ {palletBatches.length >= 2 && ( +
+
{__('Batch')}
+ setPalletBatchId(v)} + placeholder={__('— Select batch —')} + options={palletBatches.map((b) => ({ value: String(b.id), label: b.label }))} + className="w-full" + /> +
+ )}
- setPalletBatchId(v)} - placeholder={__('— Select batch —')} - options={palletBatches.map((b) => ({ value: String(b.id), label: b.label }))} - className="w-full" - /> +
- )} - -
- ) : ( -
-
-

{__('Active pallet')}

-

- {activePallet.pallet_no} -

-

- {__('Order')} {activePallet.order_no} -  ·  {__('Pieces on pallet:')} {activePallet.qty ?? 0} -

-
- - -
-
- )} -
+ )} - {/* Open pallets (persist across shifts) — grouped by line, resumable */} -
-
-

- {__('Open pallets')} -

- {__(':count open', { count: openPallets.length })} -
- {openPallets.length === 0 ? ( -
- {__('No open pallets — create one above')} + {/* Open pallets, grouped by line: resume one instead of starting another. */} +
+ {openPallets.length === 0 ? ( +

{__('No open pallets — create one above')}

+ ) : ( + groupByLine(openPallets).map(([lineName, pallets]) => ( +
+
{lineName}
+ {pallets.map((p) => { + const isActive = activePallet?.id === p.id; + return ( +
+
+ {p.pallet_no} + · {p.order_no} · {p.qty} {__('pcs')}{p.location ? ` · ${p.location}` : ''} +
+ + {p.active_by && !isActive && ( + {__('Being filled by :name', { name: p.active_by })} + )} + {isActive + ? + : } + +
+ ); + })} +
+ )) + )}
- ) : ( - groupByLine(openPallets).map(([lineName, pallets]) => ( -
-
- {lineName} -
- {pallets.map((p) => { - const isActive = activePallet?.id === p.id; - return ( -
-
- {p.pallet_no} - -  ·  {p.order_no} -  ·  {p.qty} {__('pcs')} - {p.location ? <> ·  {p.location} : null} - -
-
- {isActive ? ( - - ) : ( - - )} -
-
- ); - })} -
- )) - )} -
+ -
- {/* Last scan */} -
-

- {__('Last scan')} -

+ {/* Scanning: EAN wedge scans land here without a field; the last one stays on screen. + Shown when the plant packs by EAN at all - a serialised line has no use for it. */} + {usesEan && EAN}> + {/* `manual` scanner mode (Settings → System): the operator types the EAN here. */} + {scannerMode === 'manual' && ( +
+ + + + )} {!lastScan ? ( -
- {__('Scan an EAN code…')} +
+ +

{__('Scan an EAN code…')}

) : ( -
-
-
-

{lastScan.product}

-

- EAN: {lastScan.ean} -  ·  {lastScan.scanned_at} -

-
- -
+
+ + EAN {lastScan.ean} · {lastScan.scanned_at} + {lastScan.success && ( -
-
-
= 100 ? 'bg-om-running' : lastScan.progress >= 50 ? 'bg-om-downtime' : 'bg-om-accent'}`} - style={{ width: `${lastScan.progress ?? 0}%` }} - /> -
+
+ = 100 ? 'var(--color-om-running)' : undefined} + /> {lastScan.packed_qty} / {lastScan.planned_qty} {__('pcs')}
)} - {!lastScan.success && lastScan.error && ( -
{lastScan.error}
- )}
)} + } +
+ + {/* The routing's packing steps for this station: what each order packs + into what, and how far it is - packing is a step, not a side activity. */} + {packingSteps.length > 0 && ( +
+ {__(':count items', { count: packingSteps.length })}} bodyClassName=""> +
    + {packingSteps.map((st) => { + const cfg = st.config ?? {}; + const pct = st.target_qty > 0 ? Math.min(100, Math.round((st.passed_qty / st.target_qty) * 100)) : 0; + return ( +
  • +
    +
    + {st.order_no} + {st.product} + · {__('Step :n', { n: st.step_number })} {st.name} + {st.workstation && {st.workstation}} + +
    +
    + {[ + cfg.unit === 'pallet' ? __('Packs onto pallets') : cfg.unit === 'unit' ? __('Labels single units') : __('Packs into cartons'), + cfg.carton_capacity ? __(':n per carton', { n: cfg.carton_capacity }) : null, + cfg.pallet_capacity ? __(':n cartons per pallet', { n: cfg.pallet_capacity }) : null, + cfg.weight_expected_g ? __('weight :expected g ± :tolerance g', { expected: cfg.weight_expected_g, tolerance: cfg.weight_tolerance_g ?? 0 }) : null, + ].filter(Boolean).join(' · ')} +
    + {(st.materials?.length ?? 0) > 0 && ( +
    + {__('Packaging (BOM)')} + {st.materials.map((m) => ( + + {m.name} · {m.quantity_per_basis} {m.unit_of_measure} / {m.per === 'carton' ? __('carton') : m.per === 'pallet' ? __('pallet') : __('unit')} + {m.status === 'pending' ? ` · ${__('to pick')}` : ` · ${__('used')} ${m.consumed_qty} / ${m.allocated_qty}`} + {m.lots?.length > 0 && ` · ${__('lot')} ${m.lots.map((l) => l.lot_number).join(', ')}`} + + ))} + {st.materials.some((m) => m.needs_pick) && ( + + )} +
    + )} +
    +
    + = 100 ? 'var(--color-om-running)' : undefined} /> + {st.passed_qty} / {st.target_qty} +
    +
  • + ); + })} +
+
+ )} - {/* Flash overlay */} -
- {flash === null && ( -
- - - - {__('Waiting for scan…')} -
+ {/* Serialised units: scan the process serial, the unit goes into the + open carton (and onto the active pallet), and its label shows. */} + + { + const chosen = cartons.find((c) => String(c.id) === v) ?? null; + if (!chosen && activeCarton) await cartonCall(`/packaging/cartons/${activeCarton.id}/release`); + if (chosen) await cartonCall(`/packaging/cartons/${chosen.id}/activate`); + loadCartons(); + }} + placeholder={__('No carton - units go straight to the pallet')} + options={[ + { value: '', label: __('No carton - units go straight to the pallet') }, + ...cartons.map((c) => ({ value: String(c.id), label: `${c.carton_no} · ${c.qty} ${__('pcs')}${c.order_no ? ` · ${c.order_no}` : ''}${c.active_by && c.active_by_id !== auth?.user?.id ? ` · ${c.active_by}` : ''}` })), + ]} + className="w-72" + /> + +
+ } + > +
+ {weighing ? ( + { if (e.key === 'Escape') { setWeighing(null); setWeight(''); } }} + data-testid="weight-input" + /> + ) : ( + )} - {flash === 'success' && ( -
- - - -

{__('Scanned!')}

+
+
{__('Pack into')}
+ +
+ + + + {psnResult && ( + + {psnResult.success && [ + psnResult.unit.work_order?.order_no, + psnResult.unit.carton && `${psnResult.unit.carton.carton_no} (${psnResult.unit.carton.qty})`, + psnResult.unit.pallet_no, + psnResult.alreadyPacked && psnResult.unit.packed_at + ? __('packed at :time', { time: psnResult.unit.packed_at }) + : psnResult.scanned_at, + ].filter(Boolean).join(' · ')} + + )} + + {activeCarton && ( +
+
+ {activeCarton.carton_no} + {__(':count pcs', { count: cartonUnits.length })} + {activeCarton.pallet_no + ? {__('Pallet')} {activeCarton.pallet_no} + : activePallet && ( + + )} + + + + +
+ {cartonUnits.length > 0 && ( +
    + {cartonUnits.map((u) =>
  • {u.serial_no}
  • )} +
+ )} +
+ )} + +

+ {__('Scan the PSN or the serial number. The unit is packed into the open carton of its order (a unit of another order gets its own), or straight onto the active pallet, and its label is shown for printing. Closing the carton prints the list of units inside.')} +

+ setPickStep(null)} + title={__('Pick the lots')} + subtitle={pickStep ? `${pickStep.step.order_no} · ${__('Step :n', { n: pickStep.step.step_number })} ${pickStep.step.name}` : ''} + closeLabel={__('Close')} + footer={( +
+ +
)} - {flash === 'error' && ( -
- - - -

{__('Scan error')}

+ > + {pickStep && ( +
+ {pickStep.error && } + {pickStep.candidates.length === 0 && !pickStep.loading && ( + + )} + {pickStep.candidates.map((c) => ( +
+
{c.material_name} · {c.required_qty} {c.unit_of_measure}
+ setPickStep((p) => ({ ...p, picks: { ...p.picks, [c.material_id]: v } }))} + placeholder={__('Lot')} + options={(c.candidates ?? []).map((l) => ({ value: String(l.id), label: `${l.lot_number} · ${__('avail.')} ${l.quantity_available} ${c.unit_of_measure}` }))} + className="w-full" + /> +
+ ))}
)} -
-
- - {/* Items to pack */} -
-
-

- {__('Orders to pack')} -

- {__(':count items', { count: items.length })} -
- + -
+ - {/* Scan log */} -
-
-

- {__('Scan history (shift)')} -

-
- + {usesEan && ( +
+ {__(':count items', { count: items.length })}} bodyClassName=""> + + + + +
+ )}
); } -Station.layout = (page) => {page}; +const FIELD_LABEL = 'mb-[7px] block font-mono text-[9.5px] uppercase tracking-[0.08em] text-om-faint'; + +/** A titled panel - the same shell the work-order page is made of. */ +function Card({ title, action, children, bodyClassName = 'px-[22px] pb-5' }) { + return ( +
+
+

{title}

+ {action} +
+
{children}
+
+ ); +} + +/** One number the shift is measured by. */ +function Stat({ label, value, tone = 'text-om-ink', children }) { + return ( +
+
{label}
+
{value}
+ {children} +
+ ); +} + +// An operator works this screen from the shop-floor shell (line in the header, +// Queue / Workstation / Labels / Packing across the top); supervisors and +// admins come from the sidebar and keep it. Role decides, not the URL. +function StationShell({ children }) { + const roles = usePage().props.auth?.user?.roles ?? []; + const staff = roles.includes('Admin') || roles.includes('Supervisor'); + return staff ? {children} : {children}; +} + +Station.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/packaging/label-templates/Create.jsx b/backend/resources/js/Pages/packaging/label-templates/Create.jsx deleted file mode 100644 index 6fa0eff4f..000000000 --- a/backend/resources/js/Pages/packaging/label-templates/Create.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Head, useForm, usePage } from '@inertiajs/react'; -import AppLayout from '../../../layouts/AppLayout'; -import LabelTemplateForm from './Form'; -import { __ } from '../../../lib/i18n'; - -export default function LabelTemplateCreate() { - const { types = {}, sizes = {}, barcodeFormats = {}, availableFields = {}, defaultFields = {} } = usePage().props; - const form = useForm({ - name: '', - type: 'work_order', - size: '100x50', - barcode_format: 'code128', - fields: defaultFields, - is_default: false, - is_active: true, - }); - const submit = (e) => { e.preventDefault(); form.post('/packaging/label-templates'); }; - - return ( -
- -

{__('New Label Template')}

- -
- ); -} - -LabelTemplateCreate.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/packaging/label-templates/Edit.jsx b/backend/resources/js/Pages/packaging/label-templates/Edit.jsx deleted file mode 100644 index c06473a54..000000000 --- a/backend/resources/js/Pages/packaging/label-templates/Edit.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Head, useForm, usePage } from '@inertiajs/react'; -import AppLayout from '../../../layouts/AppLayout'; -import LabelTemplateForm from './Form'; -import { __ } from '../../../lib/i18n'; - -export default function LabelTemplateEdit() { - const { template, types = {}, sizes = {}, barcodeFormats = {}, availableFields = {} } = usePage().props; - const form = useForm({ - name: template.name ?? '', - type: template.type ?? 'work_order', - size: template.size ?? '100x50', - barcode_format: template.barcode_format ?? 'code128', - fields: template.fields_config ?? {}, - is_default: !!template.is_default, - is_active: !!template.is_active, - }); - const submit = (e) => { e.preventDefault(); form.put(`/packaging/label-templates/${template.id}`); }; - - return ( -
- -

{__('Edit Label Template')}

- -
- ); -} - -LabelTemplateEdit.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/packaging/label-templates/Form.jsx b/backend/resources/js/Pages/packaging/label-templates/Form.jsx index d4d7426a0..bc2e0b649 100644 --- a/backend/resources/js/Pages/packaging/label-templates/Form.jsx +++ b/backend/resources/js/Pages/packaging/label-templates/Form.jsx @@ -1,24 +1,78 @@ -import { Link } from '@inertiajs/react'; -import { Button, Checkbox, Dropdown } from '@openmes/ui'; +import { useEffect, useState } from 'react'; +import { useForm, usePage } from '@inertiajs/react'; +import { Button, Checkbox, Dropdown, TextField } from '@openmes/ui'; +import LabelPreviewModal from '../../../components/LabelPreviewModal'; import { __ } from '../../../lib/i18n'; /** - * Label template form. Beyond the scalar selects (type/size/barcode), it has a - * fixed checkbox grid of AVAILABLE_FIELDS toggling which fields print on the - * label — submitted as `fields` { key: bool } (server reads fields.{key}). - * Non-optimistic write-through via the passed-in Inertia useForm. + * Blank/loaded form values. A synced row carries `fields_config` as a JSON + * string (no Eloquent casts behind the collection snapshot), the edit deep link + * as an object — both land here. */ -export default function LabelTemplateForm({ form, types, sizes, barcodeFormats, availableFields, submitLabel, onSubmit }) { +export function labelTemplateInitial(r, defaultFieldsByType = {}) { + const type = r?.type ?? 'work_order'; + let fields = r?.fields_config ?? defaultFieldsByType[type] ?? {}; + if (typeof fields === 'string') { + try { fields = JSON.parse(fields) || {}; } catch { fields = {}; } + } + return { + name: r?.name ?? '', + type, + size: r?.size ?? '100x50', + barcode_format: r?.barcode_format ?? 'code128', + fields, + is_default: !!r?.is_default, + is_active: r ? !!r.is_active : true, + }; +} + +/** + * Label template form: the scalar selects (type/size/barcode) and a checkbox + * grid of AVAILABLE_FIELDS toggling what prints — submitted as `fields` + * { key: bool } (the server reads fields.{key}). The option maps come from the + * model's constants in English; they are translated here, at render. + */ +export default function LabelTemplateForm({ action, method, initial, submitLabel, onSuccess, onCancel }) { + const { types = {}, sizes = {}, barcodeFormats = {}, availableFields = {}, defaultFieldsByType = {}, fieldsByType = {} } = usePage().props; + const form = useForm(initial); const { data, setData, errors, processing } = form; + const creating = method === 'post'; - const sel = (label, name, map, error) => ( + const submit = (e) => { + e.preventDefault(); + form.submit(method, action, { preserveScroll: true, ...(onSuccess ? { onSuccess } : {}) }); + }; + + // The label as the form now describes it, on sample data - before saving. + const [previewOpen, setPreviewOpen] = useState(false); + const previewUrl = () => { + const q = new URLSearchParams({ name: data.name || '', type: data.type, size: data.size, barcode_format: data.barcode_format }); + Object.entries(data.fields ?? {}).forEach(([k, v]) => q.append(`fields[${k}]`, v ? '1' : '0')); + return `/packaging/label-templates/preview?${q.toString()}`; + }; + // The inline preview follows the fields as they change, a beat behind the typing. + const [liveUrl, setLiveUrl] = useState(null); + const previewKey = JSON.stringify([data.type, data.size, data.barcode_format, data.fields]); + useEffect(() => { + const t = setTimeout(() => setLiveUrl(previewUrl()), 350); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewKey]); + + // A new template of another type starts from that type's usual fields; + // an existing one keeps what was chosen for it. + const changeType = (type) => { + setData((d) => ({ ...d, type, ...(creating && defaultFieldsByType[type] ? { fields: defaultFieldsByType[type] } : {}) })); + }; + + const sel = (label, name, map, error, onChange) => (
{label} *
({ value: String(v), label: l }))} + options={Object.entries(map).map(([v, l]) => ({ value: String(v), label: __(l) }))} value={data[name] == null ? '' : String(data[name])} - onChange={(v) => setData(name, v)} + onChange={onChange ?? ((v) => setData(name, v))} className="w-full" /> {error &&

{error}

} @@ -26,31 +80,49 @@ export default function LabelTemplateForm({ form, types, sizes, barcodeFormats, ); return ( -
-
-
{__('Name')} *
- setData('name', e.target.value)} className="form-input w-full" autoFocus /> - {errors.name &&

{errors.name}

} -
+ + setData('name', v)} + error={errors.name} + /> - {sel(__('Type'), 'type', types, errors.type)} + {sel(__('Type'), 'type', types, errors.type, changeType)} {sel(__('Label Size'), 'size', sizes, errors.size)} {sel(__('Barcode Format'), 'barcode_format', barcodeFormats, errors.barcode_format)}
{__('Fields on label')}
- {Object.entries(availableFields).map(([key, label]) => ( + {Object.entries(availableFields).filter(([key]) => !fieldsByType[data.type] || fieldsByType[data.type].includes(key)).map(([key, label]) => ( setData('fields', { ...data.fields, [key]: next })} - label={label} + label={__(label)} /> ))}
+
+
{__('Preview')}
+
+ {liveUrl ? ( +