Skip to content

Pallet Working Release - #4

Open
roncodes wants to merge 236 commits into
mainfrom
dev-v0.0.2
Open

Pallet Working Release#4
roncodes wants to merge 236 commits into
mainfrom
dev-v0.0.2

Conversation

@roncodes

@roncodes roncodes commented Jul 19, 2024

Copy link
Copy Markdown
Member

Pallet v0.0.2 working release — inventory & warehouse management extension.

Completion progress (auto-maintained)

Last updated: 2026-08-21 (iter 46) · phased roadmap per Ron's direction · autonomous completion loop

Phase A — Functional & visual completion (PRIORITY 1) — substantially complete, gate not green

  • Dev toolchain runnable (frozen install, pest bootstrap, lint green) — c33317b, 39fae16
  • Backend test harness (Testbench + SQLite lane; production-like save paths) — 43885fe, b0f7957
  • Frontend boot (prod build green; suite executes on Ember 5.4) — 8d46cc9
  • Stock correctness 7/7: transfer-cancel loss · ledger repair · cycle-count atomicity · PO receive locks · adjustment lock · reservation txn/locks · storefront guards + link uniqueness · inventory create txn — b0f7957, c1e4c4b, b3c8a65
  • Lifecycle gaps 4/4: wave→pick-list allocation · picking moves stock · expiration sweep · status guards — 3601789, 5386de4
  • Schema/model integrity: warehouse eager-load recursion · sales-order customer columns · hierarchy keys + public ids · link uniqueness — 7bd05be
  • i18n sweep complete: sidebar, operations, widgets, facilities, inventory, controller labels — 0995804, 29fa6c1, 51f8b54
  • Full stock chain verified against real MySQL: PO receive (partial + over-receive cap) · SO fulfil (partial + insufficient-stock rejection, no partial mutation) · reserve → commit → re-commit rejected — iters 21–22
  • Frontend defect list closed (dead buttons, phone handler, ledger model+serializer, loading guards, re-exports, debug logging, dead geojson) — b126b19
  • Analytics: 13 metrics endpoints (21 widgets) covered + reorder-risk portability — 90a8c63
  • Storefront integration proven (14 endpoints, idempotency, expiry sweep, isolation) + README corrected — 568a322
  • Per-screen visual verification — every screen visited: dashboard · products · suppliers · warehouses · zones · locations · inventory · batches · adjustments · low stock · expired stock · POs · SOs · all five operations screens · audits · reports
  • Silent-reference sweeps closed: dead valuePaths (0 left) · declared-but-never-emitted attrs (0 left) · payload keys (pinned by test) · filter company-scoping (structural guard test) · panel action buttons · empty details panels · i18n keys
  • Gate A held open on two items: (1) the Octane transaction fault below means no write result on this stack is fully trustworthy; (2) bin-location create was never confirmed through the UI

Phase B — Warehouse layout designer — parked pending your decisions

  • Three design questions need your answer before I build (see PHASE_B_DESIGN.md), chiefly: does the designer model the legacy section → aisle → rack → bin hierarchy or the newer zone → bin-location one?

Phase C — User testing guide — complete

  • 54-step acceptance runbook, 7 sections, happy paths + 8 edge cases, expected result per step
  • Delivered as a private Artifact and in full as a PR comment · no credentials in the guide
  • Leads with the transaction-fault caveat, tabulates known cosmetic issues, and closes with an explicit "what I could not verify"

Phase D — Public consumable API (Fleetbase v1 conventions) — starting now (runs while you test)

  • Convention study: core-api / fleetops / storefront v1 route grouping, API-key middleware, Api/v1 controller shape, validation classes, public_id-keyed resources, error envelopes
  • Public endpoints: products/variants · warehouses/locations · inventory & availability · adjustments/transfers · POs (+receive) · SOs (+fulfill) · batches · audits (read-only)
  • README API docs + behavioural tests (auth, scoping, contract) per endpoint

Phase E — Postman collection (~/Development/fleetbase/postman) — not started

Phase F — 100% coverage · CI · codecov · README badge (LAST) — not started


