Pallet Working Release - #4
Open
roncodes wants to merge 236 commits into
Open
Conversation
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
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
- 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
|
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 keysPhase B — Warehouse layout designer — parked pending your decisions
PHASE_B_DESIGN.md), chiefly: does the designer model the legacysection → aisle → rack → binhierarchy or the newerzone → bin-locationone?Phase C — User testing guide — complete ✅
Phase D — Public consumable API (Fleetbase v1 conventions) — starting now (runs while you test)
Api/v1controller shape, validation classes,public_id-keyed resources, error envelopesPhase E — Postman collection (~/Development/fleetbase/postman) — not started
Phase F — 100% coverage · CI · codecov · README badge (LAST) — not started
api/config/octane.phphas itsRequestTerminatedlisteners commented out, withDisconnectFromDatabasesregistered only underOperationTerminated. The effect is that a write can commit while the response returns422 "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.