⚠️ Open, needs your call — not a Pallet bug. api/config/octane.php has its RequestTerminated listeners commented out, with DisconnectFromDatabases registered only under OperationTerminated. The effect is that a write can commit while the response returns 422 "There is no active transaction". Proven in MySQL (a 422'd submit created its row). It affects every module on this stack, so I have deliberately not edited it.

@roncodes roncodes changed the title upgraded dependencies v0.0.2 Jul 19, 2024
roncodes and others added 5 commits October 2, 2024 18:32
This comprehensive refactoring transforms Pallet into a complete enterprise-grade
Warehouse Management System following the Fleetops architecture patterns.

Backend Enhancements:
- Added 11 new models for enterprise WMS features:
  * PickList & PickListItem for warehouse picking operations
  * Wave for wave-based picking management
  * CycleCount & CycleCountItem for inventory accuracy
  * StockTransfer & StockTransferItem for inter-warehouse transfers
  * BinLocation for detailed location tracking
  * WarehouseZone for zone management
  * InventoryReservation for order allocations
  * ProductKitComponent for kit/bundle management

- Enhanced existing models:
  * Product: Added tracking flags, reorder points, shelf life, kit support
  * Inventory: Added lot/serial tracking, multi-UOM, reserved quantities
  * Warehouse: Added zones, bins, capacity tracking, utilization metrics

Frontend Refactoring:
- Refactored Product components to modular Fleetops pattern:
  * product/form.hbs & .js - Comprehensive form component
  * product/details.hbs & .js - Read-only detail view
  * product/panel-header.hbs & .js - Panel header component
  * product/pill.hbs & .js - Compact display component

- Modernized templates:
  * Updated products/index to use Layout::Resource::Tabular
  * Updated products/index/details to use Layout::Resource::Panel
  * Updated products/index/edit to use Layout::Resource::Panel

Database Schema:
- Created comprehensive migration for all new tables and enhanced columns
- Added proper indexes and foreign key constraints
- Supports lot/serial tracking, reservations, and advanced WMS operations

Features Implemented:
✓ Lot/batch and serial number tracking
✓ Inventory reservations (soft/hard)
✓ Pick list management with multiple strategies
✓ Wave-based picking
✓ Cycle counting with variance tracking
✓ Inter-warehouse stock transfers
✓ Bin location and zone management
✓ Kit/bundle product support
✓ Expiry date tracking and alerts
✓ Reorder point management

This establishes the foundation for a complete, scalable, enterprise-grade WMS.
Refactored all remaining resources to follow Fleetops architecture pattern:
- Inventory: form, details, panel-header, pill components
- Warehouse: form, details, panel-header, pill components
- Supplier: form, details, panel-header, pill components
- Purchase Order: form, details, panel-header, pill components
- Sales Order: form, details, panel-header, pill components
- Batch: form, details, panel-header, pill components

Updated all templates to use modern Layout components:
- All index templates now use Layout::Resource::Tabular
- All details templates now use Layout::Resource::Panel with TabNavigation
- All edit templates now use Layout::Resource::Panel with form integration

This completes the frontend modernization across all 7 resources:
✓ Product (previously completed)
✓ Inventory
✓ Warehouse
✓ Supplier
✓ Purchase Order
✓ Sales Order
✓ Batch

Total: 48 new component files + 84 app exports + 18 template updates
Enterprise WMS Refactoring with Fleetops Architecture
@roncodes roncodes changed the title v0.0.2 Working Pallet Release Feb 28, 2026
roncodes and others added 7 commits February 28, 2026 02:37
Backend fixes:
- [B1] Fix WarehouseDockController class name (was WarehouseSectionController)
- [B2] Fix PurchaseOrder resource orde_date_at typo -> order_date_at; add order_number alias for public_id
- [B3] Fix SalesOrder resource orde_date_at typo -> order_date_at; add order_number alias for public_id
- [B4] Rename WarehouesDock.php -> WarehouseDock.php (filename typo fix)
- [B5] Rename SuplierFilter.php -> SupplierFilter.php (filename typo fix)
- [B6] Fix PurchaseOrderController payload key: purchaseOrder -> purchase_order
- [B7] Fix SalesOrderController payload key: salesOrder -> sales_order
- [B8] Fix Audit model User namespace: App\Models\User -> Fleetbase\Models\User
       Add HasPublicId trait, SoftDeletes, align fillable with actual DB schema
- [B9] Add all missing WMS fields to Inventory resource (status, bin_location_uuid,
       zone_uuid, lot_number, serial_number, uom, reserved_quantity, available_quantity,
       max_quantity, reorder_point, unit_cost, received_at, last_counted_at)
- [B10] Fix PalletServiceProvider error messages (Storefront -> Pallet)
- [B11] Fix InventoryController::createRecord - explicitly set batch_uuid on inventory
        record; add received_at timestamp; improve batch_number generation
- [B12] Fix StockAdjustment resource - use incrementing_id; add missing fields
        (inventory_uuid, warehouse_uuid, reason, notes, adjustment_type)
- [B13] Create missing Audit HTTP Resource class

Frontend fixes:
- [F1] Build out Audit model with all attributes, relationships, and computed properties
- [F2] Build out AuditsIndexController with columns, search, queryParams, and tracking
- [F3] Fix routes.js - add index sub-routes to audits, reports, and batch parent routes
- [F4] Build out audits/index.hbs template with Layout::Resource::Tabular
- [F5] Fix inventory model - add all missing WMS fields (bin_location_uuid, zone_uuid,
       lot_number, serial_number, uom, reserved_quantity, available_quantity, max_quantity,
       reorder_point, unit_cost, received_at, last_counted_at, isLowStock, isExpired)
       Fix supplier relationship type (vendor -> supplier)
- [F6] Fix purchase-order model - add order_number, order_date_at, currency, meta attrs
       Fix supplier relationship type (vendor -> supplier)
- [F7] Fix sales-order model - add order_number, currency, meta attrs
       Fix supplier relationship type (vendor -> supplier)
- [F8] Fix inventory-form-panel.hbs - fix @onchange on Supplier ModelSelect
       (was passing string value, now correctly uses fn (mut ...))
- [F9] Fix @isResizeble typo -> @isResizable across ALL form panel components:
       inventory, warehouse, supplier, purchase-order, sales-order, batch,
       product, stock-adjustment, warehouse-editor
- [F10] Create addon/serializers/audit.js
- [F11] Complete translations/en-us.yaml - add all ~120 missing translation keys
        across resource, common, product, inventory, warehouse, supplier,
        purchase-order, sales-order, batch, and audit namespaces
## What changed

### Backend

**Migration (modified, not new)**
- Refactored pallet_audits table: added event_type column (indexed), renamed
  auditable_uuid/type to subject_uuid/subject_type for clarity, added
  scheduled_at/completed_at for time-bounded events, added composite indexes
  on (company_uuid, event_type) and (subject_uuid, subject_type)

**New: AuditEventType constants class**
- Defines all machine-readable event type keys: stock_adjustment, cycle_count,
  po_received, so_fulfilled, stock_transfer, inventory_created, batch_created, etc.

**New: HasOperationalAuditTrail trait**
- Reusable trait any Pallet model can use to call logAuditEvent()
- Automatically captures company_uuid, performed_by_uuid, subject, and meta

**New: AuditService**
- Centralised service for programmatic audit logging from controllers
- Provides log() and logForModel() helpers

**Refactored: Audit model**
- Now immutable (no direct create/update/delete via API)
- Added event_type, subject_uuid/type, scopes (byEventType, bySubject, recent)
- Added SoftDeletes, HasPublicId, correct Fleetbase User namespace

**Refactored: AuditController**
- Now read-only: index() and show() only
- Added eventTypes() endpoint: GET /pallet/v1/audits/event-types
- Filters by event_type, subject_type, performed_by_uuid, date range

**Refactored: Audit HTTP Resource**
- Returns event_type, subject_label, action, reason, meta, performedBy

**Refactored: routes.php**
- Replaced generic fleetbaseRoutes('audits') with explicit read-only routes
- Added GET /pallet/v1/audits/event-types endpoint

**WMS model integrations**
- StockAdjustment: logs STOCK_ADJUSTMENT event on created()
- CycleCount: logs CYCLE_COUNT event on complete() and approve()
- PurchaseOrder: markAsReceived() logs PO_RECEIVED event
- SalesOrder: markAsFulfilled() logs SO_FULFILLED event
- StockTransfer: logs STOCK_TRANSFER event on ship() and receive()

**Spatie LogsActivity added to 8 primary models**
- Product, Inventory, Warehouse, Supplier, Batch, PurchaseOrder, SalesOrder,
  StockAdjustment now all use LogsActivity with logOnly() + logOnlyDirty()
- Consistent with how core-api handles User, Alert, File, etc.

### Frontend

**Audit model**
- Updated to use event_type, subject_uuid/type instead of auditable_uuid/type
- Added eventTypeLabel, subjectLabel, eventTypeBadgeClass computed properties
- Added createdAgo with addSuffix option

**Audits/index controller**
- queryParams updated: event_type + subject_type replace auditable_type
- Columns updated: Event Type, Action, Subject, Subject ID, Reason, Performed By, Date
- Added eventTypeOptions array for dropdown filter
- Added filterByEventType() and clearFilters() actions

**Audits/index template**
- Added event type filter dropdown in subheader slot
- Added clear filters button (shown when any filter is active)
- Set @Cancreate=false and @canDelete=false (immutable trail)

**Audit serializer**
- Removed createdBy embedded relation (no longer in schema)

**Translations**
- Expanded audit section with event-types, filter labels, search placeholder
- Added common.clear_filters and common.no-records keys
Backend:
- Add migration: purchase_order_items and sales_order_items tables
  with full schema: product_uuid, warehouse_uuid, quantity, quantity_received/
  quantity_fulfilled, outstanding_quantity, unit_price, unit_cost, total_price,
  currency, sku, lot_number, serial_number, expiry_date, unit_of_measure,
  status, notes, meta, received_at/fulfilled_at
- Add PurchaseOrderItem model with recalculateTotalPrice(), relationships to
  Product, Warehouse, PurchaseOrder; LogsActivity trait
- Add SalesOrderItem model with recalculateTotalPrice(), relationships to
  Product, Warehouse, Inventory, SalesOrder; LogsActivity trait
- Add hasMany items() + item_count/total_value aggregates to PurchaseOrder model
- Add hasMany items() + item_count/total_value aggregates to SalesOrder model
- Add PurchaseOrderItemController (index, store, update, destroy)
- Add SalesOrderItemController (index, store, update, destroy)
- Add PurchaseOrderItem and SalesOrderItem HTTP Resources
- Update PurchaseOrder resource to include items, item_count, total_value
- Update SalesOrder resource to include items, item_count, total_value
- Add nested item routes: GET/POST/PUT/DELETE for both PO and SO items

Frontend:
- Add purchase-order-item Ember model with all attributes + computed helpers
- Add sales-order-item Ember model with all attributes + computed helpers
- Add hasMany items + item_count/total_value to purchase-order Ember model
- Add hasMany items + item_count/total_value to sales-order Ember model
- Add purchase-order-item and sales-order-item serializers
- Update purchase-order and sales-order serializers to embed items
- Add purchase-order-panel/items tab component (HBS + JS) with:
  - Inline add row with product ModelSelect, SKU, quantity, unit price
  - Inline edit row per item
  - Read-only rows showing product, SKU, qty, qty received, unit price, total
  - Status badge per item
  - Delete per item
  - Disabled when order is received/cancelled
- Add sales-order-panel/items tab component (HBS + JS) with same pattern
  (qty fulfilled instead of qty received)
- Wire Items tab into purchase-order-panel and sales-order-panel components
- Expand translations: purchase-order.line-items.* and sales-order.line-items.*
Backend:
- PurchaseOrderController: full receive() action with DB transaction,
  line-item iteration, inventory create/increment, lot/serial/expiry/bin
  tracking, PO status transition (partial/received), audit trail logging
- SalesOrderController: full fulfill() action with pre-flight stock check,
  FEFO inventory selection, available_quantity deduction, SO item status
  tracking, SO status transition (partial/fulfilled), audit trail logging
- routes.php: POST purchase-orders/{id}/receive and sales-orders/{id}/fulfill

Frontend:
- receive-purchase-order-form-panel: order summary, per-item receipt rows
  with ordered/received/outstanding quantities, lot/expiry/notes inputs,
  submits to API, calls onReceived callback on success
- fulfill-sales-order-form-panel: order summary, FEFO notice, per-item
  fulfillment rows with ordered/fulfilled/outstanding quantities, notes
  input, submits to API, calls onFulfilled callback on success
- context-panel.js: registered receiving intent for purchaseOrder and
  fulfilling intent for salesOrder
- purchase-orders/index.js: receivePurchaseOrder() action, improved columns
- sales-orders/index.js: fulfillSalesOrder() action, improved columns
- translations/en-us.yaml: added receive and fulfill translation keys
…emplates

Both purchase-order-panel/items.hbs and sales-order-panel/items.hbs had
the {{#if (eq this.editingItem.id item.id)}} block incorrectly closed
with {{/each}} instead of {{/if}}, with the {{/each}} for the outer
each loop also missing. This caused a Babel build error:
  'if doesn't match each - 28:23'

Fixed both files:
  - {{/each}} on line 89 replaced with {{/if}}
  - {{/each}} added on line 90 to correctly close the outer each loop
@roncodes roncodes changed the title Working Pallet Release Pallet Working Release Feb 28, 2026
roncodes and others added 12 commits March 1, 2026 15:29
- extension.js: register 'pallet' dashboard via widgetService.registerDashboard()
  and 8 widgets via widgetService.registerWidgets() using correct Widget +
  ExtensionComponent pattern (Widget/ExtensionComponent from @fleetbase/ember-core/contracts)
  Removed unused Hook import. Default widgets: inventory-summary, low-stock,
  po-status, so-status, recent-activity. Optional: stock-value, expiring-stock,
  top-products.

- templates/home.hbs: replaced bare {{outlet}} with <Dashboard> component using
  @defaultDashboardId='pallet', @defaultDashboardName='Pallet Dashboard',
  @extension='pallet' inside <Layout::Section::Body> with overflow scroll.

Frontend widget components (widget/ namespace):
- widget/inventory-summary: 5-KPI banner (SKUs, units, value, warehouses, low-stock)
- widget/low-stock: table of products at/below min_stock_level
- widget/po-status: 4-status badge grid + recent PO list
- widget/so-status: 4-status badge grid + recent SO list
- widget/recent-activity: scrollable audit trail feed with event icons
- widget/stock-value: horizontal bar chart of value per warehouse
- widget/expiring-stock: table of batches expiring within 30 days
- widget/top-products: ranked bar chart of most-moved products

Backend:
- MetricsController: 7 read-only endpoints (inventory-summary, low-stock,
  po-status, so-status, stock-value, expiring-stock, top-products), all scoped
  to session company_uuid
- routes.php: added metrics prefix group with all 7 endpoints under
  fleetbase.protected middleware
PHP fatal error: 'Cannot declare class CreateOrderItemsTables, because the
name is already in use' was caused by a naming conflict with another package
that registers a migration class with the same name.

All 14 named-class migrations have been converted to the anonymous class
pattern (return new class extends Migration { ... };) which is the modern
Laravel standard and completely eliminates cross-package class name conflicts.

The 2024_11_06_create_wms_tables.php and 2024_11_07_create_order_items_tables.php
files were already using the anonymous pattern and were left unchanged.
pallet_purchase_order_items and pallet_sales_order_items were referencing
'pallet_products' and 'pallet_warehouses' which do not exist as standalone
tables. The correct backing tables are:

  - pallet_products  → 'entities'  (Product extends FleetOps Entity)
  - pallet_warehouses → 'places'   (Warehouse extends Fleetbase Place)

The 'pallet_inventory' reference on sales_order_items is correct and unchanged.

All other Pallet migrations already use the correct 'entities' and 'places'
table names consistently.
pallet_sales_order_items referenced 'pallet_inventory' but the actual
table created by the inventory migration is 'pallet_inventories' (plural).

Fixed: ->on('pallet_inventory') → ->on('pallet_inventories')
…et_warehouses table

- Create new pallet_warehouses migration with WMS-specific fields:
  code, type, status, capacity, current_utilization, floor_area_sqm,
  operating_hours, timezone, phone, email, manager_uuid, total_docks,
  is_active, is_default, meta, place_uuid (FK to places table)

- Update 8 existing migrations to reference pallet_warehouses instead
  of places for warehouse_uuid foreign keys

- Rewrite Warehouse PHP model to extend Model (not Place) with:
  - place_uuid belongsTo(Place) for geographic/address data
  - company(), createdBy(), manager() relationships
  - All WMS hasMany relationships preserved
  - getAddressAttribute() proxy to linked Place
  - getTotalInventoryValue() using entities table

- Update WarehouseController to create/update linked Place from
  address fields on create/update operations

- Rewrite WarehouseResource to proxy address fields from linked Place
  and include new WMS-specific fields (code, type, status, capacity,
  utilization_percentage, floor_area_sqm, operating_hours, etc.)

- Update WarehouseFilter to remove type=pallet-warehouse constraint
  and add proper type/status/isActive filter methods

- Update 9 Pallet models to use Warehouse class instead of
  FleetOps\Place for warehouse_uuid relationships:
  Inventory, WarehouseSection, WarehouseDock, WarehouseZone,
  BinLocation, CycleCount, StockTransfer, PickList, Wave,
  InventoryReservation

- Rewrite Ember warehouse model to extend Model (not PlaceModel)
  with all WMS attributes, place belongsTo, and computed properties

- Update warehouse serializer to embed place, sections, docks, zones

- Update warehouse-form-panel with two content panels:
  'Warehouse Details' (name, code, type, status, capacity, phone,
  email, is_active, is_default) and 'Address' (street, city, etc.)

- Update warehouse/details.hbs to show new WMS fields (code, type,
  status, is_active) replacing the old is_3pl field

- Expand translations with new warehouse field keys (code, type,
  status, is-active, is-default, email, phone, floor-area, timezone,
  total-bins, total-docks, total-zones, utilization)
  and add common.active/inactive keys
@ukane-philemon

Copy link
Copy Markdown

Hello @roncodes!

Well done!

- align ember-concurrency to ^4.0.6: committed pnpm-lock.yaml resolved 4.0.6
  against specifier ^3.1.1, so 'pnpm install --frozen-lockfile' (the CI install
  step) failed on this branch; lockfile regenerated, frozen install verified
- composer test:unit now self-heals a vendor -> server_vendor symlink: pest's
  binary resolves vendor/autoload.php relative to itself, so the renamed
  vendor-dir made 'composer test:unit' unrunnable as shipped
- run pest with E_DEPRECATED suppressed so the EOL pest v1 stack boots on
  PHP 8.4 (CI's PHP 8.2 unaffected)
- ignore /vendor (compatibility symlink)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roncodes and others added 30 commits August 31, 2026 04:02
The reports screen mounted the shared ReportBuilder correctly — SCREENS.md section G says
to use it verbatim, and it renders — but its data source list read "No results found".
Nothing had registered Pallet's tables with the reporting registry, so the query builder
had nothing to query and no report could be produced at all. The screen looked finished
and could do nothing.

FleetOps, Fliit and Al Rashed all register through the same ReportSchemaRegistry via a
ReportSchemaServiceProvider; Pallet simply had neither. This follows that pattern rather
than inventing one.

The eight tables are the ones section G's starter reports need: stock on hand by
warehouse and stock valuation from inventories and warehouses, movement by product from
stock movements, receipt accuracy by supplier from purchase orders and suppliers, count
variance history from cycle counts, and expiry exposure from inventories — which is why
expiry_date_at is filterable and sortable rather than merely present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Stock Value tile read "$0" against 94 units on hand. Arithmetically that was right —
every inventory row has a null unit_cost and COALESCE turned each into zero — but a
warehouse holding stock and reporting no value reads as a broken tile, not as missing
cost data. It is the zero-versus-unknown confusion again, this time on the first number
anyone sees in the module.

Falling back to the product's sale price was the tempting fix and the wrong one.
Valuation is a cost figure; quietly substituting price would misstate a number people
reconcile against their books.

So the metric distinguishes three cases: nothing on hand carries a cost, and the value is
null with a footnote saying so; part of the stock is costed, and the value is real but
the footnote names the units it excludes rather than letting an understated total pass as
complete; everything is costed, and it reports plainly. An empty warehouse still reports
zero, because that is a real zero.

The tile turned a null straight back into "$0" via `?? 0`, so it now renders a dash for
an absent value while a genuine zero still renders as zero.

StockValuationTest pins all four cases. Suite 310 -> 314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SCREENS.md section A gives every widget a click-through to a pre-filtered list, because
the manager reading this screen "came to find out what needs them today". None of the
eight KPI tiles was clickable: a tile reading "LOW STOCK 3" made them go and find those
three themselves, which is the opposite of the point.

Six now lead where their number is actionable — low stock, expiring soon, open POs, open
fulfillment, total SKUs and stock value. Available units and reserved units deliberately
stay inert: neither has a list that means "these units", and a link landing on the whole
inventory list would be approximate rather than useful. A link to the wrong list is worse
than no link.

The target is a button stretched over the card rather than the card conditionally being a
button. An element's opening and closing tags cannot straddle a conditional, and
duplicating the body into both branches would mean maintaining it twice. This keeps the
markup balanced, makes the whole card the target, and keeps it a real button — focusable
and operable by keyboard — rather than a div wearing a click handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SCREENS.md section F lists this screen as Reservation ID, Product, Batch, Warehouse, Qty
reserved, Sales order, Reserved at, Expires at, Status. The reservation id was absent
entirely, and expires_at was declared but hidden — the one column that makes a
reservation need attention, on the screen whose whole subject is stock held against
future demand. Reserved at and batch were missing too.

Variant and the storefront context move to hidden in exchange. They stay available from
the column picker for the storefront cases that need them, and keeping them visible is
what pushed the row past the table's width and clipped the action menu. The visible set
is now 970px against a 1033px table, so the menu fits.

Sales order is deliberately still absent. The model carries sales_order_uuid but no
relation to read an order number from, and a column headed "Sales Order" showing a bare
uuid is worse than no column. It needs the relation first.

Dates are formatted on the model the way every other list in this module formats them,
rather than letting a Date object reach the cell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adding the columns section F asks for pushed the table to 1234px against a 1033px
container, and the two that fell off the right edge were Status and the action menu —
the only interactive things on the row.

The reservation id is hidden by default in exchange. It is an internal identifier nobody
scans a list by, so losing it off-screen costs nothing, while losing Release and Fulfill
costs the screen its purpose. It remains one click away in the column picker.

The table sizes columns to their content, so declared widths are hints rather than
limits — a warehouse named "Singapore Distribution Center" takes 209px whatever the
column declares. Fitting a list means choosing what is on it, not shrinking numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27px still hung off the right edge and the action button sat in it — 43 of its 70px were
visible, which put the button itself out of reach. A batch number is short; the declared
100px minimum was doing the damage rather than the content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The iteration 21 survey found transfers, pick lists and reservations clipping their
action menu and deferred the fix; reservations was done last iteration and these are the
other two, plus zones, which turned out to be far worse than the survey noticed.

Zones was 515px over a 1033px table, and **456px of that was the Created At column
alone**. It bound straight to the `date` attribute, so the cell rendered a JavaScript
Date through toString — "Sat Aug 22 2026 06:20:00 GMT+0800 (Singapore Standard Time)" —
and pushed capacity, utilisation and the action menu off the screen. The zone model now
formats it as every other model in this module does.

The same screen carried nine hardcoded English column labels; eight had translation keys
already defined and unused, so they now use them.

Transfers was 30px over and pick lists 34px. Both are trimmed on the declared minimum of
a column whose content does not need it, rather than on the ones holding warehouse names
— declared widths are floors, and the table sizes to content above them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reaching for warehouse.fields.code headed the column "Warehouse Code" — it is the zone's
code, not the warehouse's, and the wrong label also stretched the header to 161px.
common.code did not exist, which is why I reached for the wrong key; it does now.

Created At is hidden by default on this list. Formatting it took the column from 456px to
144px, but the table was still 253px over: it is the column that is present because it is
easy rather than because a zones list needs it, and hiding it buys back the width the
useful columns need. Still available from the picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formatting the date and hiding it took the table from 515px over to 59, but the action
menu was still short of the edge. Zone name and type are the two declared minimums with
room to give; a zone name is short and a type is one word.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section G says Pallet's contribution to reporting is starter reports, not builder UI —
"must never: build a second query UI". These are ordinary saved reports against the
shared ReportBuilder, so they open, edit and execute exactly like one a user built.

The query config is assembled from the schema registry rather than written out. A saved
config embeds the whole table definition — every column with its type, label and
capability flags — so hand-writing six of them would duplicate that metadata six times
and let it drift the moment PalletReportSchema changes. Reading the registry means a
starter report cannot describe a column that does not exist.

The shape was learned by building one report through the UI and reading back what the
builder saved, rather than inferred from the converter. That is also why each selected
column carries an `alias` the table schema does not: the builder writes one, and the
column select renders blank without it.

Idempotent by title within a company, so running it twice creates no duplicates and an
existing report is never overwritten — someone may have edited theirs. --dry-run lists
what it would do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getTableSchema() wraps the table in {table, columns, relationships,
auto_join_columns} — there is no `name` or `label` at the top level — so a report
seeded from it opened with its columns resolved but the data source trigger blank.
getTable()->toArray() is the shape the builder itself saves.

Caught by diffing a seeded report's query_config against one built through the UI,
which is the same comparison that taught me the shape in the first place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Executing a seeded report failed with "Unknown column
'pallet_inventories.batch_number' in 'field list'". The report schema I added in the
previous iteration was written from the Ember models rather than from the database, and
several columns do not exist:

  pallet_inventories has no batch_number  (batches are a relation; it has unit_cost,
                                           which is what a valuation report needs)
  pallet_products has no price            (unit_price, unit_cost, sale_price)
  purchase orders use order_created_at    (order_date_at is the sales order column)
  sales orders have no currency
  there is no pallet_suppliers table      (suppliers are FleetOps' `vendors`, already
                                           registered by FleetOpsReportSchema)

**None of this was visible in the interface.** The builder listed the tables, resolved
the columns, and let a report be built and saved; it failed only at execution, and the
preview swallowed the error into an empty table. I spent an iteration clicking through
the UI trying to explain that empty preview when one `execute()` call in tinker printed
the reason immediately.

ReportSchemaColumnsTest now checks every declared column against Schema::getColumnListing
and every starter report's selection against the registry. Both would have caught all
five faults at once — and the second matters because the seeder drops a column it cannot
find, so a typo yields a quietly incomplete report rather than a failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sweep across the module found nine lists overflowing their container — warehouses by
663px, stock adjustments by 787px, inventory by 326, suppliers by 241, products by 144 —
and the column that fell off the edge was always the last one, which is the row-action
menu. The list still scrolled, so nothing looked broken, but the only control on the row
was out of reach until you scrolled sideways to find it.

Four of those were fixed by trimming declared widths, one screen at a time. That works
and it does not hold: a declared width is a floor and the table sizes to content, so one
longer supplier name puts the menu back off the edge. Pinning the column fixes every
list at once and survives any content.

Scoped to `console-pallet-*`, the per-route class the console puts on <main>. Pallet's
stylesheet is bundled into the host app rather than namespaced, so an unscoped
`.next-table-wrapper` rule here would restyle Fleet-Ops and Storefront tables too.

No background is declared: these cells already carry their own opaque one, so scrolled
content does not show through, and setting a colour here would be a second source of
truth for it. stylelint unchanged at its 92 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SCREENS.md section B gives this screen two must-nevers: never show a quantity column
without saying which quantity, and never sum quantities across warehouses into one
number without labelling it as all-warehouse. The product list did both.

Steel Shelving Bracket read "Available 94". That 94 is 76 units in Singapore plus 18 in
Kuala Lumpur — the columns read `inventory_summary`, which is an aggregate across every
warehouse — and nothing on a catalogue-level list says so, which invites reading it as
stock available in one place. "Total" said even less: total of what?

Now "Available (All Warehouses)", "On Hand (All Warehouses)" and "Reserved (All
Warehouses)". Long headers, but the sticky action column means a wider table no longer
costs anyone the row menu, and a buyer misreading availability costs more than a wide
column does.

The same screen carried eighteen hardcoded English column labels; they now use
translations, reusing the keys that already existed for status, id, product, sku, unit
price, unit cost and the timestamps rather than adding near-duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The locations list had the same two faults the zones list had, found by checking rather
than assuming they were unrelated screens: Created At bound straight to the `date`
attribute, so the cell rendered a JavaScript Date through toString, and nine column
labels were hardcoded English.

Its "Available" column is available *capacity*, not available stock. Sitting next to
Capacity that is guessable, but a bin's free space and the stock in it are different
facts, and this is the same ambiguity section B forbids on the product list.

Eight labels now use translations, reusing keys that already existed — including the
locations block's own `bin`, rather than borrowing the pick-list one I first reached for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adjustment history showed product, warehouse, type, reason and date — and not the
quantity. A 200px free-text reason column sat in front of the numbers and pushed them
past the right edge of a table that was already 787px over, so the list said why every
adjustment happened and never how much.

The delta now sits with before and after, which were hidden. Those two are what make a
delta checkable, and on an immutable audit row they are the record's substance rather
than a detail — 8 -> 18 says something "+10" alone does not.

Reason keeps its place, after the numbers. Variant is hidden: it is empty on every row
and cost 150px in front of them.

The sticky action column added in iteration 37 protects the row menu but not the data,
so a wide table can still bury the fact the screen exists to show. Ordering is what
fixes that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sticky rule matched `td:last-child`, so on a list with no row actions it pinned an
ordinary data column. Stock adjustments is that list: its last column is the date, and
pinning it floated the timestamp over the reason text beside it — a regression I
introduced while fixing a different one.

Now matched on `.overflow-visible`, the class every one of the module's sixteen dropdown
columns sets. The header is deliberately left unpinned: `th` carries no such class, and a
pinned header above unpinned body cells would drift out of line.

stylelint unchanged at its 92 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SCREENS.md section B lists six defects to fix in `product-form-panel` while implementing
the product form: an `@isResizeble` typo, an upload path copied from the driver panel
(`uploads/{company}/drivers/{id}` with `subject_type: 'product'`), a
`productStatusOptions` array containing `do-not-product` — a mangled paste of a contact
panel's `do-not-contact` — hardcoded English throughout, a magic
`@increaseInnerBodyHeightBy={{700}}`, and a lowercase model name in its success toast.

None of them are worth fixing, because **nothing reaches the component**. The form was
rebuilt as `Product::Form` and the routes render that; `product-form-panel` was left
behind. Its only referrer is `product-panel`, whose only referrer is
`product-form-panel` — a closed loop of two components pointing at each other and
nothing else pointing at either. The products list opens details through
`catalog.products.index.details`, which uses `Layout::Resource::Panel` with
`product/panel-header`, a different and live component.

So the defects go with the code: 491 lines, both `app/` re-exports, and the two
context-panel registry entries that were the last thing naming them. Verified
unreferenced by angle-bracket invocation, the `component` helper, and plain string
search before removing anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section B asks the quantity panel for the totals "then a per-warehouse breakdown table".
It had the totals alone, so "Available 94" was 76 units in Singapore and 18 in Kuala
Lumpur presented as one number with no way to tell them apart — the same ambiguity the
product list carried until the previous iteration.

A breakdown answers it better than a label can. Rather than telling the reader the number
is a sum, it shows them the parts.

Underneath it, a fail-open filter. `product`, `warehouse` and `variant` were all listed
in Inventory's $filterParams with no method in InventoryFilter to act on them, so each
was accepted and silently ignored — the fourth instance of this in the module, after
suppliers, docks, zones and bins. Asking for one product's inventory would have returned
every row the company owns and the panel would have shown it as that product's stock:
a wrong answer rather than an error. Two tests pin it.

The panel loads on demand and is collapsed by default, since most visits to a product
want its details rather than its distribution.

Suite 316 -> 318.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Stock by Warehouse panel came back empty on a product holding 94 units, and said so:
"not stocked in any warehouse" directly beneath a total of 94.

The listing applies summarizeByProduct unconditionally — one row per product with its
quantities added across every warehouse. That is right for the inventory list and the
low and expired stock screens, and it is the wrong shape for a panel asking where the
stock is: the rows are already collapsed, so a per-warehouse breakdown cannot be read
back out of them.

`summarize=0` returns the underlying rows, one per product and warehouse. Default is
unchanged, so every existing caller keeps the summarised listing it expects.

Worth naming: the panel failed loudly rather than silently, because it distinguishes "no
inventory rows" from "zero stock" and said the first. A component that had shown an
empty table would have looked plausible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`summarize=0` never reached the server: the client adapter prunes falsy query params, so
the request went out as `?limit=200&product=...&with[]=warehouse` and the default won
silently. The panel stayed empty and nothing in the browser said why — the network log
did, which is where I should have looked first rather than re-reading the controller.

`by_warehouse=1` instead. A flag whose meaning lives in its presence cannot be dropped
by falsy pruning, and it reads better at the call site than a negated default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neither `summarize: 0` nor `by_warehouse: 1` ever left the browser. The request went out
as ?limit&product&with[] both times, so the summarised default won and the panel reported
"not stocked in any warehouse" beneath a total of 94.

My first reading was that the adapter prunes falsy params; the truthy flag disproved it.
The adapter only forwards query params it recognises, so a custom one cannot be passed
through `store.query` at all. The network log said this plainly on the first attempt —
reading it twice would have saved a commit.

`fetch` forwards what it is given, and the KPI tiles already read this API that way. The
component works with plain JSON now, which is fine for a read-only table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unsummarised listing returned 500: "syntax error near 'from `pallet_inventories`'".
The controller's `array_shift($queryBuilder->columns)` hotfix drops a column that
summarizeByProduct then replaces with its own aggregates. On the plain listing nothing
puts it back, so the query went out as `select from pallet_inventories`. Moved inside the
branch that needs it.

The panel also said the wrong thing while that was happening. It reported "not stocked in
any warehouse" — a claim about the data — when the request had failed and it knew
nothing about the data at all. An error and an empty result are different answers and no
longer share a branch.

That is the trap I named two iterations ago about the report builder, built into my own
component this time: a screen that reports nothing rather than something wrong sends you
looking in the wrong place. It cost three commits here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every quantity in IndexInventory reads an alias that Inventory::scopeSummarizeByProduct
produces — total_quantity, total_reserved_quantity, minimum_quantity and the rest. The
same controller now also serves the unsummarised listing, where none of those aliases
exist, so a per-warehouse row reported 0 on hand, 0 reserved and 0 available for stock
that is plainly there.

Zero looks like an answer. The warehouse names were right, the numbers were wrong, and
nothing distinguished "this warehouse holds none" from "this field was never selected" —
the fourth time this loop has turned on that distinction.

Each field now falls back to the column it aggregates. The summarised listing is
unchanged: where the alias exists it still wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reservations list could not name the order a reservation belonged to. The
model has carried sales_order_uuid and a salesOrder relation since the WMS
refactor, and the relation has been in the model's $with the entire time — so
the order was loaded on every query and the resource emitted nothing but a raw
uuid. §F's Sales Order column was left out because a column of uuids is worse
than no column at all.

The eager load was worse than useless. SalesOrder's own $with is customer,
warehouse, items.product, items.variant, items.warehouse and items.inventory,
so listing reservations hydrated an entire order and its whole line-item tree
per row, then threw all of it away. The relation now uses withOnly([]) — the
order itself, nothing beneath it.

The resource emits a reference rather than the SalesOrder resource. That
resource renders items through whenLoaded('items', $this->items ?? []), whose
second argument PHP evaluates before whenLoaded can decide anything, so it
lazy-loads the items and would have put a full order tree on every row.

Rendered through cell/related-record, already used for three columns on the
adjustments list, so an order deleted after the stock was held reads as such
instead of as a reservation that was never held for anything. The serializer
needed the same underscore bridge inventory.js needed for bin_location:
ember-core's ApplicationSerializer calls keyForRelationship without defining
it, so sales_order never reaches salesOrder and the column reads blank.

Verified against the running instance: reservation_q1l2j2ab49 now reports
SO-6A89F293F2F35, the two unlinked reservations render a dash, and listing all
three costs one sales-order query and zero line-item queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

show what a reservation is being held for
Ember CI failed at Lint with 92 stylelint errors, all in pallet-engine.css. PHP
CI failed at php-cs-fixer with three files. Neither workflow had ever reached
its later steps, so nothing behind those gates had been exercised.

The stylelint errors were four kinds:

  52  selector-class-pattern    thirteen classes written in BEM (__element,
                                --modifier) among the ~50 plain kebab-case ones
                                in the same file. Renamed, with their templates.
                                .svg-inline--fa is FontAwesome's own generated
                                class and is not ours to rename, so its three
                                rules carry a scoped disable instead.
  29  no-descending-specificity mostly selector lists ordering .dark after
                                body[data-theme='dark']. Reordering within one
                                list cannot change anything — every selector in
                                it shares the declaration block.
   5  no-duplicate-selectors    four selectors each split across two or three
                                rules with disjoint properties. Merged.
   6  auto-fixable              empty lines, and overflow-x/y as a shorthand.

Nine rules were `body[data-theme='dark'] X` duplicating the `[data-theme='dark']
X, .dark X` rule directly above with identical declarations. Folded in. The
body-prefixed form was always redundant: [data-theme='dark'] X already matches
everything body[data-theme='dark'] X does.

Two exceptions remain, both bare `div` key selectors ranked below a rule from
another component that sets different properties. Hoisting them ~300 lines to
satisfy source order would split their own component blocks apart.

Every one of the 243 selectors resolves to identical declarations before and
after, checked with postcss — the one intended difference is the overflow
shorthand.

PHP CI's three files were unused imports, import order, and increment style,
all from earlier commits of mine on this branch. php-cs-fixer --fix.

Behind that gate, phpstan level 0 found a real bug: the public sales order API
imported Fleetbase\Models\Contact, which does not exist. Contact lives in the
FleetOps package and every other file in Pallet says so. Contact::where() would
have thrown at runtime; Contact::class did not throw at all, because ::class is
resolved by the compiler as a literal string, so it silently wrote a
customer_type no other code reads back. Tests cover both halves.

324 tests pass, up from 322. stylelint, eslint and php-cs-fixer are clean.
phpstan above level 0 is unresolved and reported separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PHP CI's static analysis step has been red since the config was added and had
never once passed: 2752 errors at level max. Almost none were defects. PHPStan
cannot see Eloquent — Model::where(), $model->uuid, $order->items and every
relation read are undefined symbols to it — so the analysis was reporting the
framework, and the real findings were buried.

Four changes make it see the framework:

  larastan             the Eloquent extension. On its own it removed only 15%
                       (2752 -> 2330): in a package with no bootable app it
                       cannot infer model properties from the database.
  @Property blocks     generated for all 28 models from the table schema, the
                       model's casts and its relations. 602 columns.
  @mixin               on all 45 API resources. A JsonResource forwards unknown
                       properties to the model it wraps through __get, and
                       naming that model is what lets the analysis follow it.
                       This was the big one: level 1 went 973 -> 30.
  return types         on 125 relation methods. larastan resolves relations from
                       the return type, so without them ->with('variant') was
                       reported as a relation that does not exist.

Level 1 across the whole tree, down from 2752 at max / 1401 at level 1:

    level 1:   0        level 2:  77        level 3: 230
    level 4: 238        level 5: 238        level 6: 737

Level 1 is where "this method does not exist" and "this property does not
exist" are enforced, and it immediately found a live bug: SupplierFilter::query()
called $this->scopeToPalletSuppliers(), a method renamed to the scopeToCompany()
override in 3c52444 with this one call site missed. Every search on the
suppliers list raised "Call to undefined method". The listing worked, so only
typing in the search box hit it, and nothing exercised that path.

Six fields the API emits that no model can supply are listed in ignoreErrors
rather than silenced, each with what is actually wrong. reportUnmatchedIgnoredErrors
is on, so fixing one fails the config until its entry goes. The worst is
photo_url: pallet_products has photo_uuid and no accessor for a url, while the
console renders @resource.photo_url in three places — a product photo shows
after upload and is gone on the next load.

325 tests pass, up from 324. composer test:lint, test:types and test:unit are
all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each of these was a resource reading a property no model had. A JsonResource
forwards unknown properties to its model through __get and gets null back — no
error, no warning, just a field that is never populated. They were listed in
phpstan.neon.dist last commit; they are fixed here and the ignoreErrors block
is gone, so level 1 now passes with nothing suppressed.

photo_url (both Product resources)
    pallet_products has photo_uuid and no url column, and nothing derived one.
    The console reads photo_url in eleven places — the product pill, the panel
    header, the details panel, the catalogue list and three inventory lists —
    and the upload form sets photo_uuid and photo_url on the record as soon as
    a file is chosen. So a photo appeared the instant it was uploaded and was
    gone on the next load, falling back to the placeholder every time.

    Product now has the photo relation the console already assumed, and
    photo_url reads through it. Eager-loaded, because appending it would
    otherwise lazy-load one file per row on seven different lists. Null rather
    than a placeholder when there is no photo: every caller supplies its own
    fallback and they differ.

incrementing_id (Audit, StockAdjustment resources)
    Both tables have an id column; neither model appended it, so the `id` the
    resources emit for internal requests was null on every row. Harmless only
    because the Ember serializer keys records on uuid.

currency (SalesOrder resource) and customer_reference_code (PurchaseOrder)
    pallet_sales_orders has customer_reference_code and no currency;
    pallet_purchase_orders has currency and no customer_reference_code. Both
    internal resources emitted both. The public v1 resources have the split
    right, which is what gives the copy-paste away. A purchase order has a
    supplier, not a customer, so customer_reference_code was never meaningful
    there. Also dropped currency from the sales order's activity log options,
    which was watching an attribute that cannot change.

    The line items carry their own currency, so the sales order panel's total
    is denominated from them instead of from the order's absent field.

Product::getIncrementingIdAttribute() re-selected the id already on the row,
one query per product on every list that appends it. It reads the attribute now.

Six tests, five of which fail without these changes; the sixth guards against
substituting a placeholder image for a missing photo. Verified against the
running instance inside a rolled-back transaction: linking a real file to a
real product produced its url, and both order resources emit only their own
column. 331 tests pass, up from 325.

The files shim in the test harness gained the slug column File writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pallet's CI has run Node 22.x everywhere since it was written — the ember
workflow sets NODE_VERSION: 22.x for all three of its jobs — while package.json
claimed ">= 18". The console, which loads this engine, already requires ">= 22".

Nothing here still supports 18, so the declaration was the only thing out of
date. .nvmrc pins the same major for local shells: without it a machine with
several Node versions installed will happily pick an old one, which is exactly
what happened while investigating the ember test failures — the suite was being
run on 18.15.0 against a package built for 22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants