Notebook
Scratch pad
PRD Page 1
Centerpoint — Product Requirements & System Reference
Status: PHASE 3 COMPLETE — Draft 1 (20260704). This file integrates all 25 draft sections from Phase 3 synthesis. Part I–III body content merged from domain drafts; Appendices A–G structured with C–G renumbered (former C/D/E/F now D/E/F/G). See the effort ledger in todo.md → "Composite PRD — Product & System Reference (multi-phase)".
0. About This Document (Charter)
What this is. A permanent, readable, searchable reference to the Centerpoint product as a whole — the legacy Visual FoxPro application and its modern Laravel/Vue replacement, treated as one continuous product story. It captures durable intent: what the product does, why, for whom, how the legacy behavior was carried forward, and where we have deliberately gone further.
Who it is for. Developers, designers, and project managers alike. A non-developer should be able to read the body and understand the product; a developer should be able to drop into a domain chapter and find the map to the code.
What this is NOT.
- Not a live status tracker or backlog — that is todo.md.
- Not a row-level parity/drift report — that is PARITY.md.
- Not the granular technical truth — the code bases remain the source of truth for exact behavior. This document is the readable map above them and links down where detail is needed.
Relationship to the living docs. The developer/LLM docs (PARITY.md, todo.md, staff/docs/memory/*, the LEGACY_VFP_* reconstructions) track volatile state and deep technical detail. This document coexists with them at a higher, more durable altitude and does not duplicate them. Where content is volatile (live parity status, open tickets), this document links out rather than copying.
How "parity" is framed here. Not as a drift report. As decided intent, in the form: "The legacy app does X this way; we ported that forward as Y, with the following enhancements Z (and diverged at W, because …)."
How to read it — audience layers.
- Body prose is for everyone.
- Lines marked
> 🔧 For builders:carry developer detail (services, routes, anchors) and can be skipped by non-technical readers. - Appendices (Part IV) hold the deep technical material — parity matrix, legacy procedure glossary, data reference.
Maintenance. Update when durable intent changes (a new decided direction, a shipped domain milestone, a retired feature) — not on every commit. Something being further along than when this was written is not "stale"; a reversed decision or abandoned direction is. Lives in staff/storage/notes/ and is surfaced in the in-app chat Documentation tool.
Part I — Product Overview
1. Executive Summary — what Centerpoint is
Centerpoint is a comprehensive book supply and fulfillment platform — combining purchasing, inventory management, order fulfillment, and customer account management — designed to help schools and libraries efficiently acquire, manage, and receive books through a seamless digital experience.
At its core, Centerpoint solves a fundamental operational challenge: making it frictionless for librarians and educators to discover which books are available, how much they cost, and how quickly they can arrive. Schools and libraries use it to search a deep catalog of titles, place standing orders for regular supplies, build and execute promotional campaigns, and track shipments and inventory. Behind the scenes, Centerpoint manages complex fulfillment logistics — from pricing variations based on account discounts and promotions, to routing orders to the right fulfillment lane, to orchestrating printing and shipping of custom editions.
The application serves multiple audiences. End-user librarians and boosters browse and order through a web interface. School district administrators and customer service representatives use a staff portal to manage accounts, create targeted promotions, and troubleshoot fulfillment. Account representatives use specialized dashboards to support their customers. And internal operations teams have tools to manage inventory, classify orders for fulfillment, and run reports.
Why a Modern Rewrite
Centerpoint evolved from a legacy Visual FoxPro application (webnet.prg) that has served the business for decades. That application remains the system of record for core business data (stored in DBF database files, the VFP native format). However, maintaining and extending a legacy CGI-based FoxPro application became a limiting factor: adding features was slow, scaling the system to handle growing data volumes was expensive, and deploying improvements required navigating code written across many years by many developers with varying conventions.
In response, we built a modern replacement using Laravel (a PHP web framework) for the backend and Vue (a JavaScript framework) for the user interface. This new application doesn't displace the legacy system; instead, it coexists alongside it, accessing the same data through a careful bridge layer. The modern application handles the customer-facing experience, reporting, and operational workflows, while the legacy VFP application remains the authoritative source for pricing, inventory, and transactional data, updated through nightly synchronization.
This hybrid approach lets us ship new features and improvements at modern development velocity while preserving the stability and reliability of the core business system. The goal is a seamless, unified product experience — with the modern interface and capabilities of a contemporary web application backed by a battle-tested data foundation.
How to Read This Document
- For non-technical readers: The body of this document explains what Centerpoint does, who uses it, and how key workflows operate — in plain language. You can skip sections marked with the "For builders" callout.
- For developers: Each domain chapter includes technical anchors (service names, controller routes) and links to deeper architectural documentation, so you can drop directly into the code.
- For status and change tracking: This document captures durable intent and completed phases. For live work status, see todo.md; for a row-by-row parity audit against the legacy system, see PARITY.md.
For the historical context that frames this product, see Part I § Background & Migration Thesis. For a user-by-user breakdown of who does what, see § Users & Roles. For the detailed domain-by-domain feature and workflow reference, see Part II (Chapters 6–17).
2. Background & Migration Thesis
The Legacy Visual FoxPro Application
For nearly two decades, the Centerpoint business operated on a single, cohesive Visual FoxPro application: webnet.prg, a 161,252-line codebase with approximately 375 procedures and functions. A companion build, admnet.prg, differed by only 4 bytes (a configuration flag and data-path constant)—effectively the same application with two entry points: webnet for customer-facing web operations, admnet for administrative staff. Together, they handled the complete order lifecycle: shopping carts, order processing, standing-order fulfillment, inventory management, customer accounts, pricing, and fulfillment coordination.
The legacy app was built for serious, sustained business use. It ran as a CGI application on Windows, managing millions of records across twelve interconnected order and inventory tables (webhead/webdetail for shopping carts, heading/detail for active orders, backhead/backdetail for backorders, allhead/alldetail for standing orders, plus archive families). Its order-processing logic was intricate and deliberate: sophisticated pricing rules (7 different free-shipping conditions); inventory allocation strategies (3-phase "shippable now vs. backorder" determination); processing flags that controlled manual review workflows; and TRANSNO sequencing that guaranteed uniqueness across all order families. The application evolved to handle real business complexity—trade-house ordering with title exclusions and author subscriptions, multi-account organization context for bulk purchasing, standing-order plans with monthly refresh cycles, payment-cycle invoicing, and integration with Pitney Bowes printing and fulfillment.
Why Modernization Became Necessary
The legacy application solved a critical job and solved it well. But two decades of maintenance, feature accumulation, and technical constraints had made further evolution increasingly difficult:
- Maintenance burden: A 161K-line monolith written in a language that has declined in industry adoption became an expertise silo. Each change carried risk; testing was manual; diagnostic capability was limited.
- Scalability constraints: CGI-based request handling, lack of modern caching, and tight coupling between business logic and presentation made it hard to serve growing traffic or respond to time-sensitive workflows without spawning new scheduled jobs.
- User experience: The web interface, while functional, reflected decades of layered additions. Mobile responsiveness did not exist. Real-time feedback (cart updates, status changes) was not feasible.
- Technical debt: The system relied on direct database file (DBF) access, which required Windows infrastructure, made cloud deployment impossible, and limited integration with modern third-party services.
The Modern Replacement
Centerpoint was rebuilt as a Laravel/Vue web application, backed by a Rust HTTP API (serving DBF data via a local proxy). The new system is not a replacement in spirit; it is a continuation of the legacy product, written in a stack that permits faster development, better testing, cloud deployment, and richer user experience.
Parity as Decided Intent
Rather than a wholesale rebuild, the modernization was framed as a feature-by-feature port: each capability in the legacy app was either (1) ported forward — carried into the new code with fidelity to the original intent; (2) enhanced — ported and improved (e.g., richer email events, automated lane classification); (3) reimagined — redesigned for the new platform while preserving outcome (e.g., the legacy bonus-book ledger may become a general-purpose entitlement system); or (4) deliberately diverged — set aside as no longer aligned with business direction, with explicit approval.
This document treats parity not as a drift report but as decided intent in code: "The legacy app does X this way; we carried it forward as Y, with enhancements Z, and diverged at W because …." It is the record of a thoughtful migration, not a checklist of remaining work.
For the detailed mapping of every legacy feature and its modern counterpart, see Part II: Feature Domains and Appendix B: Legacy Procedure Glossary.
3. Users & Roles / Personas
Centerpoint serves multiple audiences, each with distinct workflows and levels of system access. Understanding these personas is essential to knowing what problems the product solves and how to navigate its features.
End-User Librarians & Boosters
Who they are: Librarians, collection managers, and purchasing coordinators at schools and public libraries; occasionally parent volunteers or parent organizations ("boosters") helping to acquire materials.
What they do: Search the Centerpoint catalog to discover available books, check pricing and availability, place individual orders, and set up standing orders for regular replenishment. They track order status, receive shipment notifications, and manage accounts across multiple school locations (in larger districts).
Problems they solve: Finding the right mix of titles and pricing for their collection; coordinating bulk purchases within budget constraints; reducing the time spent on purchase orders and invoice processing.
Key features: Title search, cart checkout, standing order subscriptions, order history, shipment tracking, account and address management.
Customer Service Representatives
Who they are: Centerpoint staff members who support schools and libraries — answering questions, troubleshooting orders, adjusting accounts, and managing customer relationships.
What they do: Open customer accounts, update account information and billing details, investigate order problems, process refunds or adjustments, manage customer notes and communication history, and escalate complex issues.
Problems they solve: Reducing the time to resolve customer problems; maintaining consistent customer data; ensuring that account changes are applied correctly; tracking long-running conversations and follow-ups.
Key features: Account and contact management, order lookup and modification, customer notes and annotation, email and notification workflows, bulk account updates.
Account Representatives
Who they are: Sales and relationship managers, organized by geographic sales regions (COMMCODE), responsible for a territory of school and library customers.
What they do: Manage customer callbacks and touchpoints, track scheduled follow-up dates, monitor account engagement metrics, and help customers with strategic questions about ordering and inventory. They use specialized dashboards to see at-a-glance which accounts need attention.
Problems they solve: Staying on top of a large territory of accounts without losing track of customer relationships; identifying which accounts are under-engaged or at risk; allocating time efficiently across multiple regions.
Key features: Account Representative dashboard (callback tracking, regional workload metrics, account insights), Brutus inline chat for account research, callback scheduling, customer account opening.
Operations & Fulfillment Teams
Who they are: Internal staff responsible for inventory management, order fulfillment, printing and shipping operations, and reporting on business metrics.
What they do: Monitor inventory levels, classify orders for correct fulfillment lanes (determining whether an order should be pulled from stock, back-ordered, printed on demand, or routed to a trade distributor), execute shipment workflows, and generate reports on sales, fulfillment performance, and account activity.
Problems they solve: Routing each order efficiently to minimize cost and delivery time; maintaining accurate inventory counts; ensuring that fulfillment lanes and printing/shipping partners receive the right data at the right time; monitoring business health through real-time metrics.
Key features: Trade and inventory management dashboards, order classification and fulfillment routing, batch printing and shipping manifests, sales and fulfillment reports, transaction and event logging.
System Administrators
Who they are: Staff with elevated permissions, responsible for system configuration, user management, and emergency troubleshooting.
What they do: Create and manage staff user accounts, adjust user permissions and access levels, manage system settings and configurations, audit and troubleshoot data synchronization issues between the app and the underlying database systems.
Problems they solve: Controlling who can access what areas of the system; ensuring that all staff have the right permissions for their job; diagnosing and repairing data inconsistencies.
Key features: User management and permission assignment, system configuration tools, audit logging and diagnostics, account and user bulk operations.
Authentication & Security Model
🔧 For builders: Centerpoint uses a session-based authentication model. The
Usermodel implements Laravel'sAuthenticatablecontract directly (not via the trait to avoid naming conflicts). Sessions are persistent across database rebuilds: the password table can be rebuilt without forcing users to log in again. Password data is stored in a separatePasswordmodel — purely as a data mirror, without authentication logic. AHasPermissionsTraiton theUsermodel provides permission-checking methods (canNow(),viewProps(), etc.) used across the authorization system. Permissions are checked via policies (inapp/Policies/) that guard feature access and data mutation. Configuration is inconfig/auth.phpwith a custom password provider driver.
For detailed capabilities by role and feature-level access restrictions, see the individual domain chapters in Part II (Chapters 6–17). For the architecture of permissions and auth, see Part IV § Platform — Auth, Permissions, DBF↔MySQL Bridge, Jobs.
4. Product Principles & Guiding Decisions
Centerpoint operates on a set of durable design tenets that reflect both the legacy system's constraints and deliberate modern choices. These principles govern how data flows, how decisions are made, and how the product evolves.
1. DBF Remains the System of Record
The DBF files are the authoritative source; the MySQL mirror is a cached representation rebuilt daily at 02:15. Code should assume DBF is the ultimate reference, and any schema change must propagate through both the DBF structure and the rebuild migration.
🔧 For builders: Migrations on
web*,back*,bro*,all*tables and their detail tables must update the DBF field list instaff/dbf-service/config/dbf.phpand the rebuild logic inRebuildApplicationTables.php. INDEX is the row-level bridge (RECNO mirror); cascade keys vary by table family (webhead=REMOTEADDR,webdetail=RECNO via INDEX+1,back/bro/all=TRANSNO). See dbf_three_paths.md and dbf_batch_cascade_pattern.md.
2. Submitted Orders Have Locked Pricing
Once an order is submitted and assigned SUBMITTED status, its pricing becomes immutable. No repricing occurs when the order enters fulfillment (at pull time or later), eliminating fraud risk and honoring customer expectations of quoted prices.
3. Fail Loud, Classify Deterministically in Code
Guards throw for truly-unknown states; deterministic classification happens in the code, not deferred to callers. This forces developers to find and fix root causes rather than papering over unknowns with defaults.
4. Backward Compatibility: Array Access & JSON Serialization
External tools, legacy code, and dashboards depend on models being both ArrayAccess and JsonSerializable. Never remove these interfaces from existing models—breaking this contract silently breaks calling code downstream.
5. Legacy Coexistence (Gradual Migration, Not Overnight Cutover)
Both the VFP application and the modern Laravel/Vue app may run in production simultaneously. Design for dual-lane code patterns where needed, and decide per feature which branch is production—expect eventual migration, not a flag day.
🔧 For builders: When writing code that touches a legacy-paralleled system (e.g., standing orders, inventory), use the
parity-auditskill to map legacy behavior, then decide whether to port, diverge, or retire. See PARITY.md for the live parity matrix.
6. Hardcoded Whitelists Silently Drop Unknown Keys
The ziggy route whitelist, BuildCampaignDrafts::normalizeOptions(), and the three-way DBF cascade alignment all silently ignore unknown keys. Adding a new key without updating the whitelist is a silent no-op—always verify whitelists when adding routes, options, or cascade targets.
7. Side Channels Are Not Mirrors (Legacy Branch Is Production)
The alpha_* tables exist to move data off to the side, not to become a full functional twin of the production tables. The legacy (non-alpha) branch is the production branch; keep the alpha branch minimal and don't block core work on achieving alpha parity.
These principles inform all decisions in Part II and Part III.
5. System Landscape
Centerpoint is built on a three-layer architecture: a modern web interface, a Rust API bridge, and the legacy DBF files that remain the system of record. This hybrid approach lets the modern app deliver contemporary user experience and developer velocity while preserving the data integrity and business continuity of the legacy system.
The staff-facing web application (what account reps, customer service teams, and admins use daily) is a Laravel/Vue.js stack. Users interact with six independent Vue.js single-page apps (Admin, Trade, Account Rep, Customer Service, Acquisitions, Warehouse) that share a common API client and core components. These apps are hosted within Laravel and communicate with the backend via REST endpoints.
The Rust API server (running on localhost:3636) acts as the exclusive gateway to all database operations. It reads and writes DBF files directly (raw byte-level file access — no ORM between the app and the legacy format), and simultaneously dual-writes to a MySQL mirror database. This is the linchpin: every DBF operation is atomic and synchronized, so the modern app can read from MySQL for fast searches and reporting while the legacy system and DBF files remain the authoritative source.
The data synchronization rhythm is nightly. At 02:15 each morning, a job rebuilds the MySQL tables (web*, back*, bro*, all* and their detail partners) from the DBF files. This means MySQL is always a faithful snapshot of the legacy data — never ahead, always in sync the moment the rebuild completes. Between rebuilds, the MySQL mirror is stable and queryable; the DBF is the place where live writes land.
For performance-critical operations like pricing calculation and reporting, Centerpoint uses a temporary table pattern. The OrganizationContextService builds a complete snapshot of an organization's data — all accounts, standing orders, inventory, and pricing — by loading data into 12 temporary tables in a single operation. This eliminates thousands of individual queries and delivers results in under a second when cached (vs. 30 seconds on cold start). The service caches this snapshot for 3 hours, making dashboard loads and pricing queries near-instant.
The legacy Visual FoxPro application (webnet.prg) coexists alongside the modern app. Users can log into either interface; both read from and write to the same DBF files through their respective bridges. This means the modern app doesn't have to replace the legacy system overnight — teams can migrate workflows at their own pace.
🔧 For builders: The three DBF code paths (legacy fopen, 32-bit
dbf-serviceon :8080, Rust API on :3636) implement different mechanics but share the same table schemas. Before hypothesizing about a data issue, name which path is in play. See rust_api_proxy_architecture.md for request-flow examples; organization_context.md for temp-table performance tuning; domain_seams.md § "DBF ↔ MySQL" for the three-path reference and cascade-key details.
For detailed per-domain feature descriptions, see Part II. For system maintenance and internals, see Chapter 17 (Platform).
Part II — Feature Domains
Per-domain template (applied to every chapter 6–17):
- Purpose — what the domain does for users (plain language)
- Legacy behavior — how the VFP app did it
- Ported forward + enhancements — "Legacy does X; carried forward as Y; enhanced with Z; diverged at W and why"
- Current state — shipped / in progress / planned (durable roadmap intent, not live ticket status)
- Decisions & directional rulings for this domain
> 🔧 For builders:key services/routes/anchors + links to the dev deep-dive docs
6. Orders & Carts
6.1 Purpose
Carts and orders are the heartbeat of Centerpoint. Customers build shopping carts by adding books to their account, and staff can create or manage carts on behalf of customers. A cart is a temporary holding area — a list of books a customer intends to purchase, with quantities and preferences like shipping address or special pricing. When a customer (or staff member on their behalf) submits a cart, it becomes an order: a committed purchase intent that enters the fulfillment pipeline.
The orders and carts system must answer three critical questions for every transaction: (1) What inventory is available right now? (2) What should the customer pay, given their account discounts and plan type? (3) How should this order route through fulfillment — to the warehouse for packing, to a print-on-demand vendor, to a standing order schedule, or to a backorder queue? Once submitted, an order's journey is tracked and can be cancelled, edited, or reinstated by staff as circumstances change.
6.2 Legacy Behavior
The legacy Visual FoxPro application (webnet.prg) manages shopping carts and orders using a multi-table lifecycle. Customers begin by creating a cart (CREATE_NEW_CART, line 36157), which is stored in the WEBHEAD table and indexed by REMOTEADDR (a session identifier). As items are added, they appear in WEBDETAIL records, linked to the cart session. The cart persists until the customer either abandons it or submits it.
When a customer submits a cart (SENDTOCENTER action in NEW_HEADER_UPDATE, line 7041), the system performs several critical operations: it validates the cart is not empty, calculates free shipping eligibility (FREESHIP determination ladder, lines 7236–7276), and invokes TD_SPECIFICS (line 10458) to check inventory availability for each item. For items in stock, inventory is deducted immediately (UPDATEINVENTORY, line 10472). For unavailable items, backorder records are created. The submitted cart moves from WEBHEAD to HEADING (also called brohead), where it receives a TRANSNO (transaction number) generated by GET_IMPORTANT_VALUES (line 34242) — a unique ID calculated as the maximum TRANSNO across three tables plus one, ensuring uniqueness across the lifetime of the order.
After submission, orders progress through fulfillment stages managed by processing flags (pship, pepack, pipack, pinvoice) that control whether each step is automated or requires manual staff review. Orders may also be deleted (ORDER_DELETE, line 32708) if they haven't yet shipped, or adjusted through the order edit interface (GENERAL_VIEW_ORDER_ADJUST, line 59154). Staff and customers can view the full order history (FOH, line 56387), search for orders by key or invoice number (ACCOUNT_KEYED_ACCESS, line 97212), and manage backorders waiting for inventory (BACKMANAGEMENT, lines 154206–155753).
6.3 Ported Forward + Enhancements
Cart creation & management: The legacy CREATE_NEW_CART (line 36157) and FN_CREATE_NEW_HEADER (line 1199) logic is now handled by CurrentCartController (app/Http/Controllers/CurrentCartController.php) and AddToCartService (app/Services/AddToCartService.php), with cart routes at cart.* (routes/api.php). The session-based REMOTEADDR lookup has been translated to Laravel's session ID mechanism. Items are added or updated via the same service, which now recalculates pricing and validates inventory in a single batch operation for performance.
Checkout and order submission: The legacy order submission path (SENDTOCENTER → NEW_HEADER_UPDATE → order creation) is now split between PullOrderAction (app/Actions/PullOrderAction.php) and CreateOrderFromCartAction (app/Actions/CreateOrderFromCartAction.php), both of which respect the original free-shipping ladder (certified parity at each of the 7 conditions, commit c320cd358). Pricing is locked at submission (decision D6); no repricing happens at pull time, eliminating a legacy hack that compensated for a then-missing cart-pricing feature. The cart's WEBHEAD and WEBDETAIL records are moved to an alpha_* history table (preserving the submission, not deleting it), maintaining an audit trail.
Inventory and backorder fulfillment: The legacy TD_SPECIFICS (line 10458) inventory checks — GETSALEPRICE (line 10599), GETCURRENTSTATUS (line 10823), GETSHIPPABLE (line 10867), and UPDATEINVENTORY (line 10472) — are preserved in the app's CheckItemAvailabilityAction and CreateOrderFromCartAction, which apply the same logic: available items reduce inventory and create DETAIL records, unavailable items create backorder rows. Backorder conversion (legacy MANAGE_SELECT_BACKORDERS, line 53900) is implemented in ProcessBackOrderAction and scheduled via the orders:release-backorders command, maintaining the legacy FIFO-by-TRANSNO allocation and partial fulfillment rules.
Order editing and status: The legacy GENERAL_VIEW (line 61606) and master edit surface (GENERAL_VIEW_LINEITEM_EDIT, line 63553) are now exposed through OrdersController show and edit routes (routes/web.php) and the /db/orders/{id}/edit CMS endpoint. Staff can adjust line items, change quantities, override fulfillment flags, and add notes — all via the same unified master-edit interface. Order deletion (CancelOrderAction, app/Actions/CancelOrderAction.php) and order adjustment (order actions on the show page) preserve the original approval gates.
Order history and search: The legacy FOH (Full Order History, line 56387) is now a browser-based orders search (db.orders.*) that works across legacy tables (webhead, brohead, backhead, allhead) and new alpha tables. Cross-table search by TRANSNO, REMOTEADDR, or customer KEY is implemented in CrossTableOrderSearchService (app/Services/CrossTableOrderSearchService.php), accessible via the omni-search and the CMS Orders dashboard. Weekly open-cart preflight and activity monitoring are now part of the carts tab on customer accounts and the staff dashboard.
6.4 Current State
Shipped:
- ✅ Cart create, add, update, delete (AddToCartService, CurrentCartController) — certified against legacy CREATE_NEW_CART and FN_CREATE_NEW_HEADER.
- ✅ Checkout and order submission (PullOrderAction, CreateOrderFromCartAction) — parity certified 20260702, including free-shipping ladder (all 7 conditions match legacy case-for-case), pricing lock at submit, and TRANSNO generation.
- ✅ Backorder processing and conversion (ProcessBackOrderAction, orders:release-backorders) — parity certified 20260702, FIFO allocation, partial fulfillment, and index-carryover collision fixes.
- ✅ Order history browser and search — cross-table search by key, TRANSNO, REMOTEADDR; lazy-loaded summaries; status-scoped views.
- ✅ Order detail, edit, and master edit — line-item editing, quantity override, fulfillment flag adjustment, cancellation.
- ✅ Order archival sweep (ArchiveProcessedOrderAction, orders:archive-sweep) — legacy ARCHIVE_ORDERS (line 146614) parity certified 20260702; gates 0,3,3,3/PSHIP=4; same-TRANSNO allheads; TRADE-SELECT backhead lane.
Partial / Deferred:
- ⏳ Bonus book program (BONUS_BOOK_USE, line 31798) — entitlement ledger (earn/redeem/restore) has no app counterpart yet; legacy row below; decide port vs. retire.
- ⏳ Back-order management console workflow (BACKMANAGEMENT, line 154206) — core mark/kill/status-refresh per-account workflow not yet audited as a cohesive workflow (parity-audit pending).
- ⏳ Reason + notification on order cancellation — UI for reason input and customer notification email not yet shipped; core CancelOrderAction exists but UI incomplete.
Planned:
- 🚧 Order approval workflow — submit cart → approve → assign to lane → ship (Irene's workflow: create, edit, approve, adjust SHIP QTY). Approval generates TRANSNO (invoice number).
- 🚧 Multi-tier backorder fulfillment — advance fulfillment via standing order conversions, trade auto-picks, and baker-split line routing (partial parity, awaiting field verification).
6.5 Decisions & Directional Rulings
D1: No pull-time repricing. Once a cart is submitted and SUBMITTED status is assigned, prices are locked. No repricing happens when the order enters fulfillment (PullOrderAction). Rationale: The legacy system had post-submit price adjustments as a workaround for gaps in cart-time pricing logic. That gap is now closed in the app. Repricing at pull time creates fraud risk and violates customer expectations of quoted prices.
D2: Submitted carts stay in the webhead lane until pull. The WEBHEAD record is not immediately deleted on submit; instead, it is marked as SUBMITTED (MARKCART flag = 5555), and a separate HEADING record is created for fulfillment. The original webhead is copied to alpha_webheads as a history record, preserving the exact submission state. Rationale: Single source of truth for order creation; clean separation between customer-facing shopping state and fulfillment state.
D3: Backorder conversion creates a new TRANSNO. When a backorder item becomes available and is converted to an active order (ProcessBackOrderAction), a new TRANSNO is generated. The order moves from BACKHEAD to HEADING but is not a "reuse" of the original order ID — it is a new order in the fulfillment queue, preserving the audit trail. Rationale: Legacy MANAGE_SELECT_BACKORDERS (line 53900) creates new HEADING records with new TRANSNOs; the app mirrors this.
D4: Archival is a sweep, not inline. Completed orders are not moved to archive tables immediately; instead, an overnight command (orders:archive-sweep) moves them in batches, respecting the legacy ARCHIVE_ORDERS gate (line 146614). Archive levels (allhead → oldallhead → ancienthead) are distinct migrations. Rationale: Batch operations are faster and less prone to race conditions; overnight processing avoids customer-facing latency.
D5: Processing flags (pship, pepack, pipack, pinvoice) survive into the Laravel model. These flags are preserved from legacy orders and are consulted by fulfillment staff; default is 0 (automated), but admin-assisted orders and special handling (3) are still recognized. Rationale: Legacy fulfillment workflows depend on these flags; removing them would require retraining and workflow redesign.
D6: Free shipping is calculated at cart time and recalculated at pull time. The seven free-shipping conditions (all $2.50, dist-account negatives, trade-prepaid, choice-40%, special books, ≥5 units, standing plans) are evaluated when the customer adds items to cart and again when they submit. Staff can override this flag during master edit if exceptional circumstances arise. Rationale: Customers should see accurate shipping cost in cart summary before committing; edge cases are rare enough that staff override is acceptable.
6.6 🔧 For Builders
Core Services & Actions:
- AddToCartService (app/Services/AddToCartService.php) — Adds items to a cart, calculates pricing, creates/updates cart items. Normalizes incoming item data (case-insensitive keys), loads inventory, applies plan discounts, and persists changes.
- GroupUpdatesByOrdersService (app/Services/GroupUpdatesByOrdersService.php) — Groups incoming items by their orders; used by batch add endpoints to standardize messy input formats.
- PullOrderAction (app/Actions/PullOrderAction.php) — The main order submission action. Validates the cart, reserves inventory, generates TRANSNO, moves the order through inventory checks, and hands off to CreateOrderFromCartAction.
- CreateOrderFromCartAction (app/Actions/CreateOrderFromCartAction.php) — Creates HEADING/DETAIL records from cart submission; handles split between shippable and backorder quantities.
- ProcessBackOrderAction (app/Actions/ProcessBackOrderAction.php) — Converts a backorder to an active order when inventory becomes available. Generates new TRANSNO, creates HEADING records, updates inventory.
- CancelOrderAction (app/Actions/CancelOrderAction.php) — Cancels an order with reason tracking and customer notification.
- ArchiveProcessedOrderAction (app/Actions/ArchiveProcessedOrderAction.php) — Archives a completed order (moves to oldallhead, ancienthead, etc.).
Controllers & Routes:
- CurrentCartController (app/Http/Controllers/CurrentCartController.php) — Manages customer shopping cart (add, update, delete, view).
- OrdersController (app/Http/Controllers/OrdersController.php) — Show, edit, list customer orders; master-edit operations.
- CMSOrdersController (app-cms/Http/Controllers/CMSOrdersController.php) — Staff order browser, bulk actions, search.
-
Routes:
-
POST /cart/items— Add items to cart (AddToCartService) -
PATCH /cart/items/{id}— Update cart item quantity -
DELETE /cart/items/{id}— Remove item from cart -
POST /cart/submitor similar — Submit cart (PullOrderAction) -
POST /orders/pull— Legacy pull endpoint (PullOrderAction) -
GET /orders/{id}— Show order (OrdersController::show) -
PATCH /orders/{id}— Master edit (OrdersController::masterEdit) -
GET /db/orders— CMS orders browser (status-scoped tables)
-
Key Models & Anchors:
- Webhead, Webdetail (app/Models/Webhead.php, Webdetail.php) — Shopping cart headers and items (legacy DBF mirror).
- Heading, Detail (app/Models/Heading.php, Detail.php) — Active order headers and items (legacy brohead/brodetail).
- Backhead, Backdetail (app/Models/Backhead.php, Backdetail.php) — Backorder headers and items.
- OrderHeadInterface (app/Models/Interfaces/OrderHeadInterface.php) — Interface implemented by all order head types; ensures consistent API.
-
OrderTrait (app/Models/Traits/OrderTrait.php) — Shared order methods including
generateTransno(),getFreeShippingAttribute(). - ShipLaneClassifier (app/Services/ShipLaneClassifier.php) — Determines fulfillment lane (PSHIP 0 vs. 1; print-on-demand; standing order; etc.) based on order attributes.
-
LegacyTransnoAllocator (app/Domain/Orders/Services/LegacyTransnoAllocator.php) — Static
::next()generates next TRANSNO respecting legacy MAX(brohead, backhead, allhead) + 1 algorithm.
Commands:
-
php artisan orders:pull— Test/bulk pull (PullOrderAction invoked). -
php artisan orders:release-backorders— Batch release backorders (engine:BackorderReleaseService::releaseBatch). Manual/staff-triggered — not scheduled. -
php artisan orders:archive-sweep— Archive completed orders (ArchiveProcessedOrderAction, scheduled nightly).
Deep Dive References:
- PARITY.md "Orders & Carts" (13 rows) — Full decision log and parity status for every legacy feature.
- LEGACY_VFP_ORDER_ARCHITECTURE_REFERENCE.md — DBF table mapping, TRANSNO uniqueness algorithm, processing flag rules.
- LEGACY_VFP_ORDER_LIFECYCLE_DOCUMENTATION.md — Complete state transitions, inventory logic (GETSALEPRICE, GETCURRENTSTATUS, GETSHIPPABLE, UPDATEINVENTORY), free-shipping ladder.
- ORDER_STATE_MACHINE_REFERENCE.md — Order status machine, error recovery, CANCEL_ORDER vs. DELETE_ORDER, migration system.
For granular implementation details, see app/Services/AddToCartService and the two LEGACY_VFP_ORDER docs linked above.
7. Standing Orders & Plans
7.1 Purpose
A standing order (internally called a plan) is a recurring subscription a library holds with Center Point — the customer signs up for a series of large-print titles and receives new books automatically as they publish. Plans are the engine of Center Point's subscription business: 42,873 ISBN × Account pricing records hang off the 170-column account classification system; monthly fulfillment (the standing-orders run) turns active plans into real orders; prepaid budgets ceiling trade-plan pricing and drive cash flow.
Standing orders are complex because they operate on multiple timescales: creation & lifecycle management (hold, cancel, restate, renew) happens ad-hoc; invoicing happens at plan creation and renewal (prepaid paths) or per-shipment (pay-as-shipped); monthly fulfillment (title matching, inventory depletion, order generation) happens on a staff-triggered batch schedule, not automation. The legacy VFP system (webnet.prg, still live) is the authoritative fulfillment engine — this app's standing-orders implementation reaches parity-or-better on the plan lifecycle, invoicing, and order-generation pieces, but the legacy system remains the validator for the grouping hash (SORTORDER) that feeds downstream Pitney Bowes shipping.
7.2 Legacy Behavior
Lifecycle & Invoicing (webnet.prg:88165–96021, STANDING_WRITE–STANDING_RENEW):
- Plans are created with a series/class, start date, optional end date, and quantity
- Prepaid plans (
PREP_INVOICE_CREATE,:93564) are invoiced at creation; the invoice is a stub cart line at $0 (SALEPRICE already paid) - Plans can be held (negated QUANTITY + prefix
X -, legacy manualHANDLING='T'flag), renewed (SDATE/EDATE advanced), or cancelled (CANCELDATE set) - Cancellation doesn't delete; it marks the row as excluded from renewal (
REVIEW_TRADE_PLANS,:152822)
Monthly Fulfillment (PRINT_STANDING, :129164–:129933):
- Staff manually trigger a monthly run (not cron) with a target publication month
- Per account/address, one order head is built and filled with titles matching the account's active plans
- Each plan's series/class filters titles to those SERIES-matching or BESTSELLER-flagged, published that month, in stock (
ONHAND > 0per legacy logic) - Orders are grouped by a
SORTORDERhash — the key contract with Pitney Bowes (external shipping app) — built by concatenatingNN(2-digit zero-padded plan quantity) + a 2-letter SERIES code (thesosmartmap:AA=Christian L1,EE=Platinum Fiction, etc.) for each enrolled series, in a fixed series order (e.g.01AA01BB01CC= qty 1 each of Christian L1/L2/L3) - Singletons (one-head groups) collapse to
'DAILY ORDERS'with a "print downstairs" flag - Orders are stamped with
PSHIP=1(1,1,1,1flags) awaiting Pitney Bowes; PB reads groups bySORTORDER, makes labels + weights, then flipsPSHIP→0(0,1,1,1), signaling the app to continue with in-house printing - Trade plans have a separate immediate fulfillment path (
CREATEIT,:40911) that writes aBACKHEAD(back-order head) withOSOURCE='TRADE STANDING'
Pricing & Holds (ACCOUNT_STANDING_CALCS, :86959; STANDING_INVOICE, :93564):
- Prepaid plans price at SALEPRICE = LISTPRICE − (LISTPRICE × DISC), billed once; subsequent shipments ship at $0
- Trade titles on prepaid plans are ceilinged by a per-account prepaid budget (legacy:
tradepaidamt) — titles within budget ship at $0; overages ship at 25% discount - Manual hold ("STANDING_CLEAN",
:95495) flags address inconsistencies (COMPANY/STREET/BILL_* mismatches across a plan account's eligible plans); staff can dismiss them per plan
7.3 Ported Forward + Enhancements
Plan Lifecycle (app/Http/Controllers/StandingOrderEditController, app/Models/StandingOrder):
- Full CRUD with the same series, date, quantity, prepaid fields (DBF-mirrored); enhanced with explicit hold/cancel/restate actions (legacy required manual flag-setting)
- Prepaid plans auto-default EDATE to (SDATE or today) + 12 months − 1 day, eliminating the "unpaid infinite plan" gap
-
Holds are audited — each hold snapshots the row state in a
held_standing_ordersmirror and links via a code in theRESTATEfield, so held plans can be released in-place (same id, same DBF INDEX for legacy reports) - Renewals are throttled — EDATE must be present and within 90 days to prevent accidental double-renewal
Invoicing (InvoiceThisPlanController, StandingOrderInvoiceGenerationService::generatePrepaidInvoice):
- Prepaid invoicing is cycle-aware — the service builds a webhead (submitted cart) + one webdetail line per plan, prices it as a real webhead (line total = SALEPRICE × QUANTITY), and rolls it into
NEWPRODUCTso the History tab shows actual Billed/Balance - The invoice carries a real REMOTEADDR (matching the cart-generation format) so it appears in order searches
- Duplicate-gating — invoicing a plan that already has an invoice requires confirmation
- Richer than legacy (legacy billed prepaid plans as two separate lines; app now bills as one, netting the invoice so duplicate discovery is clearer)
Fulfillment Pipeline (App/Actions/FulfillStandingOrders, App/Models/Queue/StandingOrder, /standing-orders-cms):
-
Four-stage operator-driven wizard (not automation): (1) seed inventory from a production month (
queue_inventories), (2) build queued orders per account/address + flag concerns, (3) review & set routing destinations (PB-bound vs downstairs vs hold), (4) selectively ship to production -
Staged inventory (
queue_inventories) is a working copy seeded frominventorieswithONHAND=FINALINV(legacy parity for availability basis); no productioninventoriesmutation during build -
SORTORDERv1 (regular-series) reproduces the legacyNNXXhash exactly — source of truth isPlan::plansInfo()'ssortorder_codeper class; Pest-verified against two ground-truth samples -
SORTORDERv2 (bestseller + custom-mix) extends with a.delimiter + base32 SHA-256 content hash (80-bit → 16 chars) of qty-aware ISBNs, allowing identical-content grouping for mix/bestseller titles -
Parity handoff flags — PB-bound heads are stamped
PSHIP=1(1,1,1,1) at materialization; singletons collapse to'DAILY ORDERS'+0,1,1,1(skip PB); all others route per operator decision -
Contiguous TRANSNO & back-order safety — heads are shipped in
SORTORDERorder with atomic TRANSNO minting; back-orders are assigned from the sequence (not main+1) to prevent collision -
Selective shipping — operator can ship by destination, plan type, account, or fulfillment confidence; holds never ship; multi-pass shipping is idempotent (
materialized_atmarker)
Custom-Mix Profiles (CustomMixProfileService, Configuration rows per plan):
- Per-subscription, month-scheduled cascade selection — each custom-mix plan can have monthly priority lists (which classes to mix in what order, up to a cap per class)
- Universal never-re-ship — the selector excludes any ISBN already shipped to the account (life-of-account history)
- Global fallback — if no monthly profile, the run uses a per-account account-wide fallback configuration
- Improvement over legacy (legacy required manual librarian pick per order; app auto-fulfills via profiles)
Trade Plans (TradeOrderService, TradeCMS\Models\TradeTitleExclusion, TradeAuthorAutoPickService):
- Trade I/II/III + Trade Select plans are excluded from the monthly fulfillment run (
auto_build=false) and fulfilled via a separate trade pipeline (trade:sync-author-pickscron,:reserved; manualTradeOrderService::ensureTradeCarton account first touch) -
Title exclusions (
TradeTitleExclusion, mirroring legacyORDREASON=TSPECIFIC/ASPECIFIC) per ISBN or author are enforced at cart add time (silently skip excluded titles, matching legacy's gate) -
Author auto-picks (
TradeAuthorSubscription+ daily 08:30 cron) match "Not Yet Published" trade inventory by subscribed author, applying the same title-selection rules + format prefs -
Trade prepaid pricing uses the same
TradeBudgetResolveras the app's open-cart paths (budget ceiling → $0, overages → 25%)
7.4 Current State
Status (July 2026): M1–M5 milestones ✅ complete. The full plan lifecycle (create through renewal) is production-ready; monthly fulfillment is live and operator-gated; parity with legacy is verified at the scorecard level (no regressions; 15+ deliberate improvements).
Live Capabilities:
-
SORTORDER reproduction — v1 (regular-series
NNXXcodes) matches legacy exactly; v2 (bestseller + mix content hash) extends the scheme for PB grouping - Prepaid invoicing — synchronized with plan creation/renewal; plan-start stubs are auto-generated for all prepaid plans at create/renew time
-
Fulfillment — four-stage wizard runs on
/standing-orders-cms("Fulfill Plans" tab); CLI commandsstanding_orders:create+standing_orders:move; operator controls destination routing + selective shipping - Custom-mix configuration — staff can edit per-plan monthly priority cascades + account-wide fallback via the CMS (Mix Profile tab)
-
Hold/cancel/restate — full hold audit trail via
held_standing_ordersmirror; release re-instantiates the plan in place with the same DBF INDEX - Renewal throttle — blocks premature/accidental double-renewal
-
Billing-consistency review —
/standing-orders-cms/billing-consistencytab flags address inconsistencies across eligible plans; dismiss per plan (legacy parity:STANDING_CLEAN:95495→StandingOrderBillingConsistencyService)
Known Gaps / Open Decisions:
-
FINALINV in-app freshness — the app trusts the DBF-mirrored
FINALINVfor availability (legacy parity); the deferred in-app receive/correct + print-run sizing seam is ✅ BUILT (scope:STANDING_ORDER_FINALINV_SCOPE.md) -
Format/publisher prefs — legacy account-level
xhardonly/xsoftonly/xrandom(Penguin Random House exclusion) filters are documented but unimplemented pending Q3 review - Trade-plan fulfillment via the monthly run — deferred design decision (D-M3-4); trade plans currently use a separate pipeline
-
Downstairs reroute picker UI (legacy
STANDING_DESTINATION:139514) — subsumed into the M3 operator-routing model; CLI + admin tools exist, UX polish deferred
7.5 Decisions & Directional Rulings
D1 — Parity is non-negotiable. The legacy VFP system is still live production (webnet.prg, compiled to foxisapi.dll). We port features at parity-or-better, never silent regressions. Every claim in the parity matrix (PARITY.md section "Standing Orders & Plans") is anchored to legacy code, app code, and a status + decision commit. The scorecard (regression count, SORTORDER reproduction, Pest test results) is the production-readiness metric.
D2 — SORTORDER is a contract with Pitney Bowes. The legacy grouping hash (NNXX codes) is not a display label — it's the key Pitney Bowes uses to batch orders for weigh-in and label generation. The app must build and materialize it identically to legacy so external downstream consumers see the same data. This moves SORTORDER from "nice-to-have visualization" to a functional requirement (confirmed with the user, 2026-06-18).
D3 — FINALINV is availability, not ONHAND. Legacy defines ONHAND = FINALINV − ALLSALES (gross cumulative inventory minus all-time sales). The fulfillment pipeline judges availability against FINALINV (the print run), not real on-hand stock. This allows standing orders to be promised against titles publishing this month, not just those already in the warehouse. The app trusts the DBF-mirrored FINALINV for now (in-app receive/correct is a separate seam). Inventory decrement during queue build is FORBIDDEN — it corrupts the production facts the report needs and breaks re-run safety.
D4 — Staged inventory, not production mutation. Queue build seeds a separate queue_inventories working copy, not the production table. This preserves the ability to re-run, inspect, and audit the queue without altering what's on the shelf. Improvement on legacy (which mutates INVENT in place and resets after).
D5 — Operator owns routing; system informs. The fulfillment UI flags all concerns (oversized groups, address mismatches, unfulfilled titles, singletons) and seeds sensible defaults (regular multi-member → Pitney Bowes, HANDLING='T' → downstairs). The operator decides what ships, where, and when — never automatic rerouting. This honors legacy's manual control model but makes it explicit + auditable (vs legacy's opaque flags).
D6 — Contiguous TRANSNO assignment. TRANSNOs (and MySQL id insertion) must be contiguous in SORTORDER order so that third-party consumers reading the DBF see grouped orders with adjacent numbers. Mirroring legacy's sorted renumber. (SequenceService::nextTransno is gap-safe but scattered; this path needs the block.)
7.6 🔧 For Builders
Key Files & Routes:
-
Plan model & lifecycle —
app/Models/StandingOrder.php,app/Models/HeldStandingOrder.php(hold audit mirror) -
Invoicing —
app/Http/Controllers/InvoiceThisPlanController.php,app/Services/StandingOrderInvoiceGenerationService.php(prepaid pipeline) -
Fulfillment —
app/Actions/FulfillStandingOrders.php(2-stage action:queue_inventory+queue_orders; the formercreate_special/create_regularstages were retired in M5 → operator-driven ship viaCMSPlansController::fulfillShip),app/Models/Queue/StandingOrder.php(builder + materializer),app/Services/Pricing/TradeBudgetResolver.php(trade ceiling) -
Custom-mix —
app/Services/CustomMixProfileService.php(profile/fallback resolution),app/Models/Configuration.php(per-plan configs) -
Trade —
app/Models/TradeCMS/TradeTitleExclusion.php,app/Models/TradeCMS/TradeAuthorSubscription.php,app/Services/TradeAuthorAutoPickService.php -
Routes —
routes/web_auth.php:cms.plans.*(editor + fulfill tab),db.standing-orders.*(detail editor),db.held_standing_orders.*(hold audit)
Source Documentation (the spec):
-
LEGACY_VFP_STANDING_ORDER_PIPELINE.md — 9-stage legacy reconstruction,
SORTORDERdecode map, Pitney Bowes contract, parity/regression matrix, locked forward design (v1 + v2 + auto-gating) - STANDING_ORDERS_REFERENCE.md — staff-facing plan lifecycle overview, fulfillment mechanics, trade plans, custom-mix config, gap matrix
- STANDING_ORDER_BUILD_PLAN.md — roadmap (M1–M5), per-milestone verification ritual, decision audit trail (D1–D6)
Parity Scorecard:
Read the "Standing Orders & Plans" section in PARITY.md — the 16 rows track status + legacy/app anchors + decision commit hashes. Any new feature should update the matching row (e.g. none → full, with commit + date). Zero regression rows is the production gate.
Pest Test Patterns:
-
tests/Feature/StandingOrders/StandingOrderSortorderTest.php— ground-truth samples (01AA01BB01CC01HH01JJ,01AA01BB), code ordering, zero-pad,applyShipmentHandoffcollapse -
tests/Feature/StandingOrders/— full suite covering lifecycle, invoicing, hold/restate, hold/cancel gate checks, fulfillment queue build (non-DDL assertions only; full pipeline checked manually via the ritual) - No parallel test execution;
staff_testingDB is owned bytest:init; never mutate schema in tests
Critical Gotchas:
-
Legacy is still the validator. The app fulfillment is a working reimplementation, but
webnet.prgSTILL runs production and still writes the DBF. The legacySORTORDERstays authoritative until the app can prove monthly scorecard parity (same distinct values for the same month). -
SORTORDERis immutable after ship. Never re-compute it during archive or subsequent edits. It's a contract field. -
Trade plans excluded from monthly run. They have
auto_build=false+ a separate pipeline. Opening trade to the monthly run is a future decision gate (D-M3-4). -
FINALINV decrement during queue build is forbidden. It corrupts the report and breaks re-runs. Queue
ONHANDis informational only. -
Prepaid invoice quirk: the plan-start invoice bypasses
Webhead::recalculate()(no pricing re-jig), so the generator must set head totals itself.
8. Trade / Distributor Pipeline
8.1 Purpose
The Trade domain enables bulk subscription plans for library networks and distribution partners — customers who buy books by plan (predictable selections arriving automatically each month) rather than case-by-case orders. A library system (customer) subscribes to a Trade Level plan (Level 1, 2, or 3, delivering 2, 4, or 6 titles/month respectively across distinct class categories) and receives those titles shipped as standing orders, with prepaid pricing that applies a discount once the library has purchased enough titles to satisfy the "obligation" they signed up for. This domain also historically housed integrations with distribution partner EDI pipelines (notably Baker & Taylor's legacy 850/855/856/810/997 consoles), which are now being generalized into an abstract distribution-partner pipeline for reuse with any future partner.
8.2 Legacy behavior
The legacy VFP app (webnet.prg) implemented trade as an event-driven, manually curated system (TRADE_HOUSE_CUSTOMER_VIEW :49592, TRADE_HOUSE_SELECT_* :40911/:52522). A buyer would navigate to the trade house customer view and select titles for inclusion in a standing order, with title exclusion lists (TRADE_HOUSE_CUSTOMER_CHECKSHOW :40434) preventing specific ISBNs or authors from being auto-loaded. Baker & Taylor, the primary distribution partner at the time, had a dedicated EDI console (EDI_MENU :139788) for 850/855/856/810/997 message exchange and a monthly buyer review screen (FOH_BT_LINE_ITEMS :128701) where staff reviewed B&T-specific orders before shipment. The app also calculated prepaid obligation pricing :136326 — titles were free ($0) while the library had unused obligation budget, then 25% discounted once the budget was exhausted. Notably, legacy trade had no SORTORDER or batch fulfillment discipline — selections were applied directly, and the system relied on manual staff intervention for grouping and routing to fulfillment.
8.3 Ported forward + enhancements
The modern app retains the core plan-based subscription model (3 Trade Level plans per organization + an optional Trade Select placeholder for one-off trades) and the prepaid obligation pricing (TradeLinePricer, :45e0dbf89), but reimplements the fulfillment pathway as a staged batch process integrated into the global StandingOrders fulfillment pipeline. The existing app-trade tool (manual curation UI at /trade, app-trade/Services/TradeOrderService) remains authoritative for per-customer add/update/remove operations and plan management (app-trade/Http/Controllers/TradeInventoryController); the new pathway adds an alternative batch-fulfillment mode that queues, routes, and materializes trade plans to production without bypassing the pipeline discipline.
Key enhancements over legacy:
-
Plan-driven batch selection instead of manual per-title curation:
getTradeTitles(Stage 1,:fd1b7c128) automatically selects cumulative-by-level titles (Level 1 = CLASS_H; Level 2 = CLASS_H + CLASS_I; Level 3 = CLASS_H + CLASS_I + CLASS_J) for a given run month, honoring format preferences (xhardonly/xsoftonly/xrandom, matching legacy's non-PRINT_STANDING code path:41783). -
Batch integration into the shared queue infrastructure: reusing
queue_inventoriesandqueue_broheadswithINVNATUREscoping (D-T-1,:fe16c8227) — trade runs are collision-free from regular (CENTER POINT) fulfillment runs; the two never interfere. -
SORTORDER + routing + PB lane (D-T-5): unlike legacy (which had no SORTORDER for trade), trade heads now receive a SORTORDER via content-hash and join the same grouped-shipment routing logic as regular standing orders (
ShipLaneClassifier, M3 materialize). This is an intentional divergence-as-improvement — legacy lacked the discipline; the modern pipeline adds it at no cost to other features. -
Prepaid pricing abstraction (Stage 0): prepaid obligation + budget calculation extracted to a pure calculator (
TradeObligationCalculator/TradeLinePricer) that both the batch path and the manual app-trade tool call, ensuring identical pricing regardless of fulfillment mode. -
Explicit dedup + discrepancy reporting (D-T-6): trade queues the full plan set (to inform vendor procurement planning) and flags each title's back-order status; the move to production deduplicates on the operator's decision (default: skip already-on-order; toggle:
dedup_open_backorder, defaulting ON in the Ship form). Regular standing orders use the same pattern withzombie_prev_purchased, ensuring consistency. -
Direct-to-production preorder materialization (D-T-8): trade lines skip the brokered (
bro*) queue entirely and go straight tobackhead/backdetail(TARGET_TABLE=backheads,OSOURCE='TRADE STANDING'/'TRADE SELECT'). Pricing at queue time is tentative — the final bill is recalculated out-of-band when the app-trade fulfillment path ships (CreateOrderFromCartAction::recalculateSalePriceForTradeItem, production Order transition code).
B&T / EDI future direction: Baker & Taylor is defunct as a distribution partner (decision resolved 2026-07-03, :PARITY.md rows 33 + 108). The legacy B&T EDI consoles (850/855/856/810/997, hardcoded key 6095400000001) and buyer-review workflows are being deferred in favor of a generalized "distribution-partner pipeline" that will abstract the EDI exchange, PO/receiving, and buyer review console for reuse with any future B&T-like relationship. Research + specification is first; implementation is a separate arc (see todo.md). No app counterpart is being built for B&T specifically.
8.4 Current state
Shipped (2026-07-03):
-
Trade Level plans (CLASS_H/I/J): fully ported; cumulative-level auto-selection
getTradeTitles(:9a47e265c, parity-verified mid-month window fix). -
Trade fulfillment Stages 0–5 (Scope doc
:STANDING_ORDER_TRADE_FULFILLMENT_SCOPE.md):- Stage 0: prepaid pricing abstraction (
:45e0dbf89) - Stage 1:
getTradeTitlesselection + wiring toFulfillmentScope(:fd1b7c128) - Stage 2: hybrid scope-delete reset (INVNATURE-scoped queue ops)
- Stage 3: head composition + dedup operator policy (
:c14347ffe,TradeHeadCompositionTest, 11 contracts) - Stage 4:
/standing-orders-cms/fulfill-tradeUI surface (:fe16c8227, sharesFulfill.vueviascopeprop, no fork) - Stage 5: parity verification scorecard (
:9a47e265c+:9f5da77b1, all dimension verdicts green; 268 test assertions)
- Stage 0: prepaid pricing abstraction (
-
Trade Title Exclusion & Author Subscriptions (REMDETAIL + AUTPICKS) (20260704,
:PARITY.mdrow 49):-
TradeTitleExclusion(trade_title_exclusionstable: account_key + nullable isbn/author, mirroring legacy ORDREASON discriminator) enforced on new-title add (silent skip, legacy-faithful) (:TradeCMS\Models\TradeTitleExclusion) -
TradeAuthorSubscription(trade_author_subscriptionstable: account_key + author) +TradeAuthorAutoPickService(daily 08:30 ET schedule viatrade:sync-author-picks) matching Not-Yet-Published TRADE inventory by subscribed author (:TradeCMS\Models\TradeAuthorSubscription,:TRADE_AUTPICKS_SCOPE.md)
-
-
Trade stock reconciliation (20260704,
:PARITY.mdrow 54):count_returned/count_shrinkagecolumns + accessors onBookmodel, Advanced Search filters, mirroring legacyTradeBooksReturnsProcessed/TradeBooksShrinkageexactly (:7be21407a). -
Trade plan auto-create on first order (20260704):
StandingOrder::createTradeSelectPlanFor($account, $poNumber)— idempotent, clones billing/address from existing plan or account record (:LazyCreateTradeBackheadTest.php).
Operational certification pending:
- First live trade batch ship through the console (Stage 4 ops cert gate) — queue → materialize → ship path verified via factory-free unit tests (
TradeHeadCompositionTest,TradeQueueResetIsolationTest) but not end-to-end with DBF cascade live.
In progress / Not yet shipped:
- B&T / EDI generalization to distribution-partner pipeline: deferred (see 8.5 Decision).
-
Trade-specific PO/vendor management UI (
:PARITY.mdrow 51 "Trade PO admin + publisher emails" — unaudited): CMS resources exist (purchase_orders+suppliers) but parity with legacyTRADE_HOUSE_ZEBRA(:42875) +TRADE_HOUSE_EMAIL_SUBSYSTEM(:41943) not yet confirmed. - "By Email" bulk-update tab for trade: deferred to own session (not bolted onto existing BySO UI).
8.5 Decisions & directional rulings
D-T-1 (Trade as separate run, collision-free reset). Trade and regular (CENTER POINT) fulfillment use shared queue tables (queue_inventories, queue_broheads) but with INVNATURE scope-tagging. Each run type gets its own ActionRun with distinct action_id ('fulfill_standing_orders' for regular, 'fulfill_trade_standing_orders' for trade). Reset operations are scope-aware — a trade reset deletes only INVNATURE='TRADE' queue rows + its own ActionRun; a regular reset only CENTE. Every queue read/write/freshen/ship is scope-filtered. This ensures neither run disturbs the other's staged rows even if both are in flight simultaneously (user decision, 2026-06-29).
D-T-2 (Trade selection = cumulative-by-level auto-load). Each Trade Level plan is cumulative: Level 1 (CLASS_H) sources 2 titles/month; Level 2 (CLASS_H + CLASS_I) sources 4 titles/month; Level 3 (CLASS_H + CLASS_I + CLASS_J) sources 6 titles/month. No manual per-title curation in the batch path; getTradeTitles reads the plan's plansInfo.mix directly (:STANDING_ORDER_BUILD_PLAN.md config section) and selects exactly the published titles that month across the assigned classes (PUBDATE=month, INVNATURE='TRADE'). Format preferences (xhardonly/xsoftonly/xrandom) are applied, matching legacy's non-PRINT_STANDING code path (:41783). This satisfies the 24/48/72 annual-title targets.
D-T-4 (Trade Select out of scope for batch). "Trade batch fulfillment" refers to CLASS_H/I/J only. Trade Select (CLASS_Z, a placeholder for accounts without a trade-level plan who manually order trade titles) is explicitly out of scope for the batch path. It stays entirely in the manual app-trade tool.
D-T-5 (SORTORDER is added to trade — intentional divergence-as-improvement). Although legacy trade had no SORTORDER or batch routing, the modern trade path assigns SORTORDER via content-hash (the cumulative title set per level encodes the level; different title mixes get different hashes) and joins the same PB-lane routing logic as regular standing orders. Trade heads therefore benefit from grouped-shipment optimization (PSHIP=1 lanes to Pitney Bowes, others to Downstairs) without requiring new sort codes (HH/II/JJ are already used by regular series; the content hash groups within the existing scheme). This is a designed improvement over legacy, not a regression, and aligns trade fulfillment with the modern discipline.
D-T-6 (Queue full set, dedup on move, operator decides). Trade preloads titles onto back orders for vendor procurement sizing, so the queue must show the full plan demand without purchasing/back-order gates at the queue step (unlike regular's whereNotIn purchased preference). The reporting surfaces discrepancies before the move (e.g., "2 titles queued, 1 already on back order"). The dedup + filter happen on move to production via the ship-stage toggle dedup_open_backorder (default ON) — an operator policy, not a hidden rule. Same philosophy as regular's zombie_prev_purchased toggle: system informs, operator decides (user, 2026-06-30).
D-T-8 (Trade move = direct-to-production preorder, tentative pricing). Trade lines skip brokered queues entirely and materialize directly to backhead/backdetail (TARGET_TABLE=backheads, OSOURCE='TRADE STANDING'), reflecting the preorder nature of trade (Center Point orders from vendors off these backorders, then bills as it ships). Pricing at queue time is tentative — accurate at the moment, not a billing source. The final price is recalculated out-of-band (CreateOrderFromCartAction::recalculateSalePriceForTradeItem at ship time), overwriting the tentative queue-time price with the live plan DISC. This is not a bug — it's intentional; production brodetail→invoice billing is not replicated in the fulfillment system (the app-trade tool owns that).
B&T / EDI → Distribution-partner pipeline generalization (Resolved 2026-07-03). Baker & Taylor is defunct; the legacy B&T EDI console suite (850/855/856/810/997 message handlers), monthly buyer review (FOH_BT_LINE_ITEMS :128701), and scan-to-ship workflow are being deferred in favor of a generalized abstraction. Future work: research the EDI interaction patterns + the buyer-review workflow across any future partner, then design a reusable "distribution-partner pipeline" encompassing EDI message exchange, PO/receiving, vendor address management, and buyer-review consoles. No B&T-specific port is planned. Tracked in todo.md.
8.6 🔧 For builders
Core services (app-trade module):
-
TradeTitleExclusion,TradeAuthorSubscriptionmodels + test suites (TradeTitleExclusionTest.php, TradeAuthorSubscriptionTest.php, TradeAuthorAutoPickServiceTest.php) —:app-trade/Models/,:app-trade/Tests/. -
TradeOrderService::applyChanges()— per-customer add/update/remove, enforces exclusions on new additions (:app-trade/Services/TradeOrderService.php:applyChanges()). -
TradeCMS\Http\Controllers\TradeInventoryController::levelOrders/selectOrders— pages the trade inventory, surfaces for fulfillment (:app-trade/Http/Controllers/TradeInventoryController.php).
Fulfillment pipeline services (main app):
-
StandingOrdersPreview::getTradeTitles()(:fd1b7c128) — Stage 1 selector, cumulative-by-level, PUBDATE window (whole month for TRADE, matching INVNATURE-scope requirement). -
FulfillmentScope::trade(:c14347ffe) — scope enum + routing; paired withStandingOrdersPreview::setPlans()(stage-gated byauto_buildplan classification). -
FulfillTradeStandingOrdersaction class (extendsFulfillStandingOrders, overridesscope()only) + correspondingActionRun(distinctaction_id='fulfill_trade_standing_orders'for parity-isolation, D-T-1) (:app/Actions/FulfillTradeStandingOrders.php). -
TradeLinePricer(:45e0dbf89) —final classwith a staticprice()method for prepaid obligation pricing; called by both the pipeline (Queue\StandingOrder::addTitle, Stage 1) and app-trade (TradeOrderService) (:app/Services/Pricing/TradeLinePricer.php). -
TradeHeadCompositionTest(:c14347ffe) — Stage 3 factory-free unit test confirming OSOURCE/SORTORDER/DESTINATION/prepaid pricing on a materializedBackheadmatches the recipe. -
TradeQueueResetIsolationTest(:fe16c8227) — Stage 4 parity test confirming reset + freshen + ship scope-filter isolation (two concurrent trade+regular runs, each reset clears only its own). - Queue read/write scope-filtering: all of
Queue\StandingOrder::seedInventory,shipQueue,materializeHead,reflagDestinations,sliceCoveragetake$scopeparameter and filter by INVNATURE (:queue.standing_order.phproutes).
UI routes & controllers:
-
GET /standing-orders-cms/fulfill-trade→CMSPlansController::fulfillTrade($scope='trade')(Stage 4,:fe16c8227) → delegates toPages/Plans/Fulfill.vuewithscopeprop (no fork; the M4 custom-mix card naturally drops out for trade runs). -
POST /standing-orders-cms/{endpoint}?scope=trade— shared endpoints (reset/ship/reflag/route-gate/etc.) accept optionalscoperequest field, redirect back to whichever page posted (Stage 4). - Ship form: surfaces both
dedup_open_backorder(trade-only) andzombie_prev_purchasedtoggles (Stage 3/4, D-T-6). -
CmsTabs.vue— new "Fulfill Trade Plans" tab alongside regular Fulfill tab (Stage 4).
DB models / migrations:
-
trade_title_exclusionstable — columnsaccount_key, isbn (nullable), author (nullable), created_by; notype/valuecolumns and no unique constraint; two non-unique composite indexes(account_key, isbn)and(account_key, author)(legacy didn't discriminate; exclusion is per-row). -
trade_author_subscriptionstable (account_key + author, uniqued).
Commands:
- STANDING_ORDER_TRADE_FULFILLMENT_SCOPE.md — the complete Stage 0–5 plan + parity scorecard.
- TRADE_INVENTORY_AND_ORDER_MANAGEMENT_CMS.md — implementation status of all Trade Hub phases.
- TRADE_PRINTING_REFERENCE.md — trade printing + fulfillment workflow.
- PARITY.md — rows 33, 49–54, 108 for full parity status + evidence commits.
Ops checklist (live first batch):
- Verify queue seeding (
seedInventoryTRADE scope): correct title count + pricing (prepaid $0 vs 25% discounted). - Verify head composition: OSOURCE='TRADE STANDING', SORTORDER by content-hash, DESTINATION seeded correctly.
- Verify dedup logic:
openBackorderIsbns($key)returns the right set;dedup_open_backordertoggle ON/OFF behaves as documented. - Verify DBF cascade at ship (if
skip_dbf_cascade=false): backheads/backdetails written correctly. - Verify the two-run isolation: run a trade batch + a regular batch concurrently; confirm reset of one doesn't touch the other's queued rows.
9. Inventory & Catalog
9.1 Purpose
The inventory domain manages the book catalog — the titles, stock levels, pricing data, and metadata that make it possible for customers to browse, search, order books, and for staff to manage availability. For customers, the catalog is the front door: the ability to find titles by ISBN, author, title keyword, and to see current availability and prices. For staff and fulfillment operations, inventory is the control surface for stock decisions — deciding when a title is available to buy, how many copies exist in stock, managing reprints, tracking sales and demand, and reconciling against the legacy print-on-demand and distribution partner pipelines. The catalog also holds the foundational data for standing-order and trade-distributor fulfillment.
9.2 Legacy behavior
The legacy VFP app manages inventory through a single INVENT table (webnet.prg) containing title records with ISBN, availability status, three stock-counting fields (FINALINV, ALLSALES, ONHAND), and metadata like HOTBOX status (for print-on-demand flags). The catalog is searchable by ISBN, title text, and author; the legacy app surfaces searches in the customer cart view (TD_TITLE_VIEW :15019, TD_DETAILS :17703) and a staff inventory editor (INV_READ :84028, INV_WRITE :111719).
The critical inventory semantics are:
-
FINALINV — lifetime cumulative units ever received/printed, set at title creation or receipt and never reset. Standing-order fulfillment gates on this value (aborts if FINALINV=0 :129221) because it is the print-run commitment (
webnet.prg:145660). - ALLSALES — cumulative units shipped across all channels; zeroed at monthly rebuild.
-
ONHAND — current sellable remainder, derived as
FINALINV − ALLSALESand re-seeded before standing-order runs (onhand := finalinvat :129236).
Availability status (Available / Not Yet Published / Out of Print) is synchronized during monthly release runs (MAKE_AVAILABLE :145548–145731) and drive customer-facing filters. The legacy app also manages supplementary fields like ONORDER (supply on the way) and per-title pricing overlays (flatprice ceiling, clearance floor, choice-plan discounts).
9.3 Ported forward + enhancements
The modern app preserves the inventory data model exactly (FINALINV, ALLSALES, ONHAND, HOTBOX all mirrored in MySQL via the nightly rebuild), but stages inventory operations in temporary tables to enable safe preview-first workflows. This is a deliberate divergence: legacy runs standing orders and see consequences; the app lets operators queue/preview demand before committing to ship.
Ported features:
-
Catalog search (InventorySearchWithPricingService, /search endpoint) mirrors legacy title/ISBN/author lookup with predictive scoping (high-confidence scope activation at 0.85+ confidence; :SEARCH_REFERENCE.md). Text searches preserve relevance ranking; ISBN and scope queries use indexed lookups.
/db/inventoryadmin interface with per-role field gating (20260615) mirrors the legacy inventory editor with a modern audit surface. -
Availability status (Available / Not Yet Published / Out of Print, driven by FINALINV and monthly release runs) — identical gate and reset logic, now in the
inventory:make-availableArtisan command (built 20260702, match-legacy D10, :b58f91238). Idempotent; safe to re-run. Dates to the current month and prior months by operator choice. -
FINALINV as the standing-order basis — standing-order fulfillment
needed = max(0, requested − FINALINV)(not against ONHAND), matching legacy's exact gate (:LEGACY_VFP_INVENTORY_FIELDS.md §4 locked decision). The app gates at Ship (where production is written), not at Queue/preview (where nothing is committed yet) — an improvement over legacy's preview-less, monolithic abort that forced operators to pre-decide FINALINV externally. The missing-FINALINV warning is now loud and non-blocking, enabling a better data-driven workflow: preview demand, then key FINALINV based on that preview + retail buffer.
Enhancements:
-
Inventory staging — Queue Orders builds
queue_inventoriesandqueue_ordertables (never touching production INVENT until Ship), enabling operators to build/preview/adjust without live consequences.freshenInventoryCounts()computes fresh ONBO (back-order demand, :LEGACY_VFP_INVENTORY_FIELDS.md §6b) and CART (web-cart demand) per ISBN at queue time — data legacy never surfaced — so the inventory-impact ledger shows the real demand basis before ship. -
Received/print-run suggestions — when setting FINALINV, the app can suggest the sum of
standing-order needed + back-order demand + open carts + retail buffer, based on the previewed queue. (Deferred: the UI action to set FINALINV from this suggestion; the data calculation is built as of 20260619.) - Print-on-demand lane (FASTPRINT, D11) — new; matches legacy fastprint console behavior (stock check gated, 6-month PUBDATE window, $0 prepaid pricing, HOTBOX='FPR' marker, forward-dated invoice) but adds automated monthly auto-create (28th 06:45 ET) and kill-with-notification for out-of-print titles. Ops-certified live as of 20260703 (fcaa106af).
- Search performance — text-search scoring uses exact-title matches (24,513 pts), title-contains-phrase (1,500 pts), and keyword matches (400–1,200 pts), ranked by relevance. Scope predictions (e.g., "current month") activate only above 0.85 confidence to avoid false positives.
9.4 Current state
Shipped:
- Catalog search (customer + staff) with relevance ranking.
- Inventory editor with per-role field gating.
- MARC record access (download endpoints + FTP :20260629).
- Bi-monthly catalog ordering (order-by-catalog + catalogs pages).
- Monthly availability release (make-available command, idempotent).
- Print-on-demand (FASTPRINT) lane, Phase 2 complete with auto-create + kill (full parity, 20260703).
- Book detail content (covers, copy, reviews, title.show) and legacy view retirement in progress.
In progress / Planned:
- FINALINV suggested-set action (deferred; data calc complete, UI action pending).
- Broader receiving/PO domain ownership (ONORDER/RPURCHASES, ONHAND correction — separate arc, tracked in todo.md).
- NOSTOCK (out-of-catalog title) feature — decide port vs. retire.
- Memo (FPT) field writing (reads implemented; writes not yet).
Decided/Diverged:
- Missing-FINALINV gate moved to Ship, not Queue — enables preview-first workflow (intentional improvement, approved).
- ONHAND reset pre-standing-order run (onhand := finalinv) — preserved; matches legacy's seed logic exactly.
- Per-ISBN suggested print run = NEEDED + ONBO + CART + buffer — data-driven basis, replacing legacy's external forecast (improvement, user-confirmed).
9.5 Decisions & directional rulings
| Decision | Ruling | Rationale | Date |
|---|---|---|---|
| D9 | FINALINV = lifetime print baseline, never reset; ONHAND = derived (FINALINV − ALLSALES); standing-order needed = max(0, requested − FINALINV) | Mirrors legacy exactly; FINALINV is the print-run decision, ONHAND is post-release erosion (irrelevant for demand) | 20260618 |
| D10 | Monthly make-available gates on FINALINV ≠ 0 (missingFINALINV warning, not hard abort) and re-seeds ONHAND := FINALINV at publish. Mirrors legacy's :129221/:129236 |
Operator workflow safety + data integrity | 20260702 |
| D11 | FASTPRINT lane (POD) — stock gated OFF (ships full), 6-mo PUBDATE window, HOTBOX='FPR', +1-mo invoice date, freeship, PIPACK=3/PEPACK=1 | Legacy parity at lane entry (FASTPRINT_CREATE :160254); note: DBF HOTBOX is C(3), legacy 'FPRINT' stores as 'FPR' — verified 25 prod broheads |
20260703 |
| D12 | FINALINV readiness gate belongs at Ship, not Queue/preview — preview stays open at FINALINV=0 (warned) to discover demand; operator keys FINALINV from preview + buffer; Ship holds any order with unsettled FINALINV | Improves legacy's dead-end (forced external FINALINV forecast); preserves safety intent (no undecided print run ships) | 20260618 |
9.6 🔧 For builders
Key services & routes:
-
InventorySearchWithPricingService(app/Services) — unified search router; usesPredictiveQueryBuilderServiceto detect ISBN/scope/text and chooses strategy. Confidence thresholds at0.85(HIGH),0.72(MEDIUM, rejected).InventorySearchScoringServiceweights matches;CompareInventoryTitle::bestMatches()powers text search relevance. -
/searchGET endpoint — customer + staff, returns books with pricing + standing-order-plan eligibility. -
/db/inventory/{id}/editPATCH endpoint — per-role field gating (inventory controller); DBF sync viaPrintRunInventoryServicereceive/correct actions. -
inventory:make-available {YYYYMM}Artisan command (20260702, :b58f91238) — idempotent; runs AFTERorders:release-backordersandorders:archive-sweep(run-last doctrine). Performs the bulk ONHAND/ALLSALES recompute and status flip inline inMakeAvailable::handle()(no separate service class). Supports--break(teardown: ONHAND=0, status=NYP) and--dry-run. -
FastPrintService+orders:fastprint {create|flag-available|status-check}— lane orchestration. Creates broheads with PIPACK=3, PEPACK=1, PINVOICE=1, PSHIP=0, +1mo forward invoice date, $0 for prepaid plans.FastprintVendorRowstaging + batch-CSV export. Tests:FastPrintLaneTest.php(14 contracts/127 assertions, :ceec2ed1a). -
PrintRunInventoryService— receive/correct interface for FINALINV mutations (legacy:43230,:111915,:120676). Inventory editor UX built; in-app receiving/PO domain is deferred.
Deep-dive references:
-
LEGACY_VFP_INVENTORY_FIELDS.md— definitive — FINALINV/ALLSALES/ONHAND semantics, standing-order basis, monthly run sequence, missing-FINALINV gate placement, suggested-print formula (NEEDED + ONBO + CART + buffer). Read before touching standing orders or print runs. -
SEARCH_REFERENCE.md— Inventory search architecture, decision matrix, scope confidence thresholds, performance optimizations. - Memory:
build_catalog_job.md(error handling & diagnostics).
Schema anchors:
-
inventories.FINALINV, ALLSALES, ONHAND, ONORDER(DBF + MySQL mirror via nightly rebuild). (HOTBOX is NOT an inventories column — it lives on the order-head tables (broheads/backheads/allheads/…char(3)) andstanding_orders.) -
queue_inventories(staging; temporary, built per-standing-order run). -
inventory_metrics(per-ISBN demand counters: back_ordered, purchased_total, etc.).
10. Pricing, Promotions, Campaigns & Direct Mail
10.1 Purpose
Pricing, promotions, and campaigns form the revenue-driving engine: calculating the right price for the right customer at the right time, promoting titles across email and direct mail, and managing the customer relationships that sustain recurring orders. This domain encompasses:
- Pricing: Real-time discount calculation for every ISBN–account pair based on purchase history, standing orders, and active promotions.
- Promotions: Dynamic campaigns tied to inventory (which titles are on sale) and audience (which customers are eligible).
- Campaigns & Direct Mail: Orchestrated email and mail blasts targeting specific customer segments (choice-active, standing-order subscribers, ledger accounts).
10.2 Legacy Behavior
The legacy VFP app hardcoded promotion types as 17 boolean columns on inventory_promotions (western, romance, mystery, bestseller, flashsale, clearance, trade_select, trade_level_1/2/3, choice, bookmark_1-6) and 6 promote_* columns on account_meta mirroring each promotion. Adding a promotion required two migrations, enum edits, classifier updates, and 6 blade templates. Pricing was calculated on-demand per request. Campaigns were spreadsheet-based exports with manual email template selection per run. Catalog mailing used the account.PROMOTIONS field to gate catalog quantity per user (1 = main user only; >1 = main user gets that count, others get 0).
10.3 Ported Forward + Enhancements
Pricing carried forward the decision to compute prices per ISBN–account pair, but added:
-
Temp-table architecture (
OrganizationContextService, §4.1 organization_context.md): All 42,873 pricing records (complete ISBN × account coverage) pre-calculated and cached for 3 hours, eliminating N+1 queries (99% query reduction, 30s cold / 0.55s cached) (staff/app/Services/OrganizationContextService.php). -
Sparse purchases optimization: Required fields (list_price, sale_price, discount, promotion_type, is_previously_purchased, why_discount) always present; purchases object omitted for unpurchased titles (92.8% sparse), reducing output from 16MB to 9.5MB (
organization_context.md, §7.2). -
Bucketed pricing profiles: Promotions subscribe to reusable pricing configurations organized by customer bucket (ledger, choice_active, non_choice_active, canceled_3yr), replacing hardcoded tiers (
dynamic_promotions_plan.md, §4.1).
Promotions replaced the rigid 17-column schema with a slot-pool architecture (dynamic_promotions_plan.md, §1–4):
- Each of 17 inventory-promotion columns is an anonymous, interchangeable slot. A
promotionstable row claims exactly one slot (western, romance, etc.) viainventory_slot_idand carries metadata, pricing config, and templates (Promotionmodel). - New promotion = one DB row + a Vue form (no DDL, no enum edits).
- When slots exhaust, the slot pool expands via
PromotionSlotService::expandInventoryPool()(DDL only when expanding, not creating) (dynamic_promotions_plan.md, §6, PromotionSlotService). - Each promotion's email/mail templates are stored as file_ids in a
template_assignmentsJSON column, keyed by channel + bucket scope, with fallbacks to system universals (zeroPromotionTemplatepivot tables) (dynamic_promotions_plan.md, §8).
Campaigns moved from spreadsheet + manual template selection to:
-
Processor-driven account queries: Four mutually-exclusive campaign types (choice_active, standing_order_active, standing_order_inactive, ledger), each with a dedicated processor class implementing
CampaignProcessorInterface. The processor owns the account-filtering logic and title-selection rules (CAMPAIGNS_REFERENCE.md, §2, Campaign Processor Architecture). -
Unified campaign options & wizard:
CampaignOptionenum drives wizard step creation; a hardcoded whitelist inBuildCampaignDrafts::normalizeOptions()gates which options reach the build (gotcha: new options must be added to the whitelist or are silently dropped,campaign_options_and_wizard.md, §1–2). -
Saved query integration: Users can save and reuse account-classification filter combinations directly in the wizard (Step 40), reducing manual filter selection for recurring campaigns (
saved_queries.md, Phase 1–4).
Direct Mail simplified: catalog preferences now always respect the modern account classification flags (is_ledger_account, is_active_choice_customer, is_active_non_choice_plan_customer) set during OrganizationContextService builds, independent of the legacy PROMOTIONS field logic (direct-mail.md).
Build List Interface introduced a smart inventory-filtering tool (§7.5 build_list_interface.md) with 6 customizable groups (Western, Romance, Mystery, Bestseller, Flash Sale, Clearance) and offline-first Dexie persistence. Staff can filter, group, and export ISBN lists for promotion setup or title-selection workflows without leaving the CMS (BuildListModal.vue et al., build_list_interface.md).
10.4 Current State
Shipped:
- OrganizationContextService with temp-table pricing (~30s cold / 0.55s cached).
- Dynamic promotions with slot-pool architecture and processor-driven campaigns (committed May 2026,
dynamic_promotions_plan.md). - Four promotional campaign types with processor inheritance and account-filtering logic (
CAMPAIGNS_REFERENCE.md, Command:php artisan campaigns:initialize-promotional). - Saved query integration for account-classification filters in the campaign wizard (
saved_queries.md, Phase 1–4, Commit 3947bffb4). - Build List Interface with 6 custom groups, Dexie persistence, and offline filtering (
build_list_interface.md, Phases 1–3, Commit f54e52ebe). - Pricing profiles with deduplication on save (deep-equals match → reuse, structural mismatch → auto-generate new name) (
dynamic_promotions_plan.md, §7.3).
In Progress:
Deferred:
- Slot pool expansion UI (Tier 13) — only needed when 11/17 free slots exhaust.
- Legacy
StandardPromotionenum cleanup (Tier 14) — 26 references across 13 files; blessed as optional "belt-and-suspenders forever" (dynamic_promotions_plan.md, Tier 14).
10.5 Decisions & Directional Rulings
- D10.1 — Submit locks pricing. All order pricing is locked at submit time; carts are repriced on pull only if back-ordered (parity with legacy; see domain_seams.md). Pricing is not recalculated mid-cart.
- D10.2 — Pricing is always complete. The 42,873 ISBN × account records must be pre-calculated and cached; no on-demand fallback. Required fields always present; sparse optimization only for purchases.
- D10.3 — Promotions are slot-based, not semantic. Column names (western, romance, etc.) are historical convenience; slots are interchangeable. Code asks PromotionsConfiguration for column names, never hardcodes them.
- D10.4 — Campaigns are processor-first. Each campaign reason has a dedicated processor class; the campaign-builder is orchestration, not logic. New campaign types = new processor + factory registration (no wizard-step cascading changes required).
- D10.5 — Saved queries are filter combinations, not saved campaigns. They store account-classification flag selections in the wizard, reducing manual filter entry for recurring audience targeting. They do not save campaign settings or email templates.
-
D10.6 — Campaign options flow through a whitelist. New
CampaignOptionenum cases must be added toBuildCampaignDrafts::normalizeOptions()or they are silently dropped before reaching the build (campaign_options_and_wizard.md, gotcha, step 3).
10.6 🔧 For Builders
Key Services & Classes:
-
OrganizationContextService::getContext(forceRebuild, isbnFilter, reclassifyAccounts)— Build or fetch cached org context with complete pricing (§1–7 organization_context.md). Cache key:org_context_v7_(current version). -
PromotionsConfiguration::load()/slot(id)/slotByColumn(name)/expandInventoryPool(newCount)— Resolve slot pool and column names; the only path through which code learns what column a promotion claims (§4.2 dynamic_promotions_plan.md). -
GlobalPromotionsConfigService::activePromotions()/resolvePrimaryPromotionForTitle(isbn, accountKey)/resolveBucketForAccount(accountKey)/resolveEmailTemplateFor(Promotion, accountKey, channel)— Promotion resolver layer replacing scattered priority/profile logic (§5 dynamic_promotions_plan.md). -
CampaignProcessorFactory::create(reason, options)→CampaignProcessorInterface— Account-query and title-selection logic per campaign reason (§2 CAMPAIGNS_REFERENCE.md). ExtendBaseCampaignProcessorfor standard filtering;AbstractCampaignProcessorfor custom logic. -
BuildCampaignDrafts::normalizeOptions(options)— Whitelist gate onCampaignOptionenums; consumed by all BuildCampaignDrafts-based paths (⚠️BuildCustomEmailListin app/Jobs has its own separate, non-whitelistednormalizeOptions) (campaign_options_and_wizard.md, §1, gotcha). -
InventoryListExportService/BuildListModal.vue/inventoryList.js(Pinia store) — Build, filter, group, and export ISBN lists with 6 custom groups and Dexie persistence (build_list_interface.md, §2).
Key Routes:
-
POST /api/campaigns/initialize-promotional— Artisan command entry point (CAMPAIGNS_REFERENCE.md,campaigns:initialize-promotional). -
GET /cms/promotions//cms/promotions/create//cms/promotions/{slug}/edit— Promotion admin (dynamic_promotions_plan.md, §7.1–2). -
GET /api/inventory/bulk— Load 10k inventory items for Build List (build_list_interface.md, Controllers). -
POST /inventory/lists/generate— Queue CSV export for selected ISBNs (build_list_interface.md, Routes). -
GET /ajax/accounts/classifications/queries— List saved account-classification filter combinations (saved_queries.md, Phase 2).
- Organization Context:
staff/docs/memory/organization_context.md(architecture, temp tables, compression). - Promotions:
staff/docs/memory/dynamic_promotions_plan.md(slot pool, pricing profiles, template strategy). - Campaigns:
staff/storage/notes/CAMPAIGNS_REFERENCE.md(processor architecture, execution modes, troubleshooting). - Campaign Options:
staff/docs/memory/campaign_options_and_wizard.md(whitelist gotcha, new-option checklist). - Build List:
staff/docs/memory/build_list_interface.md(filtering, grouping, offline persistence, phases). - Saved Queries:
staff/docs/memory/saved_queries.md(account-filter combinations, mutual exclusivity, checkbox options).
Parity Reference: See PARITY.md "Pricing / Promotions / Campaigns" section (8 rows: legacy behavior, ported forward or deferred status, divergence rationale).
11. Accounts, Organizations & Contacts
11.1 Purpose
An account is the central customer record in Centerpoint — the single entity representing a school, library, bookstore, vendor, or individual who purchases books. Every account holds organizational identity (company name, account type), geographic location, contact information, order history, plan memberships, and a classification profile used to determine pricing, eligibility, and communications. Organizations extend the account model: branches enable multi-location customers to manage separate purchasing within a single parent account, while contact pools and address composition (planned) will support complex, multi-person purchasing workflows. The account system also houses callback management, allowing staff to track follow-ups with customers and manage relationship workflows through the Account Representative Dashboard.
11.2 Legacy Behavior
The legacy Visual FoxPro application (webnet.prg) stores account data in the ACCOUNTS table, keyed by ACCOUNT_KEY (a unique identifier per customer). Each account carries denormalized contact information (first name, last name, title, phone, fax, email), organizational fields (ARTICLE, ORGNAME), and a single physical address (STREET, CITY, STATE, ZIP). Contact handling is distributed: the PASSWORDS table mirrors contact identity per login row (parallel SEX, FIRST, LAST, TITLE, VOICEPHONE, FAXPHONE, EMAIL fields), creating duplication across the two tables. The RECALLD (recall date) field on accounts tracks the next callback date for account representatives. Additional address variations (shipping, billing, standing-order specific) are stored denormalized in the legacy ADDRESSES table as separate rows, with no unified pool deduplication. The NATURE field (webnet.prg line context, account classification) is a single-character code (C=customer, I=individual, V=vendor, B=bookstore, S=supplier, P=publisher) used to distinguish account types.
11.3 Ported Forward + Enhancements
Account core: The Account model (app/Models/Account.php) preserves all legacy contact and organizational fields (ARTICLE, ORGNAME, STREET, CITY, STATE, ZIP5, COUNTRY, SEX, FIRST, LAST, TITLE, VOICEPHONE, EXTENSION, FAXPHONE, EMAIL, RECALLD), maintaining compatibility with the daily DBF rebuild. The modern application adds the account_meta table (app/Models/AccountMeta.php) — a classification layer that augments every account with 86 calculated columns (commit 5565eaed8) covering account types (NATURE codes), plan membership, behavioral flags, metrics, and preserved external preferences (promote_western, promote_romance, etc.). Classification runs during OrganizationContextService context build (app/Services/OrganizationContextService.php) and is also available via artisan account:classify-all.
Address composition and contact architecture: The Address model (app/Models/Address.php) and AccountAddress pivot (app/Models/AccountAddress.php) implement a composer pattern: each account_addresses row links an account to a canonical geographic location and specifies use flags (default_physical, default_shipping, default_billing, default_plans_shipping, default_plans_billing, default_promotionals). The AddressTranslationService (app/Helpers/AddressTranslationService.php) translates composed addresses to webhead-shaped fields (COMPANY, ATTENTION, STREET, CITY, etc.) with fallback rules. During transition, contact identity remains denormalized on accounts and passwords; the target-state canonical contacts pool (planned) will deduplicate across all contact identities via soft-link policy, with string_id dedup keys (app/Models/Contact.php, not yet wired).
Callback management: The CallbackManagementService (app/Services/CallbackManagementService.php) manages RECALLD updates with dual persistence (accounts + accounts_meta, surviving daily rebuilds) and automatic Custnote generation with locked_at timestamps (locked_at surviving partial rebuilds). The AccountRepresentativeDashboard.vue (resources/js/Pages/AccountRepresentativeDashboard.vue) consolidates callbacks into a unified SPA with workload visualizations, progress tracking, and bulk rescheduling strategies. Related DashboardController endpoints (app/Http/Controllers/DashboardController.php) handle completions, reschedules, and bulk operations.
11.4 Current State
Shipped:
- ✅ Account CRUD and view (AccountController, accounts.show, edit) — full contact and organizational fields; DBF mirrored.
- ✅ Account classification — 86-column account_meta (exclusive types, 32 plan flags, behavioral flags, metrics) populated via OrganizationContextService; decision D1 (classify during context, not per-request); verified by AccountClassificationBulkPipeline and validateClassificationCompleteness.
- ✅ Address composition and defaults — AccountAddress pivot with role flags (default_physical, shipping, billing); AddressTranslationService provides webhead translation with fallback rules.
- ✅ Callback management dashboard — AccountRepresentativeDashboard, rescheduling (single and bulk), completion with automatic Custnotes, metrics tracking, workload visualization.
- ✅ Vendor-style address rules — CSR Central Library pattern (identical billing/shipping, identical SO addresses, org-name-derived DEPARTMENT); AddressFieldConverter enforces smart ARTICLE+ORGNAME split.
Partial / Planned:
- ⏳ Canonical contacts pool (Contact model, contacts table) — target-state entity designed; soft-link policy documented; seeds from passwords+accounts and mirrors forward; schema not yet created;
findOrCreateandmakeStringIdhelpers not yet implemented. - ⏳ Account_addresses contact_id FK — pivot rows will reference canonical contacts instead of inline VOICEPHONE/FAXPHONE; migration splits
default_plansinto four flags (default_plans_shipping, default_plans_billing, plans_shipping, plans_billing). - ⏳ Account Priority System — planned (LOW, MEDIUM, HIGH) with picker, filter, budget warning, tier comparison; design pending, not yet built.
- ⏳ Standing-order seeding chain — target uses independent plans_shipping and plans_billing seeds; StandingOrder::initFromAccount not yet wired; currently reads via legacy getLegacyAddresses.
11.5 Decisions & Directional Rulings
D1: Account classification is point-in-time, not per-request. The 86-column account_meta classification runs once during OrganizationContextService build (context TTL 3 hours) and is cached. No per-request classification. Rationale: 12 temp tables with ~100 queries per account, ~30s cold / 0.55s cached; per-request would be 30s per page load. Classification is deterministic (NATURE codes, order history, plan membership); caching is safe.
D2: Contact denormalization persists during transition. Accounts and passwords keep their SEX, FIRST, LAST, TITLE, VOICEPHONE, FAXPHONE, EMAIL fields through the transition to the canonical contacts pool. Writes to these fields trigger soft-link ensure-or-create in the contacts pool (when implemented), but the composer fields stay for legacy reads and DBF mirroring. Rationale: Accounts is DBF-mirrored; passwords is structurally frozen (daily rebuild). Removing denormalized fields would require schema changes that can't survive the rebuild.
D3: RECALLD dual-persistence survives account rebuild. Callback date changes write to both accounts (for immediate views) and accounts_meta (survives daily rebuild). Custnotes created with locked_at timestamp survive partial rebuilds. Rationale: accounts is rebuilt daily from DBF, losing MySQL-only changes; accounts_meta persists. Dual-write pattern ensures continuity across rebuild cycles.
D4: Address composition via fallback chain, not override-only. Default_physical composes from accounts directly (accounts.ARTICLE+ORGNAME, accounts.SEX+FIRST+LAST, etc.); non-physical pivots read via contact FK (when planned) with fallback to accounts composite. Rationale: No customer should ever see blank address fields; fallback ensures coverage. COMPANY override (pivot-only) allows alt-row organizational identity without affecting accounts.
11.6 🔧 For Builders
Core Services & Models:
- Account (app/Models/Account.php) — Primary singular customer record; soft-linked to canonical contacts via denormalized contact fields; hard-linked to AccountAddress pivots.
- AccountMeta (app/Models/AccountMeta.php) — ~159-column classification table (162 incl. id/timestamps); keys off account_key; populated via OrganizationContextService classifyAccountsInternally, validateClassificationCompleteness. (The "86-column" figure predates the Apr 2026 account_metrics absorption, commit 78052a6b2.)
- AccountAddress (app/Models/AccountAddress.php) — Pivot; links account → address + contact + role flags (default_* pairs); saving guard enforces COMPANY/VOICEPHONE null on default_physical, email_for_* null when !default_billing.
- Address (app/Models/Address.php) — Canonical geographic pool; dedup via string_id (normalized STREET, CITY, STATE, ZIP, COUNTRY); makeStringId, findOrCreate, createAddressFromAccount helpers.
- Contact (app/Models/Contact.php) — Planned canonical contact pool; dedup via string_id hash of SEX, FIRST, LAST, TITLE, VOICEPHONE, EXTENSION, FAXPHONE, EMAIL.
- CallbackManagementService (app/Services/CallbackManagementService.php) — rescheduleCallback, completeCallback, bulkReschedule, validateDateCapacity, suggestNextAvailableDate.
- AddressTranslationService (app/Helpers/AddressTranslationService.php) — addressToWebHeadFields; composes pivot + account into webhead shape with fallback rules.
- AddressFieldConverter (app/Helpers/AddressFieldConverter.php) — Smart split of ARTICLE+ORGNAME; SEX/FIRST/LAST name-part mapping; null-preserve list.
- OrganizationContextService (app/Services/OrganizationContextService.php) — classifyAccountsInternally, classifyAccountTypes, classifyPlanMembership, classifyAccountBehaviors, updateAccountMetaTable; 12 temp tables; accounts_meta fully populated.
Controllers & Routes:
- AccountController (app/Http/Controllers/AccountController.php) — show, edit, update account; custom write paths for default-physical and alt-address edits (PUT /account/{account}/addresses/default-physical, etc.).
- AddressesController (app/Http/Controllers/AddressesController.php) — setDefault*, bulk address pipeline integration.
- DashboardController (app/Http/Controllers/DashboardController.php) — accountRepDashboard, getCallbacksForDate, completeCallback, rescheduleCallback, bulkReschedule.
-
Routes:
-
GET /account/{account}— show account -
PATCH /account/{account}— edit account (contact/org fields) -
PUT /account/{account}/addresses/default-physical— UpdateDefaultPhysicalAddressService -
PUT /account/{account}/addresses/default-shipping— UpdateDefaultShippingAddressService -
PUT /account/{account}/addresses/default-billing— UpdateDefaultBillingAddressService -
POST /account/{account}/addresses/set-default— AddressesController::setDefault* -
GET /report/account-representative— AccountRepresentativeDashboard -
POST /api/callbacks/complete,/reschedule,/bulk-reschedule— Callback operations -
GET /api/callbacks/date/{date}— Callbacks for date
-
Components:
- AccountRepresentativeDashboard.vue (resources/js/Pages/AccountRepresentativeDashboard.vue) — Unified SPA; default CallbackWorkloadChart, drill-down to CallbackList, modal actions.
- CallbackWorkloadChart.vue, CallbackList.vue, RescheduleModal.vue, BulkRescheduleModal.vue, CompleteCallbackModal.vue — Callback UI.
- useCallbackManagement.js — Composable; state, API integration, modal control.
Commands & References:
-
php artisan accounts:classify [--only=] [--limit=] [--dry-run]— Bulk classification (the only classify command; runs all phases by default). - ⚠️ There is no single-account
account:classify --account-keycommand — scope with--limit=/--only=, or usephp artisan org:context {account_key}for per-account context. - ACCOUNTS_REFERENCE.md — Vendor-style address rules, planned priority system.
- address_contact_architecture.md — Canonical pools, composers, soft-link policy, migration deltas (target-state design).
- account_meta_classification.md — classification reference (documents the Feb-2026 86-column snapshot; live table is now ~159 cols); NATURE codes; category breakdown.
- ACCOUNT_REP_DASHBOARD_GUIDE.md — User guide; metric explanations; workflow.
- CALLBACK_DASHBOARD_REFERENCE.md — Technical implementation detail; legacy integration workarounds; testing checklist.
12. Fulfillment, Printing & Shipping (Pitney Bowes / SORTORDER)
12.1 Purpose
Once an order is confirmed — whether from a web cart, standing order fulfillment, back-order release, or trade plan — it must move through a complex pipeline to reach the customer. Fulfillment decides what books ship and how they route (print on demand, stock pull, trade distributor, etc.). Printing & Shipping then produces the physical shipment — invoices, packing lists, labels, and manifest files ready for a carrier.
The fulfillment system routes orders into distinct lanes: orders bound for Pitney Bowes (a 3rd-party shipping application that prints labels and calculates weights), orders destined for in-house printing (paid-per-order processing, special handling), back-order batches, and fast-print (print-on-demand) flows. Each lane has its own print template, shipping manifest, and partner integration.
This chapter covers how orders flow from confirmation through fulfillment routing, then through printing and shipment execution — with a special focus on the SORTORDER hash and PSHIP flag, which form the contract with Pitney Bowes.
12.2 Key Workflows
Back-Order Release & Batch Invoicing
When a customer orders a title that isn't yet in stock, the order is held as a back-order. Every month (typically at the start of the month following publication), back-orders are released: the system retrieves all held orders for titles that have now arrived, recalculates pricing (e.g., jobber discounts may shift), merges multiple orders per account, and creates heads ready for picking and shipment. The orders:release-backorders command (BackorderReleaseService, ProcessBackOrderAction) performs this release:
- Pre-clean: wipe old dust (orphaned details from prior months).
- Merge: combine multiple back-order heads for the same account into one shipment when possible.
- FIFO allocation: distribute limited stock (New York & Penguin Random House titles especially) fairly across waiting orders, oldest first.
- Partial fulfillment: if not enough copies exist to satisfy everyone, split the order (some lines ship now, others stay held for next month).
- Discount overlay: apply standing-order discounts and other ledger adjustments.
The release command includes a dry-run mode for staff to preview what will happen before committing. (Source: webnet.prg:60095–60992 library merge, FIFO, discount overlay; app: release-backorders command, dry-run mode at PARITY.md#Fulfillment).
Fast-Print (Print-on-Demand) Lane
Print-on-demand titles — those flagged with HOTBOX='FPR' (originally 'FPRINT' in the legacy app, truncated to 3 characters in the DBF) — are sent directly to a print vendor rather than pulled from stock. These orders skip the inventory gate (they always "ship" their full requested quantity, even if stock is zero) and carry a forward-dated invoice (one month ahead, so the title's receipt date appears correct to the customer). The POD lane:
-
Monthly mark:
orders:fastprint flag-availableauto-marks eligible titles (6+ months past publication, no prior fastprint mark, FASTAVAIL≠'NO'). -
Create batch:
orders:fastprint creategenerates the print order with vendor-specific columns, CSVs for the vendor, and ship flags (PIPACK=3,PSHIP=0, no Pitney Bowes routing). -
Status check:
orders:fastprint status-checkpolls the vendor for completion. - Out-of-print sweep: titles older than 5 years can be declared out-of-print via the console, removing POD backorders and notifying customers.
POD orders are excluded from Pitney Bowes grouping and flow directly to in-house printing. (Source: webnet.prg:158781–160254 FASTPRINT console, fstatus state machine, hotbox marker; app: FastPrintService, /fastprint CMS console, FastPrintLaneTest; PHASE 1 + 2 committed 2a08a04ec, fcaa106af, ceec2ed1a.)
Standing-Order Fulfillment & SORTORDER Hash
Standing orders — recurring monthly deliveries to schools and libraries — are fulfilled via the Fulfill Plans tab on the /standing-orders-cms page. The fulfillment process is staged:
- Build orders: create heads and detail lines from each plan's enrolled series, grouped by account and address. At this stage, the system computes the SORTORDER hash — a fingerprint of the order's content used by Pitney Bowes to group identical orders for a single label set and weight calculation.
- Route to destination: classify each head as Pitney-Bowes-bound (grouped, high-volume, auto-label), downstairs (special handling, low-volume), or held for manual review.
- Materialize: commit the staged heads to the live database and assign TRANSNO identifiers.
The SORTORDER value is built from the 2-letter codes in the sosmart map (now stored in Plan::plansInfo()), one code per enrolled series, concatenated in a fixed order with per-series quantities: 01AA01BB01CC01HH01JJ means qty 1 of Christian-L1 + qty 1 of Christian-L2 + qty 1 of Christian-L3 + qty 1 of Premier-Fiction + qty 1 of Premier-Mystery. Identical content (same series, same quantities) produces the same SORTORDER, allowing Pitney Bowes to batch-group them.
The PSHIP flag indicates fulfillment state:
-
PSHIP=1(withPEPACK=PIPACK=PINVOICE=1): order is awaiting Pitney Bowes (labels + weight calculation). -
PSHIP=0(withPEPACK=PIPACK=PINVOICE=1): Pitney Bowes has processed the order and flipped the flag; the app may now proceed with in-house printing. -
PSHIP=0(withPEPACK=PIPACK=PINVOICE=3): printing is done; the order is ready to ship.
Single-book orders (singletons) and staff-routed special-handling orders skip the Pitney Bowes lane and are written 0,1,1,1 directly. (Source: webnet.prg:129164–129933 PRINT_STANDING, sosmart 16-row code map, onesie-collapse logic, SORTORDER build; app: StandingOrdersPreview::buildOrders(), StandingOrdersPreview::buildSortorderCode(), Queue\StandingOrder::materializeHead() flag-setting, FulfillStandingOrders UI with 4-stage wizard.)
Pitney Bowes Handoff
After standing-order or regular-order fulfillment creates heads with PSHIP=1, the Pitney Bowes shipping application (external to this app) reads the database:
- Queries for orders with
PSHIP=1, grouped by distinctSORTORDERvalues. - Staff trigger "get orders" and select one SORTORDER value.
- The PB app prints shipping labels and calculates weights for all orders with that SORTORDER.
- The PB app flips those orders'
PSHIPflag from1to0, signaling the app that label printing is done.
The app then resumes: PrintQueueService queues those orders for invoice and packing-slip printing (in-house), triggered by the flag flip. This is the single external integration point — everything else (order fulfillment, print execution, manifest generation) is internal to Centerpoint. (Source: legacy webnet.prg:101064–102848 print queues, flag routing; app: PrintQueueService, per-flag batch selection.)
Bulk Printing & Shipping Manifests
Once orders are flagged PSHIP=0 (or 0,1,1,1 for non-Pitney orders), the invoice and packing-slip print run executes. Staff trigger a print action at the console, which:
- Queries orders by flag state (typically
PSHIP=0, PEPACK=1). - Renders PDF invoices and packing slips via
.dottemplates (merged with order data). - Generates a shipment manifest file for hand-off to the carrier (EDI format for trading partners, CSV for in-house logistics).
- Flips the flags to
PEPACK=3, PIPACK=3, PINVOICE=3(marked printed).
Print jobs can be filtered by date range, account, order type, and other criteria; staff can preview before committing. (Source: webnet.prg:99863–102848 invoice print engine, packing-list templates, stamp logic; app: PrintQueueService, print routes, PDF queue.)
Invoice & Packing-List Templates
The app produces multiple print formats:
- Invoices: itemized list of books, quantities, unit and total prices, including any discounts or free-shipping markers.
- Internal packing lists: sorted by carton number (for warehouse picking), with ISBN, title, quantity.
- External packing lists (customer-facing): itemized list, sometimes formatted for label printing if glued to the carton.
- Trade invoices (trade-specific): simplified format for trade distributor accounts.
Each format is a .dot merge template, filled at print time with order, detail, and account data. Print queue batches are logically grouped (by account, by date, by order type) and can be previewed before printing to PDF. (Source: webnet.prg:99863–101571 EASYDATA, INSENDTOPRINTER, print template logic; app: PrintQueueService batching, .dot template rendering, PDF outputs.)
12.3 Audience & Access
End-user librarians do not directly interact with the fulfillment or printing layer; they place orders and track status through the Orders and Shipments pages.
Account Representatives may see high-level shipment status in their account dashboards but do not control fulfillment routing or printing.
Customer Service Representatives manage back-order investigations and can view fulfillment status, cancel or adjust pending orders, and trigger special routing or reprinting when needed.
Operations & Fulfillment Teams control the entire fulfillment and printing pipeline:
-
Fulfillment operators run monthly back-order releases, standing-order fulfillment, and fast-print batches via the CMS pages (
/back-orders-release,/standing-orders-cms/fulfill,/fastprint). - Print operators manage the print queue and shipment manifests via console and CLI.
- Inventory managers set FINALINV (the baseline received inventory) and monitor stock for fastprint-eligible titles.
System Administrators audit fulfillment lane configuration, permissions, and data consistency.
12.4 How It Works: The Fulfillment Pipeline
The Stages of Standing-Order Fulfillment
The /standing-orders-cms Fulfill Plans tab orchestrates standing-order fulfillment as a 4-stage wizard. Each stage is an ActionRun — a resumable, idempotent batch operation that can be paused and resumed without duplicating work:
-
Stage 0 — Queue Orders
-
StandingOrdersPreview::buildOrders()iterates enrolled plans for each account, selects eligible titles from the month'sFINALINVinventory (gated onINVNATURE='CENTE'), and buildsqueue_brohead/queue_brodetailrows. - For each plan, calls
getRegularTitles(),getBestSellerTitles(), orgetCustomMixTitles()to select the ISBN list for that month (respecting plan type, format/publisher filters, never-re-ship rules). - Computes the SORTORDER hash by reading each plan's 2-letter code from
Plan::plansInfo(), concatenating in alphabetical order, and appending per-series quantities. - For bestseller or custom-mix content, extends the hash with a
.delimiter and a SHA-256 content fingerprint (deterministic, so identical mixes group). - Saves each head with the computed SORTORDER and a
DESTINATIONflag (Pitney Bowes vs. downstairs vs. held). -
(Source: app
Queue\StandingOrder::buildOrders(),StandingOrdersPreview::buildSortorderCode().)
-
-
Stage 1 — Route to Destination
-
Queue\StandingOrder::shipQueue()(operator-triggered) reads the queued heads and applies routing rules: auto-gate for mail-innovations limits, operator overrides, and fulfillment strategy (e.g., "always group Platinum by address" or "split custom-mixes to downstairs"). - Sets
DESTINATIONon each head (FulfillmentDestination::PitneyBowes, Downstairs, ManualReview, or Held). - Operator can manually override individual heads or entire groups (the
moveOrdersUI allows settingrouting_approved_atfor safety gates). -
(Source: app
Queue\StandingOrder::shipQueue(),CMSPlansControllerrouting UI.)
-
-
Stage 2 — Materialize
-
Queue\StandingOrder::materializeHead()(operator-triggered) converts queued heads to livebrohead/brodetailrows with assigned TRANSNO identifiers. - Reads the
DESTINATIONflag and sets thePSHIP+PEPACK/PIPACK/PINVOICEflags: Pitney Bowes heads →1,1,1,1(awaiting PB); downstairs →0,1,1,1(skip PB); held → flags TBD. - Contiguously assigns TRANSNOs in SORTORDER order (so same-hash heads get adjacent transaction IDs, mirroring the legacy renumber pattern).
- Calls
HeadRetallyServiceto recalculate totals (AMOUNT, TAX, SHIP, etc.) andStandingOrderBillingConsistencyServiceto ensure address consistency across the plan. -
(Source: app
Queue\StandingOrder::materializeHead(),HeadRetallyService, flag-setting logic.)
-
-
Stage 3+ — Print & Ship (below, shared with back-orders and web orders)
Back-Order Release
The orders:release-backorders command (ProcessBackOrderAction) executes as a single, all-or-nothing commit:
- Pre-clean: delete orphaned detail rows from prior months (no matching head).
- Load back-order heads (where
PSHIP=4or similar held flag) and inventory for the target publication month. - Merge: for each account, combine multiple held heads into one (via
HeadMergeService). - FIFO allocate: sort by TRANSNO (oldest first), distribute available stock.
- Partial fulfillment: lines that don't get stock remain held; lines that do are stamped for fulfillment.
- Discount overlay: apply any standing-order or promotion discounts.
- Append to live heads.
- Run the archive sweep (below).
The command supports --dry-run to preview before committing. (Source: app BackorderReleaseService, ProcessBackOrderAction, dry-run mode.)
Fast-Print Lane
The fast-print monthly workflow:
-
Flag-available (
orders:fastprint flag-available): scan titles by publication date, find those without a prior fastprint mark and withFASTAVAIL='YES'(or unset, subject to auto-rule gate), and setFASTPRINT='Y'on the title row. -
Create (
orders:fastprint create): query back-orders for fastprint-marked titles, create a print head per account/address, forward-date the invoice (month ahead), set ship flags (PSHIP=0, PIPACK=3), and generate the vendor CSV. -
Export & send to vendor: the
FastPrintVendorRowstaging table accumulates rows; a combined CSV is exported and handed off to the print vendor (usually monthly). -
Status-check (
orders:fastprint status-check): poll the vendor system for completion; mark heads done and ready for in-house printing.
(Source: app FastPrintService, orders:fastprint command, FastPrintLaneTest contract tests.)
Print Queue & Shipment Manifests
Once orders are ready for printing (flag state PSHIP=0, PEPACK=1), the PrintQueueService manages the print run:
- Select batch: operator chooses a date range, order type, or account scope via the console.
-
Render PDFs: fetch order data, render invoices and packing lists via
.dottemplates, accumulate into a batch print job. - Generate manifest: extract shipment metadata (addresses, carrier, tracking numbers if known) and format as EDI or CSV for the carrier.
- Preview & commit: operator previews the batch before printing to PDF or sending to the printer.
-
Mark printed: flip flags to
PEPACK=3, PIPACK=3, PINVOICE=3.
Print jobs are idempotent — reprinting a batch re-renders the PDFs but doesn't re-stamp the flags unless explicitly reset. (Source: app PrintQueueService, print routes, CLI print command.)
Archive & Cleanup
After shipment, orders move to the archive tables (allhead / alldetail):
-
ArchiveProcessedOrderActionororders:archive-sweepmoves shipped heads (where all lines have shipped) to archive. - Back-ordered lines (where some detail rows remain unshipped) are split: shipped lines go to archive, unshipped lines stay in the live back tables for next month's release.
- Soft-lifecycle audit (legacy bridge):
SoftLifecycleAuditServiceinfers timestamps for legacy writes that bypassed the app (e.g., Pitney Bowes flippingPSHIP=0directly on the DBF). - FINALINV is reset to zero for the month (so next month's fulfillment can rebuild from the fresh received inventory).
(Source: app ArchiveProcessedOrderAction, orders:archive-sweep, SoftLifecycleAuditService.)
12.5 Integration Points
Pitney Bowes: The external shipping application reads orders with PSHIP=1 grouped by SORTORDER. Staff trigger "get orders", the app lists distinct SORTORDERs alphabetically, staff pick one, and PB flips those orders to PSHIP=0 when done. No two-way API; the app polls the DBF flag.
Legacy VFP App: The modern app coexists with the legacy webnet.prg. Both can write fulfillment heads and details. The SoftLifecycleAuditService (running at 23:00 ET nightly) audits orders that the legacy app modified directly on the DBF (e.g., back-order status, shipment consolidation) and updates the MySQL mirror to match.
Inventory System: Fulfillment seeds from the monthly FINALINV inventory snapshot. FINALINV is set by the inventory:make-available command, which tallies received books and published inventory updates. Fastprint checks FASTAVAIL (a per-title opt-out for print-on-demand eligibility).
Account & Address Tables: Fulfillment reads account-level preferences (billing consistency, format/publisher filters, prepaid status) and address records (shipping vs. billing). Address mismatches are flagged in the Billing Consistency review queue.
Standing Order Plans: The fulfillment system reads Plan::plansInfo() (the source of truth for plan registry, 2-letter SORTORDER codes, class, discount, placeholder ISBN) and the plan's active subscriptions (StandingOrder rows) to determine which plans are enrolled for each account.
Print Queue & Email: After materialization, heads flow to the print queue. Email notifications are triggered by AccountTransactionEvent (codes 34–35 for soft-lifecycle audit, 36+ for account status changes); the pipeline logs events as transactions for audit trails.
12.6 🔧 For Builders
Key Classes & Services
-
BackorderReleaseService(app/Domain/Orders/Services/BackorderReleaseService.php): pre-clean, merge, FIFO allocation, partial fulfillment, discount overlay for back-order release. -
ProcessBackOrderAction(app/Domain/Orders/Actions/ProcessBackOrderAction.php): stageable action wrapper for the release command. -
StandingOrdersPreview(app/Models/StandingOrdersPreview.php): builds queue heads, selects titles per plan, computes SORTORDER. -
Queue\StandingOrder(app/Models/Queue/StandingOrder.php): staged materialization (shipQueue, materializeHead) with operator routing. -
FastPrintService(app/Domain/Orders/Services/FastPrintService.php): monthly mark, create, status-check, out-of-print sweep. -
PrintQueueService(app/Domain/Orders/Services/PrintQueueService.php): batch selection, PDF rendering, manifest generation. -
ArchiveProcessedOrderAction(app/Domain/Orders/Actions/ArchiveProcessedOrderAction.php): archive sweep, back-order split. -
SoftLifecycleAuditService(app/Services/SoftLifecycleAuditService.php): bridges legacy VFP writes to the MySQL mirror (codes 34–35).
Routes & Commands
CMS Routes (in routes/web_auth.php):
-
cms.plans.release.*: back-order release workflow UI. -
cms.plans.fulfill.*+cms.plans.audit.*: fulfillment and review queues. -
cms.fastprint.*: POD console (create, status, out-of-print). -
cms.plans.print-run-inventory.*: print-run inventory (receive/correct);cms.print-catalog.*for catalog batches.
Artisan Commands:
-
orders:release-backorders {invoice_date} [--batch=] [--comparator=] [--dry-run]: back-order release (no--account-keyoption). -
orders:fastprint {create|flag-available|status-check} [--dry-run]: POD batch. -
orders:archive-sweep [--dry-run]: move shipped orders to archive. -
trade:sync-author-picks: trade author subscription auto-fulfill (daily 08:30 ET).
Database & Cache
-
Queue tables:
queue_brohead,queue_brodetail,queue_inventories(staging for a fulfillment run; dropped/recreated per run). -
Live tables:
brohead,brodetail(standing orders, back-orders in fulfillment lane),backhead,backdetail(raw back-orders awaiting release),allhead,alldetail(archive). -
Inventory:
inventories(indexed byPROD_NO;FINALINV,ONHAND,ALLSALEScolumns;FASTAVAILfor POD eligibility). -
Standing orders:
standing_orders(plan subscriptions), plans registry inPlan::plansInfo()(2-letter code, class, discount).
Cache:
-
broker:sortorder-code-map(2-letter code per plan class; pre-built on plan changes). - Queue-run logs cached in the
ActionRunmodel (progress, error counts, resumability).
Contract Tests & Parity
See tests/Feature/Fulfillment/ for the lane contracts:
-
BackorderReleaseTest.php(6 contracts: pre-clean, merge, FIFO, partial, discount, dry-run). -
StandingOrderLaneTest.php(4 contracts: queue build, routing, materialization, contiguity). -
FastPrintLaneTest.php(11 contracts: flag, create, vendor export, status-check, sweep).
Parity anchors (legacy to app code):
- SORTORDER hash build:
webnet.prg:129677–129774(barfdog 1..16loop) ↔StandingOrdersPreview::buildSortorderCode(). - Onesie-collapse:
webnet.prg:129787–129933(singleton demotion) ↔Queue\StandingOrder::materializeHead()(DESTINATION=Downstairs routing). - PSHIP flag lifecycle:
webnet.prg:129618–129637(INSERT column alignment) ↔FulfillmentDestination::flags()+materializeHead(flag-setting). - Back-order FIFO:
webnet.prg:60453/:10905(oldest-first by TRANSNO) ↔BackorderReleaseService::allocateFifo().
Critical Requirements
-
SORTORDER uniqueness: Identical content (same series, same qty) must produce identical SORTORDER. The hash uses the per-series code from
Plan::plansInfo()(source of truth) in alphabetical order and per-series quantity. Test against the legacy ground-truth samples:01AA01BB01CC01HH01JJ(5-series group) and01AA01BB(2-series group). -
Pitney Bowes handoff: Orders bound for Pitney Bowes must be written with
PSHIP=1(1,1,1,1) at materialization (not0). Only Pitney Bowes, when done, flipsPSHIP→0. Non-Pitney (downstairs, fast-print) orders skip to0,1,1,1directly. -
FINALINV gate: Standing-order title selection and back-order release must gate on
INVNATURE='CENTE'and targetFINALINV(notONHANDor current stock).FINALINVis set once per month byinventory:make-available(afterorders:release-backorders+ daily rebuild). -
Contiguity: TRANSNO assignment must be contiguous within SORTORDER groups (or at least within a fulfillment run) so same-hash orders get adjacent IDs, mirroring legacy's sorted renumber. Use
SequenceService::nextTransno()but assign inSORTORDERorder. -
Never mutate production inventory: The queue build uses a staged
queue_inventoriessnapshot;ONHANDis never decremented during fulfillment (only thequeuesnapshot is).FINALINVis reset to zero at month-end (after fulfillment and archive). -
Idempotency & resumability: Each fulfillment stage is an
ActionRunthat can be resumed without duplicating heads. Guard against re-running withmaterialized_attimestamps or arun_idforeign key.
For detailed queue-stage decisions and forward-design notes (Bestseller + custom-mix handling, auto-gating limits, per-series format filters), see:
-
staff/storage/notes/LEGACY_VFP_STANDING_ORDER_PIPELINE.md— SORTORDER encoding, stages, legacy parity matrix. -
staff/storage/notes/STANDING_ORDER_BUILD_PLAN.md— multi-milestone rollout (M1–M5) with stage-by-stage decisions. -
staff/docs/memory/domain_seams.md— "Fulfillment / Pitney Bowes" facts.
13. Notifications, Email & Chat (Brutus)
13.1 Purpose
This domain keeps customers and staff informed — automatically on events (plan renewals, expirations, order status changes) and on-demand (account lookups, plan health checks, contact callbacks). The AccountEvents pipeline fires email + in-app notification + custnote + transaction log on standing-order lifecycle changes. The Brutus chat interface surfaces intent-driven tools (account edit, contact callback, standing-order management) inline within the app, accessible from buttons, widgets, and canvas embeds without leaving the current page.
The legacy app's email system was file-based (EMBASE.DBF dropped by a console; staff hand-wrote templates). The modern app replaces that with a unified event pipeline that triggers email + side effects deterministically, paired with a ubiquitous chat layer that makes common actions available everywhere.
13.2 Legacy behavior
The legacy VFP app (webnet.prg) had three distinct notification paths:
Trade email console (NEW_EMAIL_CONSOLE :136450–138136): A staff interface to compose and send promotional emails to trade accounts. Templates were file-based; the system tracked EMBASE.DBF exports (EMBASE_UPDATE :137021, EMBASE_DELETE :137131).
Mail/file library (MAIL_CONSOLE_CS :125537, MAIL_CONSOLE_MENU :85519): A separate staff tool to store templates, forms, and images for reuse in mailings. No API integration — staff downloaded, edited locally, and re-uploaded.
Proofing emails (PROOF_WEB_COPY :102948): A workflow to send web-copy text for editorial review before publication — an infrequent, manual process.
All notification triggers (new order, plan expiration) went through hardcoded email routines (SEND_PASSWORD :6098, SEND_FLYER :6351). There was no unified event log, no in-app notification, and no automatic custnote record.
13.3 Ported forward + enhancements
Event pipeline: The legacy approach of hardcoded per-action emails became AccountTransactionEvent (app/AccountEvents/AccountTransactionEvent.php:55-79, 20260701). A single dispatcher fires email + notification + custnote + transaction log for standing-order lifecycle events:
- Renewed (code 38) —
StandingOrderRenewed - Expiring (code 39) —
StandingOrderExpiring - Expired (code 40) —
StandingOrderExpired - Lagging (code 41) —
StandingOrderLagging - Scheduled cancel (code 42) —
ScheduledCancelNotification - Lagging expiring (code 43) —
StandingOrderLaggedExpiring - Contact note logged (code 46) —
ContactNoteLogged
Nightly expiration detection: Legacy staff manually reviewed accounts; the app now runs ScanPlanLifecycle daily at 09:00 ET (app/Console/Commands/ScanPlanLifecycle.php; detection lives in app/AccountEvents/PlanLifecycleEvaluator.php:29-46 → isExpiring()/pace()) to detect expiring and lagging plans (30-day dedup, QUANTITY×2-month pace). No user action required.
Health checks: The legacy app had no equivalent. The modern app adds CheckPlanHealthController — an on-demand action that surfaces from both the chat (PlanActionsWidget.vue, 20260702) and the account-rep dashboard (StandingOrderEdit.vue, 20260702). It checks whether a plan is healthy, expiring-under-budget, or actively lagging, and fires email + notification accordingly.
Choice/Trade email summaries: Legacy emails were rigid templates. The modern app computes dynamic summaries per plan type:
-
Choice: "3 of 8 titles selected — 5 still available on your budget" (
resources/views/emails/partials/titles_selected.blade.php:2) -
Trade: Budget, scheduled/backordered/shipped count, prepaid remaining (
resources/views/emails/partials/trade_summary.blade.php, built byPlanEventContext::tradeSummaryForEmail(), 20260702)
Auto custnote: Every notification writes a custnote automatically (app/AccountEvents/SideEffects/WriteEventCustnote.php, 20260701) with the event kind (SUBJECT = PLAN RENEWED, PLAN EXPIRING, etc.) — creating an audit trail without manual entry.
Manual contact notes: Staff can log a contact note (phone, email, in-person visit). The note now fires a partial event pipeline (98c4ab5b1, 20260701): a transactions row (code 46, TransactionDomain::ContactNote), an in-app notification to the note author only, and CALLNATURE tracking (data.call_nature from the note's existing field). Deliberately no customer email on contact-type notes (decision 20260701).
Brutus chat (ubiquitous tool dispatcher): A green-field replacement for the legacy console pattern. Intent-driven: instead of navigating to a "Contact Callback" page, staff click a button → the chat opens → the contact form renders inline. Intents map to canvas tools (AskBrutus.php constants, 20260701+). Two invocation surfaces:
All intent handlers return the same response shape (status, type, message, chrome.title). A unified tool-completed event ({intent, status, payload}) replaces per-tool events.
Documentation KB (future): A new tier in the answer pipeline (design locked, pre-implementation). Curated FAQ (regex/keyword) → KB grounded answer (chunk retrieval + optional LLM) → fallback (admin alert). The corpus is 67 markdown docs from storage/notes, all staff-audience today; chunk granularity is H2 sections. Data architecture: KB → Chunk (verbatim raw text + metadata + durable back-reference) → Index (disposable, BM25 ranking now, dense/embedding later). No LLM generation required for Level 0 (section-as-answer); enrichment (Level 1) and rephrasing (Level 2) are optional. Expected Q4 2026 start (currently pre-implementation).
13.4 Current state
Shipped (complete):
- ✅ AccountEvents pipeline (codes 38–46): email + notification + custnote + transaction. All 5 standing-order lifecycle events.
AccountTransactionEventdomain-generalized (enables non-plan events like contact notes). - ✅ Nightly plan-lifecycle scan (
ScanPlanLifecycle,PlanLifecycleEvaluator). - ✅ On-demand "Check Plan Health" action (health-only message vs fire expiring/lagging email).
- ✅ Dynamic Choice/Trade email summaries + trade summary block (20260702).
- ✅ Auto custnote per event + contact-note partial pipeline (logs transaction + notifies author only).
- ✅ Notifications sent from nstewart@centerpointlargeprint.com (
config/cp.php:203). - ✅ Brutus ubiquitous chat: intent dispatch, page-button + canvas-chip invocation,
useBrutusTriggercomposable, unifiedtool-completedevent. - ✅ Account health badge + contact-health flags (plan-health, contact-health, health-badge derivation, last-notified tracking). Derived; persistence deferred.
- ✅ 37 AccountEvents tests green; 264+ SO + contact pipeline tests green (20260702).
In progress / partial:
- ⏳ Account search + account-rep dashboard integration: surface derived health flags in search results + plan table. Scopes exist (
expiring choice,recently-expired choice,upcoming canceling, etc.,app/Models/StandingOrder.php:3155-3249); surfacing in UI = open todo §4. - ⏳ Batch renewal + opt-out + report: pattern exists (
bulkRescheduleinDashboardController.php:1633); batch email report on completion = open. - ⏳ Custnotes search UI: type filter (pre-select auto-note types), "my notes only" author filter, show account name in results, restore last-search context. Search/store pipeline exists; UI refinements = open todo §7.
- ⏳ How-can-we-help CTA block in expiring/lagging emails (generic action button exists; richer CTA = deferred).
Planned:
- 🔮 Knowledge Base grounded-answer tier (retrieval + optional LLM generation, Q4 2026+). Preprocessing locked; engine choice deferred. Chunk schema + BM25 ranker to follow.
PRD Page 2
13.5 Decisions & directional rulings
| Ruling | Date | Rationale |
|---|---|---|
| Email on every plan event — one event = one email + notification + custnote. No event batching. | 20260701 | Transparency + audit trail; each action is atomic and logged. Pace (qty×2mo) prevents notification fatigue. |
| Contact-type notes = log-only — no auto customer email. Manual contact is staff's action, not a customer-facing event. | 20260701 | Clarity: contact-note is internal documentation; email goes only to the note author (in-app). |
| Notifications from a shared account (nstewart@), not individual staff. | 20260701 | Single sender reduces support confusion; individual author tracked in transactions.by_email. |
| Check Plan Health is universal — applies to all plan types (Choice, Trade, Regular), not just Choice. | 20260702 | Parity: legacy had no equivalent; modern adds uniformly. Trade gets its own email summary block (not the Choice "titles selected" template). |
Account health derivation, not persistence (for now). Flags computed on-read from transactions rows (codes 38–46) + StandingOrder lifecycle scopes. |
20260701 | Faster iteration; permanentization to account_meta columns + batch classification = deferred pending flag-set stabilization. |
13.6 🔧 For builders
Core event pipeline:
-
Handler entry:
CheckPlanHealthController::check()(app/Http/Controllers/CheckPlanHealthController.php:31, routeweb_auth.php:715) — the on-demand health check; delegates toPlanLifecycleEvaluatorand fires the sameAccountTransactionOccurredevents as the daily scan. (There is noAskBrutus::checkPlanHealth().) -
Event dispatcher:
AccountTransactionEvent::dispatch(…)— fires once per event code (38–46); side effects auto-discovered via Laravel listeners. -
Side effects:
app/AccountEvents/SideEffects/—SendEventEmail,NotifyAccountOwner,WriteEventCustnote,LogEventTransaction. -
Test suite:
tests/Feature/AccountEvents/— domain-specific + pipeline integration tests (264+ assertions across SO + contact). -
Config:
config/cp.php→notifications_sender_email.
Plan lifecycle + health:
-
Detection:
ScanPlanLifecycle(command),PlanLifecycleEvaluator(service). Scopes:StandingOrder::expiringChoice(),::upcomingCanceling(), etc. (:3155–3249). -
Health check:
CheckPlanHealthController, invoked fromPlanActionsWidget.vue(chat canvas) +StandingOrderEdit.vue(page). -
Derived flags:
AccountHealthService::contactHealth()(RECALLD ±7-day due window),::planHealthBadge()(has_expired_choice, has_short_choice, etc.),::lastNotified()(MAX transaction created_at per code).
Brutus chat architecture:
-
Intent registry:
AskBrutus.phpconstants (INTENT_*) +config/chat.phpaliases +resources/js/composables/brutusIntents.js. -
Page-button invocation:
useBrutusTrigger({intent, mode, contextPage, onCompleted}).invoke(account)— composes → opens modal/dock → boots session → handler response → canvas render. -
Canvas chip invocation:
emit('drill', {intent, params})oremit('open-side', {intent, params})— instant, no session init (chat already open). -
Response handler:
ChatSurface.loadWidgetFromToolCode()readschrome.title+type+ widget-specific payload; dispatches toCanvasPanel.vuewidget selector. -
Completion: Widget emits
tool-completed({intent, status, payload})→useBrutusTriggersubscriber callback → custom cleanup (reload, redirect, etc.). -
Rendering modes:
modeprop onChatSurface:full(thread + canvas + composer) |canvas(canvas + composer) |canvas-bare(canvas only) |minimal(composer only) |deferred(lazy, sized-to-content). Seebrutus_inline_embed_modes.md.
Knowledge Base (future):
-
Chunk store:
doc_chunkstable (durablechunk_id,doc_path,anchor,heading_path,raw_text,audience,embeddingslot). -
Sparse index: Positional postings (
term → (chunk_id, tf, positions)) + corpus stats for BM25. - Tokenization: Lowercase/unicode-fold; preserve identifiers (RECALLD, ISBN, account KEY); Snowball stem + protected list; idf-soft stopwords; query-time synonym expansion.
-
Service:
KnowledgeBaseSearchinterface (swappable engine behind it). -
Command:
chat:eval-docs-search(hit@1 / hit@3 / MRR againstquestion → expected chunkfixture). -
UI integration: Upgrade existing
DocumentationWidgetsearch to the new chunk search (section cards w/ snippet + anchor link). - See
KNOWLEDGE_BASE_AND_LLM_INTEGRATION.md(design locked, pre-implementation).
References:
- Legacy email:
LEGACY_VFP_STANDING_ORDER_PIPELINE.md§ "Email notifications" - Parity matrix:
PARITY.mdrows "Notifications & Email" - Todo ledger:
todo.md§ "Notifications & email content — core SHIPPED" + "Manual staff contact-notes → partial pipeline" - Ubiquitous chat:
brutus_ubiquitous_chat.md(intent dispatch, invocation surfaces, primitives reference) - Embed modes:
brutus_inline_embed_modes.md(mode prop, sizing, height gotcha, composer laziness) - KB design:
KNOWLEDGE_BASE_AND_LLM_INTEGRATION.md(data architecture locked, retrieval-first, Levels 0–2 generation)
14. Reports & Analytics
14.1 Purpose
Reports and analytics provide operations and management teams with visibility into sales trends, inventory velocity, standing order health, account performance, and fulfillment metrics. These dashboards and exports allow staff to monitor business health, plan inventory acquisitions, track fulfillment, analyze marketing effectiveness, and manage standing orders.
14.2 Legacy behavior
The legacy VFP application offered a limited set of hardcoded reports accessible through menu systems. Key legacy reporting features included:
- Order/sales summaries by type
- Sales-by-plan-type breakdowns
- Inventory lifecycle overviews (title availability, new releases, clearance stock)
- Limited ad-hoc filtering (by account key, date range, or territory)
Inventory projection (GEN_ORDER_MENU_INVPROJECT :122921):
- ISBN quantities sold and sales projections
- Basic depletion estimates based on historical velocity
- Export to spreadsheet for planning meetings
Specialized reports:
- Bulk mailing-list generation (
BULKMAIL_MENU_*:98795–99713) - Standing Order sales ledger (
TD_VIEW_LEDGER_SALES:158435) - Promotion series text files (
CREATE_PROMOTIONS:108480) - Catalog and title-list spreadsheet exports (
PRINTCAT:98465,CREATE_OUTPUT_DATA:98544)
Reports were often run ad-hoc, output to files on disk, or printed directly. Caching and reusability were manual (recalculate from scratch each time or maintain cached spreadsheets).
14.3 Ported forward + enhancements
The modern application consolidates and significantly expands the legacy reporting capability through a unified Analytics Dashboard service architecture, with 24 fully implemented reports across eight categories.
Services — Reporting is organized into domain-specific services (app/Services/Reports/Analytics/):
-
SalesService— Annual/monthly comparisons, multi-year trends, all-time metrics -
SOPStatisticsService— Standing order vital signs, year-over-year analysis, 12+ month projections -
InventoryVelocityService— Sales velocity calculations for depletion forecasts -
InventoryDepletionService— "No new ISBNs" scenario modeling (5-year ISBN lifetime, urgency categorization) -
InventoryAcquisitionsService— Acquisitions pipeline and timeline tracking -
PromotionOverlayService— Marketing promotion tracking and performance -
RoyaltiesReportService— Annual billing and royalty status -
DataLensService— Unified API backend for analytics endpoints
All services support consistent filtering across reports: date range, account keys, inventory nature (CENTE/TRADE), and order type (daily/standing).
Report categories — Ported from legacy ad-hoc menus and expanded:
- Sales & Performance (5 reports) — Annual performance, monthly comparison, multi-year trends, sales-by-plan-type, executive dashboard ("Big Picture")
- Standing Order Plans (3 reports) — Vital signs, year-over-year, 12+ month projections
- Inventory (4 reports) — Lifecycle metrics (Not Yet Published → Promotional → Clearance → Expired), CENTE vs TRADE comparison, heavy stock analysis, historical snapshots
- Analytics Breakdowns (10 reports) — Performance by bestseller flag, territory, series, format, division, non-consignment returns, order type, campaign, marketing channel, sales presentations
- Account Analytics (7 reports) — Summary, age breakdown, type breakdown, plan type breakdown, purchase metrics, health indicators, activity tracking
- Projections (3 reports) — No New ISBNs scenario, depletion calendar, acquisition recommendations
- Acquisitions & Production (3 reports) — Acquisitions status, acquisition timeline, production timeline
- Royalties (2 reports) — Annual billing and royalties planning
Enhancements over legacy:
-
INVPROJECT parity achieved — ISBN quantities/sales projection (ported from
GEN_ORDER_MENU_INVPROJECT:122921) now implemented inapp/Services/Reports/Analytics/InventoryDepletionService.php; production deployment pending (ReportsController::STUB_TARGET_DATE~2026-07-15) -
Persistent caching — Reports are saved to
storage/app/private/reports/as JSON files; 24-hour indefinite cache prevents regeneration (unless explicitly bypassed with--no-cacheflag) -
Scheduled CLI execution — Long-running reports (20–30 minutes for 2-year datasets) no longer timeout on the web;
artisan experience:generate-report [--from=DATE --to=DATE]generates with progress tracking -
Unified service pattern — All report logic is encapsulated in
CompanyReportServiceand domain-specific analytics services, eliminating controller sprawl - Rich filtering — All reports support account key, date range, inventory nature, and order type filters (legacy supported ad-hoc filtering on a few reports only)
- Multi-format exports — Reports available as JSON (default, for web), with future CSV/Excel/PDF support planned
- No-new-ISBNs scenario — Forecasts what happens if no acquisitions occur (depletion timeline, urgency recommendations) — no legacy counterpart
Divergences from legacy (all approved):
- Mailing-list generation simplified ("pare-by-year" and "1-contact-per-account" advanced shaping = open todo, not yet ported)
- Catalog/spreadsheet exports replaced by
inventory lists + downloads— legacy.TXTper-series generation deprecated (confirm if consumers still exist) - Memo-field (FPT) writing still unimplemented on Rust API side (memo reads exist; writes stripped)
- Editorial cost-of-goods (per-title CGS) not located app-side; deferred pending accounting system clarification
14.4 Current state
Shipped:
- ✅ Analytics Dashboard (
/analytics) with 24 reports fully implemented - ✅ Report service architecture with persistence and caching (
CompanyReportService,app/Services/Reports/Analytics/) - ✅ Artisan command for scheduled/long-running report generation (
artisan experience:generate-report) - ✅ SOP statistics, year-over-year, and projection reports (
SOPStatisticsService) - ✅ Sales reports (annual, monthly, multi-year trends, by-plan-type) (
SalesService) - ✅ Inventory lifecycle, CENTE/TRADE comparison, and heavy-stock analysis
- ✅ Account analytics (summary, age/type/plan breakdowns, health, activity)
- ✅ Campaign performance and marketing channel attribution
- ✅ Acquisitions and production timeline reports
- ✅ Royalties reporting
- ✅ Depletion calendar and acquisition recommendations
In progress / Partial:
- 🧪 INVPROJECT (ISBN quantities/sales) — production stub — implemented and tested; production deployment deferred to ~2026-07-15 pending final ops sign-off (
ReportsController::STUB_TARGET_DATE) - 📋 Mailing-list generation — advanced shaping (pare-by-year, 1-contact-per-account) open (todo §3)
- 📋 Spreadsheet-designer upgrade — catalog/title-list exports use CSV; advanced filtering/formatting = open todo
- 📋 Acquisitions management — transitional phase: spreadsheet is source of truth (
ACQUISITIONS_SPREADSHEET_PATH=storage/app/acquisitions/inventory_acquisitions.csv); read-only website metrics viaInventoryAcquisitionsService(full CRUD will be enabled whenACQUISITIONS_TRANSITION_MODE=false)
Not yet ported:
- Editorial cost-of-goods analysis (per-title CGS unit vs reprint cost) — awaiting accounting system clarification
- Flier title spreadsheets with purchase-history checks (CHECKHIST/ISCHOICE parity unverified)
- SO-series title XLS transfer (CLASS_* mapping :105947–106065 in webnet.prg; parity unaudited)
- Web-copy proofing emails (legacy
PROOF_WEB_COPY:102948; decide port vs n-a)
14.5 Decisions & directional rulings
D12 — No New ISBNs Projection (5-year ISBN lifetime)
All depletion projections use a fixed 5-year ISBN lifetime from PUBDATE. Titles older than 5 years are considered "expired" and excluded from recommendations. This matches legacy's acquisition planning model and ensures forecasts remain realistic across the catalog.
D13 — Caching is indefinite unless explicitly bypassed
Reports are stored persistently in storage/app/private/reports/ and never expire automatically. This is a deliberate trade-off: accuracy is secondary to performance and predictability. Users who need fresh data must explicitly use --no-cache flag (which archives the old report with a timestamp and regenerates). This prevents accidental multiple-hour regenerations when a report is requested twice in the same session.
D14 — Acquisitions spreadsheet = source of truth (transitional)
During the transition period, inventory acquisitions data lives in a CSV spreadsheet (ACQUISITIONS_SPREADSHEET_PATH). The website provides read-only analytics over that file. CRUD operations are disabled (ACQUISITIONS_TRANSITION_MODE=true). When ready, ACQUISITIONS_TRANSITION_MODE=false will unlock full in-app management. This prevents dual-source data conflicts during the bridge period.
D15 — Report generation runs on CLI, not web
Long-running reports (20–30 minutes for 2-year datasets) are generated via artisan experience:generate-report, not the web interface. This prevents timeout errors and allows asynchronous scheduling. The web interface (/analytics/reports/company/download) retrieves cached reports or shows generation-in-progress status if a lock is active.
D16 — Report JSON format (future: CSV/Excel/PDF)
Current reports export as JSON (consumable by web UI and by programmatic API). Future support for CSV, Excel, and PDF formats is planned. The service layer (CompanyReportService::generateReport()) is format-agnostic; output format is a parameter.
14.6 🔧 For builders
Services and key methods:
-
app/Services/Reports/Analytics/DataLensService— Unified API backend for all analytics endpoints-
resolve(string $reportKey, array $filters)— Dispatch to domain-specific service based on report key - Returns report data ready for JSON response or export
-
-
app/Services/CompanyReportService— Persistent report orchestrator-
generateReport(string $dateFrom, string $dateTo, bool $useCache = true): array— Generate or retrieve cached report -
getReport(string $dateFrom, string $dateTo): ?array— Get cached report without generation -
listReports(): array— List all available cached reports - Stores reports in
storage/app/private/reports/(configurable disk viaconfig('filesystems.reports_disk'))
-
-
app/Services/Reports/Analytics/SalesService— Sales metrics-
getAnnualPerformance(),getMonthlyComparison(),getMultiYearMonthlyBreakdown(),getSalesByPlanType(),getBigPicture()
-
-
app/Services/Reports/Analytics/SOPStatisticsService— Standing order metrics-
getSOPStatistics(),getSOPYearOverYearComparison(),getSOPProjections() - Active SOP logic:
EDATE > today OR EDATE is null,SDATE is not null,CANCELDATE is null
-
-
app/Services/Reports/Analytics/InventoryVelocityService,InventoryDepletionService,InventoryAcquisitionsService— Inventory forecasting -
app/Services/Reports/Analytics/RoyaltiesReportService— Royalties reporting-
getRoyaltiesReport(),getRoyaltiesPlanning() - Prepaid calculation: 10% of list price
-
Controllers:
-
app/Http/Controllers/AnalyticsDashboardController— Web dashboard endpoints (/analytics)- Queries
DataLensServicefor report data - Returns Inertia-rendered
Dashboard.vue
- Queries
-
app/Http/Controllers/InventoryAcquisitionsController— Acquisitions management (/inventory/acquisitions)- CRUD operations conditional on
ACQUISITIONS_TRANSITION_MODEconfig
- CRUD operations conditional on
-
app/Http/Controllers/ExperienceController— Long-running company reports-
companyReport()— Display report summary -
download()— Retrieve or generate report; usesCompanyReportService
-
Routes:
-
GET /analytics— Analytics dashboard (dispatches to multiple reports) -
GET /analytics/{reportKey}— Single report endpoint (usesDataLensService) -
GET /analytics/snapshots— Historical inventory snapshots -
GET /analytics/projections/no-new-isbns— Depletion scenario analysis -
GET /inventory/acquisitions— Acquisitions management page -
GET /experience/reports/company— Company report summary -
GET /experience/reports/company/download— Download company report (with caching)
Artisan commands:
-
php artisan experience:generate-report [--from=DATE --to=DATE] [--no-cache]— Generate or retrieve company report- Default date range: last 2 years
-
--from/--to: Override date range (YYYY-MM-DD format) -
--no-cache: Force regeneration, archive old report with timestamp - Output: JSON file in
storage/app/private/reports/ - Duration: 20–30 minutes for 2-year datasets
Filter parameters (supported across all reports):
-
invnature— null | 'CENTE' | 'TRADE' -
date_range— [start, end] date strings -
account_keys— Array of account KEY values -
order_type— null | 'daily' | 'standing'
Caching behavior:
- All-time metrics: 1 hour (3600s)
- Current statistics: 15 minutes (900s)
- Projections: 30 minutes (1800s)
- Company reports: Indefinite (manual cache clear required)
Configuration:
-
config/filesystems.php— Definereports_disk(defaults to 'local', can be S3 or custom) -
.env REPORTS_DISK— Customize storage disk -
.env ACQUISITIONS_SPREADSHEET_PATH— Path to acquisitions CSV -
.env ACQUISITIONS_TRANSITION_MODE— true = read-only, false = full CRUD -
app/Http/Controllers/API/ReportsController.php—STUB_TARGET_DATEconstant at line 38 (INVPROJECT production deployment gate)
Development references:
- Source material:
storage/notes/ANALYTICS_REFERENCE.md,storage/notes/ANALYTICS_REPORTS_INDEX.md,storage/notes/REPORT-SERVICE-ARCHITECTURE.md - Legacy VFP legacy source: webnet.prg
GEN_ORDER_MENU:120589,MIS_ORDER_MENU:126571,GEN_ORDER_MENU_INVPROJECT:122921,BULKMAIL_MENU_*:98795–99713 - Parity tracking:
storage/notes/PARITY.md"Reports" section (rows 112–126) - Vue components:
resources/js/Pages/Analytics/Dashboard.vue,resources/js/components/Analytics/*.vue
Testing:
- Test INVPROJECT deployment gate:
ReportsController::STUB_TARGET_DATE - Cache behavior: Generate report twice to verify cache hit on second run
- CLI timeout handling: Run 2-year report via artisan (verify web timeout does not occur)
- Filter combinations: Verify all filters work independently and in combination
- Acquisitions transition: Test both
ACQUISITIONS_TRANSITION_MODE=true(read-only) andfalse(full CRUD)
15. EDI / ONIX / MARC — Metadata Ingestion & Distribution
Centerpoint receives, processes, and distributes bibliographic metadata through three overlapping channels: ONIX (book metadata from publishers), MARC (library-formatted catalogs), and EDI (order/fulfillment messages with trading partners). This chapter documents each pipeline, their current implementation status, and the architecture for builders extending them.
15.1 Purpose
The metadata problem: Centerpoint serves libraries with book orders, but the source data (titles, pricing, publication status, BISAC categories, contributor information, cover images) must flow in from publishers and be reformatted for multiple downstream audiences:
- Librarians searching Centerpoint's public catalog
- Staff reviewing inventory and pricing
- Library systems expecting standardized MARC records
- Trading partners (Baker & Taylor, wholesalers) expecting EDI messages or custom formats
The solution: Three independent pipelines transform vendor data into MySQL, then export in multiple formats on demand.
Scope of this chapter:
- ONIX ingestion (Books-in-Print / publisher feeds) ✅ full parity
- MARC record delivery (library catalog exports) ✅ full parity
- EDI messaging (B&T orders/fulfillment) ⏸️ deferred (generalize for future partners)
15.2 Background: Legacy Implementation
The legacy app (webnet.prg) supported three separate metadata paths, each hardcoded to a specific vendor or format.
ONIX:
Legacy feature GEN_ONIX_MENU (:115721 in webnet.prg, with GEN_ONIX_WRITE :116051) offered a manual UI to export the complete inventory as an ONIX 2.1 file (a date-range / catalog-nature / single-ISBN form → confirm-and-write to a network share, for fulfillment-partner consumption).
⚠️ The legacy menu implemented only this export path. The file-drop ingestion and per-ISBN message-view described in §15.3–15.4 are new-system capabilities (onix-cli +
OnixReconciliationService), not ports of legacy functionality — no ingestion trigger exists inwebnet.prg.
The legacy pipeline was synchronous, file-driven, and tightly bound to Ingram's ONIX 3.0 schema. No scheduling, no message queue, no re-processing — ingest happened once per upload.
MARC:
Legacy feature GENERAL_VIEW_ACCESS_MARC (:20272) provided catalog access through:
- An FTP domain (
marc.centerpointlargeprint.com) - Directory structure matching library codes (e.g.,
/ALI_COUNTY/ALI_COUNTY_2024.mrc) - Legacy staff manually built MARC files using a FoxPro record-to-MARC conversion routine, storing them on the FTP drive
EDI:
Legacy featured EDI_MENU (:139788) as a hardcoded console for Baker & Taylor (B&T) only:
- Inbound: EDI 850 (purchase orders), 856 (advance ship notice), 997 (functional acknowledgment)
- Outbound: EDI 810 (invoices), custom 997 responses
- Key ID: B&T's hardcoded account key
6095400000001(embedded throughout the legacy code)
B&T is now defunct (as of 2026); the EDI infrastructure remains but is deferred pending a broader "distribution-partner pipeline" design.
15.3 Key Concepts
Metadata formats:
- ONIX 3.0 — Industry-standard XML for book metadata. Encodes title, author, pricing, availability, classification (BISAC). Publishers push ONIX messages; Centerpoint injects into the catalog.
- MARC — Library-standard binary/text format for catalog records. Fields are numeric tags (020 = ISBN, 245 = title, 521 = target audience, etc.). Centerpoint reads legacy MARC from publishers; exports MARC to library systems.
- EDI (X12/EDIFACT) — Structured text-based message format for B2B transactions. Segments encode purchase orders, invoices, shipment notices. (EDIFACT = European variant; X12 = North American.)
Pipeline architecture: All three follow a similar pattern:
- Ingest — Raw vendor file arrives (ONIX XML, EDI text, MARC binary/MRK)
- Parse — Format-specific parser extracts structured data
-
Reconcile — Cross-reference with MySQL
inventorytable; resolve ISBNs, pricing, availability flags - Store — Intermediate results cached in a transient store (onix-cli SQLite, EDI file system)
- Export — On-demand output in target format (ONIX XML, MARC file, EDI message, or spreadsheet)
ONIX as source of truth: The ONIX ingestion pipeline is the primary path for new metadata. MARC is read-only input (from legacy data or publishers) but does not auto-update inventory. EDI is order/fulfillment messaging, not title metadata.
15.4 Current State in the App
ONIX Ingestion (✅ Full Parity)
Architecture:
- Standalone CLI tool (
onix-cliat../onix-cli/) handles heavy lifting: XML parsing, ISBN resolution, BISAC mapping, deduplication - Laravel
OnixService(app/Services/OnixService.php:13–74) wraps the CLI, calls it as a subprocess, and streams results back -
OnixReconciliationServicecompares inbound ISBNs against the MySQLinventorytable and flags missing or outdated titles
Routes & controllers (routes/onix_edi.php):
-
GET /onix— List all ingestion runs and their results (OnixController:index) -
GET /onix/{isbn}/pipeline— Show pipeline status for a specific ISBN -
GET /onix/{file_name}/download-file— Download raw XML or result spreadsheet -
POST /onix-receive/{message}— Webhook for inbound ONIX messages from Ingram -
DELETE /onix/{run_id}— Purge a run and its cached data
Scheduled sync (routes/console.php:306–331):
onix:sync --time-limit=540 # Friday 03:00 ET, 9-minute budget
onix:sync --time-limit=540 # Monday 03:00 ET, 9-minute budget
Full pipeline: OnixSync command (app/Console/Commands/OnixSync.php:13–31 class/signature; orchestration in handle() at :33–157) orchestrates:
- Ingest new ONIX files from the watch directory (
config('cp.TRADE_MESSAGES_PATH')) - Resolve ISBNs against inventory via
OnixReconciliationService - Cache results in
storage/onix-store.sqlite(managed by onix-cli) - Export reconciliation report as spreadsheet (CSV)
- Optionally email a daily status report (
OnixDailyReportmail class)
Options (command flags):
-
--dry-run— Show what would be done, no mutations -
--limit=N— Process only the first N ISBNs -
--fresh— Delete the pipeline store and re-ingest from scratch -
--reprocess— Re-resolve all cached ISBNs against current inventory -
--time-limit=S— Time budget in seconds (defer remainder to next run) -
--status— Show pipeline store status and exit -
--history=ISBN— Show message history for an ISBN and exit
Known limitation:
The legacy OnixProduct class (app/Onix/OnixProduct.php:1–12) is marked @deprecated. It was an earlier attempt to parse ONIX in-app; the onix-cli tool supersedes it. Do not use for new work.
MARC Record Delivery (✅ Full Parity)
Architecture:
-
MarcFtpController(app/Http/Controllers/MarcFtpController.php:17–54) gates access to legacy MARC files stored on a network FTP drive -
MarcParser(app/Marc/MarcParser.php:15–99) parses binary.mrcor text.mrkfiles intoMarcRecordvalue objects -
MarcComposer(app/Services/MarcComposer.php) converts between ISO 2709 (binary) and MRK (text) formats -
MarcValidator(app/Marc/Validation/MarcValidator.php) enforces field length and structure constraints
Routes & access:
- Virtual domain:
marc.centerpointlargeprint.com(routes/web.php:28) -
GET /marc— List available library directories -
GET /marc/{library}/{file}— Download a MARC file by library code and filename - Staff console:
GET /marc-records(web_auth.php:447) — Browse MARC records, trigger export - Staff action:
POST /marc-action/{action}(web_auth.php:760) — Mark record status, queue for export
File locations:
Legacy FTP path: \\Webnet\ftp\From Cecilia\Processing orders - Direct to libraries\{LIBRARY_CODE}
Example: \\Webnet\ftp\From Cecilia\Processing orders - Direct to libraries\ALI_COUNTY\ALI_COUNTY_2024.mrc
Centerpoint staff do not auto-generate MARC records. The system exposes pre-built MARC files from the legacy FTP drive and provides a query interface. If libraries require fresh MARC exports (e.g., from a custom title list or standing order), the export path is via the BuildList flyer export (app/Http/Controllers/BuildListController.php) or a custom query tool.
Validation:
MarcValidator enforces field 020 (ISBN) length constraints; other rules can be added as needed. Violations are logged but do not block ingestion or delivery.
EDI Messaging (⏸️ Deferred / B&T Legacy)
Status: Deferred pending generalization for future distribution partners.
Current state:
-
EdiController(app/Http/Controllers/EdiController.php:15–60) provides a legacy message browser -
Edihelper class (app/Helpers/Edi.php) encodes/decodes EDI segments -
BaseMessage(app/Edi/BaseMessage.php) is the base class for EDI message types - Routes (routes/onix_edi.php:32–45):
GET /edi,POST /edi-send, etc. - FTP storage:
Storage::disk('edi')(config/filesystems.php) maps to a local EDI message directory
Hardcoded B&T context:
Baker & Taylor's account key 6095400000001 is embedded throughout the legacy code (webnet.prg:42875, :149330, etc.). The app mirrors this hard-coding in the EDI controller's FTP credential store.
Decision (approved 20260703): The B&T EDI pipeline is deferred and will be generalized into a broader "distribution-partner pipeline" design. This will abstract:
- Vendor account key & credential management
- Inbound/outbound message type routing
- Per-partner schedule and transformation rules
- Mutual exclusion with other order paths (no redundant EDI + in-app order for the same ISBN)
Until then: EDI functionality stays read-only, and B&T orders are routed through the legacy system or manually entered into Centerpoint.
15.5 Architecture & Design
ONIX Pipeline Layers
Publisher ONIX feed (XML)
↓
[watch directory]
↓
onix-cli tool (standalone Rust/Node binary)
- XML parsing
- ISBN resolution (Ingram catalog)
- BISAC classification
- Deduplication
↓
[SQLite message store: onix-store.sqlite]
↓
OnixReconciliationService (Laravel)
- Cross-ref vs inventory MySQL
- Pricing/availability reconcile
- Flag missing or outdated titles
↓
[Result spreadsheet + cache]
↓
Export paths:
• CSV (staff reporting)
• ONIX XML (fulfill partner consumption)
• MySQL inventory update (future phase)
Key design rules:
-
Subprocess isolation: onix-cli runs as a separate process; failure does not crash Laravel.
OnixService::centerpointUpdate()streams results withProcess::forever()(no timeout). - Deferred reconciliation: CLI outputs raw ONIX data; reconciliation happens in Laravel where we can query MySQL. This keeps onix-cli stateless and portable.
-
Time budgeting:
--time-limit=Sallows long-running ingests to be split across multiple runs without re-parsing already-processed messages. -
Reprocessing:
--reprocessre-resolves all cached ISBNs against current inventory, useful after inventory schema changes or pricing updates.
MARC Access Architecture
Library staff request MARC file
↓
MarcFtpController routes request to network FTP
↓
[FTP shared folder: \\Webnet\ftp\...\{LIBRARY}]
↓
MarcParser detects format (.mrc binary vs .mrk text)
↓
MarcComposer converts binary→MRK or MRK→binary
↓
[MarcRecord value objects in memory]
↓
MarcValidator checks field constraints (optional)
↓
Serve file to client (download or render in UI)
Design decisions:
- Read-only: Centerpoint does NOT auto-generate MARC from inventory. Legacy MARC files are the source; the app exposes them.
- Lazy validation: MARC validation is optional per request; errors are logged but do not block delivery (libraries may accept malformed MARC and fix it client-side).
-
FTP network path: On Windows, SMB paths (
\\Webnet\ftp\...) are accessible directly from PHP; no separate FTP client needed.
EDI (Future: Generalized Distribution-Partner Pipeline)
Planned design (not yet implemented):
Vendor account config (encrypted)
↓
[accounts → vendor relationship table]
↓
DistributionPartnerService
- Credential lookup
- Schedule management
- Mutual exclusion (no dual-order)
↓
Inbound: {Vendor}MessageService
- EDI 850 (purchase orders)
- EDI 856 (advance ship)
- Custom vendor formats
↓
[Message queue: jobs.messages_pending]
↓
Route to order pipeline OR skip if conflict
↓
Outbound: {Vendor}ExportService
- EDI 810 (invoices)
- Shipment notices
- Custom manifests
Decoupling benefits:
- No hardcoded vendor IDs or credentials in code
- Easy to onboard new partners (add config row, swap service class)
- Clear error handling per partner
- Audit trail of all vendor interactions
15.6 🔧 For Builders
Adding ONIX Support for a New Publisher
-
Update the watch directory path:
- Check
config('cp.TRADE_MESSAGES_PATH')inconfig/centerpoint.php - Coordinate with ops: where will the publisher's ONIX files land?
- Check
-
Extend
OnixReconciliationService:- Add publisher-specific field mappings if needed (e.g., custom BISAC override logic)
- Test with sample ONIX file using
php artisan onix:sync --dry-run --limit=10
-
Add a scheduled sync if needed:
- Example:
routes/console.php:306–331shows Friday/Monday schedules - Use
Schedule::command('onix:sync --time-limit=540')->fridays()->at('03:00') - Set a time limit (
--time-limit) if the publisher sends many files
- Example:
-
Test reconciliation:
php artisan onix:sync --dry-run --status php artisan onix:sync --history=9780743273565 # Check one ISBN php artisan onix:sync --limit=100 # Process first 100 -
Monitor & alert:
- Add publisher email to
--notify=so staff get daily reports - Set up Sentry monitoring for process failures (timeout, OOM, parsing errors)
- Add publisher email to
Exporting ONIX from Inventory
The OnixExport action (app/Actions/OnixExport.php) generates ONIX XML from the MySQL inventory table. Status: Deferred pending builder review of schema coverage.
To export a title subset:
$action = new OnixExport();
$onix_xml = $action->forIsbn('9780743273565'); // Returns ONIX 3.0 XML string
Delivering MARC to Libraries
-
Verify FTP path access:
- Test SMB connectivity:
ls \\Webnet\ftp\From Cecilia\Processing orders\ - Confirm library folders exist (e.g.,
ALI_COUNTY,WAYNE_COUNTY)
- Test SMB connectivity:
-
Trigger export at MarcFtpController:
- Route:
GET /marc/{library}/{file}automatically downloads the file - For batch downloads: use
POST /marc-action/zipto create a batch archive (if implemented)
- Route:
-
Parse a MARC file in code:
$parser = new \App\Marc\MarcParser(); $records = $parser->parseFile('path/to/file.mrc'); foreach ($records as $record) { $isbn = $record->getField('020')->subfield('a'); // field 020 = ISBN $title = $record->getField('245')->subfield('a'); // field 245 = title } -
Validate MARC:
$validator = new \App\Marc\Validation\MarcValidator(); $violations = $validator->validate($record); if (count($violations) > 0) { Log::warning('MARC validation failed', ['record' => $record, 'violations' => $violations]); }
Preparing for EDI Generalization
When ready to onboard a new distribution partner (beyond B&T):
-
Audit the EDI folder structure:
- Identify which message types the partner sends/receives
- Get a sample file to parse
- Document credential/FTP location
-
Create a partner-specific service:
namespace App\Services\Edi\Partners; class {PartnerName}MessageService { public function parseInbound(string $raw): array { ... } public function formatOutbound(Order $order): string { ... } } -
Register in a partner factory:
class DistributionPartnerFactory { public static function for(string $accountKey): PartnerService { ... } } -
Add configuration row:
- Table:
distribution_partners(new) - Columns:
id,name,account_key,credential_encrypted,supported_message_types,schedule_expression
- Table:
-
Test with sample messages:
php artisan edi:process {partner} --file=sample_850.txt --dry-run
Code Anchors (Cross-Reference)
| Feature | File | Line |
|---|---|---|
| ONIX pipeline entry | app/Services/OnixService.php | 39–74 |
| ONIX sync command | app/Console/Commands/OnixSync.php | 13–31 |
| ONIX scheduled runs | routes/console.php | 306–331 |
| MARC FTP controller | app/Http/Controllers/MarcFtpController.php | 17–54 |
| MARC parser | app/Marc/MarcParser.php | 15–99 |
| MARC parser format detection | app/Marc/MarcParser.php | 29–48 |
| EDI controller | app/Http/Controllers/EdiController.php | 15–60 |
| ONIX routes | routes/onix_edi.php | 14–43 |
| MARC routes | routes/static_files.php | 24, 42–43 |
| MARC domain | routes/web.php | 28 |
| Legacy ONIX menu | webnet.prg | 115725 |
| Legacy MARC access | webnet.prg | 20272 |
| Legacy EDI (B&T) | webnet.prg | 139788 |
16. Time Clock, Tickets & Internal Ops
16.1 Purpose
This domain covers three operational staff tools: (1) Interactive Time Clock — staff members punch in/out, with a single status-change endpoint; (2) Support Ticket System — customer-facing issue tracking; (3) Internal Operations Dashboards — aggregated account management and operational views.
16.2 Legacy Behavior
This domain is entirely green-field — the legacy Visual FoxPro application (webnet.prg, lines 1–161252) had no unified time-clock system or formal ticketing subsystem.
The legacy system did have a workaround: procedures SERVICE_HELP (line 4447) and SERVICE_REQUESTS (line 151553) attempted to piggyback service requests onto the CUSTNOTE infrastructure, but this was not a designed system and not used operationally. The legacy application provided no interactive status tracking or draft timesheet projection.
There is no PARITY row for time clock or a modern equivalent of SERVICE_HELP/SERVICE_REQUESTS, because the modern systems diverge by design from non-systems into purpose-built infrastructure.
16.3 Ported Forward + Enhancements
Since these systems are green-field, the story is in-app modernization: three fragmented time-tracking approaches (legacy TimeCard model, chat status endpoints, manual timesheet entry) were unified in June 2026 into one cohesive status-driven state machine (shipped commit aa121654b–82aa72aa8, Jun 15).
Time Clock unification:
-
Before: TimeCard model (never used in production), separate clock endpoints per action (legacy routes
/clock/{in,out,lunch,break}), manual timesheet entry without state projection. -
Now: Single
setStatus()endpoint (routes/users.php:82-83) that accepts any legal status slug; theTimeClockStateread model projects the status stream into valid next-state actions + draft timesheet. One state machine (UserStatus enum + StatusEffect transition rules in config/time_clock.php) is the source of truth. The TimeCard model was retired (~2177 lines of dead code removed).
Tickets enhancement: The custom ticketing system (TicketController, routes/tickets.php:9–21) supersedes the non-system legacy workaround. It adds: public customer links (UUID tokens per Ticket::public_token), threaded message history, status-change audit trails (TicketStatusHistory), and image attachment processing with recompression (ImageProcessor service).
16.4 Current State
Shipped (Jun 15):
- Interactive Time Clock: full state machine, widget (resources/js/components/TimeClockWidget.vue), endpoints (GET/POST /u/clock, /u/clock/set), nightly draft sync guard (timeclock:sync-drafts command, 23:45 ET).
- Support Tickets: create, display, message threads, attachment upload/display, status workflow (open/in_progress/feedback_requested/complete/closed), assignee management, public customer view.
- Test coverage: 40 green integration tests in tests/Feature/TimeClock/ (mapping, projector state matrix, lunch-split & forgot-clock-out edge cases, draft sync, write gates).
Planned / Out of scope:
- Scheduled reminders (integration with native Laravel scheduler, not AI agents); deferred pending business rules clarification.
- Time approval/manager sign-off workflows; currently draft-only.
- Integration of tickets into the unified chat (currently separate Blade surface).
16.5 Decisions & Directional Rulings
D-TC-1: Single write endpoint. All status transitions (clock in, clock out, on lunch, etc.) go through one setStatus() endpoint (TimeClockController::setStatus, routes/users.php:83). The controller does NOT hardcode per-action logic; instead, the transition guard in User::updateStatus() decides legality from the current clock state. This reduces surface area and makes the state machine self-evident in config/time_clock.php.
D-TC-2: Status events are canonical. UserStatusChanged events (fired from the controller) trigger the TimeClockState read model + TimesheetDraftSync listener. The worker-friendly side effect (automatic draft entry for clocked-in intervals) never reaches back to corrupt the event stream.
D-TC-3: Tickets diverge from legacy CUSTNOTE. Rather than extend the customer-note infrastructure to handle service requests, tickets are a standalone domain with separate models, permissions (TicketPolicy), and Blade views. This allows future enhancements (SLA tracking, knowledge-base linking) without entanglement.
D-TC-4: Draft timesheet sync is a safety net, not the system. Real-time updates come from the ProjectStatusToTimesheet listener (AppServiceProvider::boot, registered via Event::listen). The nightly timeclock:sync-drafts command (console.php:99–106) caps "forgot to clock out" at end-of-day and runs at 23:45 ET to handle edge cases.
16.6 🔧 For Builders
Time Clock:
-
Read model:
App\TimeClock\TimeClockState(unified clock payload + valid next-state actions).App\TimeClock\WorkedTimeProjector(state machine: out → working → paused → out).App\TimeClock\UserStatusenum +StatusEffect(transition rules). -
Write:
App\Models\User::updateStatus()(guarded by the UserStatus transition matrix).App\Events\UserStatusChanged(triggers TimesheetDraftSync). -
Config:
config/time_clock.phpholds only operator-tunable numbers (timezone,pay_anchor= biweekly pay-period start,paid_break_minutes_per_day= the lunch/break budget). The status map + clock effects live in theApp\TimeClock\UserStatusenum (clockEffect(),app/TimeClock/UserStatus.php:138) +StatusEffect, not the config. Routes:routes/users.php:82–83. -
Integration: Status is stored in
users.status_id(nullable, foreign key to a status_id enum or a string enum depending on migration). Checkmigrations/*_create_users_table.phpanddatabase/migrations/*_add_status_to_users.phpfor schema. -
Widget:
resources/js/components/TimeClockWidget.vue+resources/js/components/Chat/TimeClockWidget.vue(embedded chat variant). Self-loads viashow()endpoint, live ticker with refesh on status change. -
Tests:
tests/Feature/TimeClock/*— state matrix coverage, lunch-split, overnight intervals, forgot-clock-out cap, draft sync, endpoint gates. -
Nightly command:
timeclock:sync-drafts(console.php:105–106, scheduled 23:45 ET).
Tickets:
-
Models:
App\Models\Ticket(subject, status, priority, requester_email, public_token UUID).TicketMessage(threaded replies).TicketAttachment(stored on disk 'public', with mime + size).TicketStatusHistory(audit trail). -
Routes:
routes/tickets.php:9–21(staff-only view/create/show/message). Public guest route:routes/tickets.php:22–24(TicketPublicController, token-gated). -
Controller:
App\Http\Controllers\TicketController(index with filters: q, status, priority, assignee_id, mine, updated_from/to, sort). Status lifecycle handled byTicket::markStatus()(fires TicketStatusHistory). -
Attachment handling:
App\Services\ImageProcessor(downscales + recompresses images on upload). Non-image files stored raw. Base64 paste support (TicketController::store lines 181–205). -
Notifications:
App\Notifications\TicketCreatedPublicLink,TicketReplyAdded,TicketStatusUpdatedsent to requester_email. - Access: Gated by TicketPolicy (viewAny/updateAssignee/updateStatus permissions). Customers see only tickets they created via the public token route.
Internal Ops:
-
Dashboard:
App\Http\Controllers\DashboardController(routes/users.php:51–57). Widget pinning: PUT/dashboard/widgets(routes/web_auth.php:745–746, DashboardWidgetController::update). -
Customer Service Dashboard:
App\Services\CustomerServiceDashboardService+ ~14-panel widget mounted atapi.php:57under the DynamicDashboard infra. See staff/docs/memory/ACCOUNT_REP_DASHBOARD_GUIDE.md for usage. - Memory: project_unified_time_clock.md (status-driven design), project_manual_order_backstop.md (TimeClockState integration for chat plan actions).
17. Platform — Auth, Permissions, DBF↔MySQL Bridge, Jobs
17.1 Purpose
Centerpoint runs on a hybrid infrastructure: a modern web application reading from MySQL for speed and reporting, while the legacy DBF files remain the system of record. The Platform layer is the invisible backbone that keeps these systems synchronized, ensures users can authenticate and use the app reliably, and logs every action for audit and operational monitoring.
17.2 Authentication & Permissions
The User Model is the entry point. The User class at app/Models/User.php implements Laravel's Authenticatable contract directly (not via the trait, to avoid naming conflicts with the password() relationship). When a user logs in via the login form, Laravel validates their credentials against the Password model's DBF-mirror data and creates a session.
The Password Model (app/Models/Password.php) is not a pure data store — it implements AuthorizableContract, uses the Authorizable + HasApiTokens traits, and carries auth/authorization logic (getAuthPassword(), a custom createToken(), createCredentialsFromPasswordsTable()) plus authz-gated CRUD and cart helpers. It mirrors the DBF passwords table and holds UPASS (the encrypted password hash), UNAME (username), and EMAIL. On each backend request, HandleInertiaRequests middleware calls $request->user()->viewProps() and $request->user()->currentAccountInfo() to populate the inertia props the frontend uses.
Sessions survive password table rebuilds. The nightly RebuildUsers job rebuilds the MySQL passwords table from the DBF at 02:15 (app/Actions/RebuildApplicationTables.php line 200–220), but the session cookie remains valid. This is because the session store (Redis or file-based) is independent of the password table — the session just confirms the user is logged in; the password table is consulted only on new login.
Permissions are managed via a HasPermissionsTrait on the User model (app/Models/Traits/HasPermissionsTrait.php). The trait provides methods like canNow($permission) that check against policies defined in app/Policies/. A user's role (users.role_id) determines which policies allow or deny access. Feature-level checks typically run in controller middleware or policy methods; data mutation checks run in policy update() and delete() methods.
Configuration lives at config/auth.php. The custom password-provider driver's model is App\Models\User::class (config/auth.php:69), not Password; the web guard uses session-based authentication. No changes to this config are needed for most use cases — it's set once and rarely touched.
17.3 The DBF ↔ MySQL Bridge: Three Paths, One Schema
Centerpoint has three code paths that read/write DBF files, each with different mechanics but the same table schemas:
-
LEGACY path — Direct
fopenin PHP (app/Models/Dbf/Table.php). Used by the legacy VFP app and fallback code paths. -
dbf-service path — 32-bit Lumen server running VFPOLEDB on :8080 (
staff/dbf-service/config atconfig/dbf.php). Used when the proxy is healthy. -
RUST path — Rust API on :3636 (
centerpoint_apirepository). Not production-ready; reserved for future migration.
Name the path before hypothesizing. A data inconsistency that looks like a sync bug might be a legacy-path artifact, a timeout in dbf-service, or a Rust-side issue. Knowing which path is in play cuts debugging time in half. Check logs for DBF sync handler: or grep the request context.
INDEX is the link. In MySQL, every record that comes from the DBF has an INDEX column matching the DBF record number (RECNO). When a new Laravel model syncs to DBF, INDEX starts as NULL; after the sync, it's set to the DBF record slot. This link is sacred — never null it out unless you're about to resync. Cascade keys vary by table:
-
webheadsandwebdetails: keyed onREMOTEADDR(the IP or session ID) -
backheads,broheads,tradeheads: keyed onTRANSNO(the order number) -
standing_orders(the DBF table name isstanding.DBF): keyed onid(the Laravel primary key)
MySQL is readable; DBF is the source of truth. Writes always flow MySQL → DBF via $model->dbf()->save(). The daily rebuild at 02:15 (RebuildApplicationTables::daily_tables() in app/Actions/RebuildApplicationTables.php lines 240–290) tears down and rebuilds web*, back*, bro*, all* tables from their DBF counterparts. A plain Laravel migration on these tables will not survive the rebuild — the DBF and the rebuild job must both carry the column change.
17.4 DBF Integrity & Health Checking
Corruption is detected and healed automatically. The system has three defense layers:
1. parity:fix command (php artisan carts:fix). Runs on-demand or scheduled. It performs a full DBF↔MySQL parity check in two stages:
-
Step 1: Find MySQL rows sharing the same INDEX. Read the DBF slot to determine the real owner (match on KEY + REMOTEADDR). Losers get
INDEX=NULLand resync to a fresh slot. -
Step 2: Loop all DBF records. Check if MySQL claims each. For orphaned records: recover (create MySQL row from DBF), soft-delete stale ones, or link unlinked slots to MySQL rows with
NULL INDEX.
Healing logic at app/Console/Commands/CartsFix.php (class CartsFix) around lines 116–224.
2. data:check command (php artisan data:check). Broader weekly health check with named checks:
-
users— UNAME mismatches, missing EMAIL -
remoteaddrs— Duplicate REMOTEADDRs in MySQL and DBF -
carts— Cart integrity + stale user REMOTEADDRs -
booktext— Book text table coverage
With --fix, it cascades to carts:fix for duplicates and dispatches SyncInventoryText job for missing rows. Scheduled weekly Sunday 8am with --fix (at routes/console.php line 180).
3. PAID flag compatibility (a subtle gotcha). The allheads table stores PAID as '0' or '1', not 'T' or 'F' like VFP booleans. Use Misc::isTruthy($value) in PHP or isTruthy(value) in Vue (Helpers/Strings.js) to normalize. Both accept 'T', '1', 'TRUE', 'Y' — a guard against future copy-paste bugs (used in 15 locations: AlphaCalculateInvoiceService, OrderTrait, Allhead, etc.).
17.5 Jobs & Transaction Logging
Jobs run on Laravel's queue. The scheduler in routes/console.php defines 23 instrumented jobs (e.g., RebuildApplicationTables::daily_tables at line 60, BuildCatalogList::build at line 100). Each scheduled job wraps its Schedule::* chain with an $instrument() closure that emits started/completed/failed transaction rows on before/onSuccess/onFailure hooks — no changes to the command class needed.
TransactionLogger is the primary gate (app/Support/TransactionLogger.php). Most transactions rows flow through TransactionLogger::record() (config-driven gating, fail-soft). (Not strictly universal — a few production call sites still write via Transaction::create() directly, bypassing the gate: app/Listeners/CreateOrderTransaction.php, app/Domain/Orders/History/OrderHistoryService.php:227, and the BulkAddressUpdate services.) Client code calls TransactionLogger::record(int $code, array $data, ...) and the logger decides what to keep based on config/transactions.php gates:
-
gates.enabled— global kill switch -
gates.codes_disabled— drop specific transaction codes -
gates.jobs_disabled— fnmatch globs (e.g.,'noisy-*') -
gates.max_data_bytes— truncate or drop oversize payloads (default 64 KB) -
ignore_blacklist— legacy override to always write
Never edit call sites to silence a noisy transaction — flip a config gate instead. All methods fail-soft: exceptions log a warning and return null so the caller never blocks.
Schedule-level lifecycle uses helper methods:
TransactionLogger::started('job-name', $data);
TransactionLogger::completed('job-name', $data);
TransactionLogger::failed('job-name', $error, $data);
TransactionLogger::bracket('job-name', $work, $startData); // auto-bracket a callable
Pulse monitoring integrates via the "Jobs" widget. It reads codes 29 (started), 30 (completed), 31 (failed), 32 (warned) and renders them with ⚙ (normal) or ⚠ (failed/warned) icons. Commits: 5c3ebd263 (refactor), 33e30878a (instrument 23 jobs).
17.6 🔧 For Builders
Auth gotchas:
-
User->password()is ahasOnerelationship, not the authentication method -
$request->user()returns aUserinstance with role and permissions loaded - Sessions survive
RebuildUsers— test login across a rebuilds to verify this works in your environment
DBF↔MySQL gotchas:
- Always check which of the three paths is active before blaming data inconsistency
-
INDEX = nullmeans "MySQL-only, not yet synced"; never create it manually - The daily rebuild at 02:15 wipes
web*,back*,bro*,all*— migrations on these tables must also land inRebuildApplicationTables - VFP reserved words (
WHERE,REPLACE, etc.) must be aliased in VFPOLEDB queries; never bracket them
Health & repair:
-
php artisan carts:fix --dry-runpreviews what will be fixed - Healing logic assumes INDEX is the primary join — never change the cascade-key strategy without re-auditing all three paths
-
data:checkruns hourly (weekdays 10am–5pm, report only) and Sunday 8am (with--fix)
Job instrumentation:
- Add new scheduled jobs via
$instrument(...)wrapper inroutes/console.php - Transaction logging is fire-and-forget; never await confirmation
- Config gates let you dial verbosity per environment — use
TRANSACTIONS_GATE_ENABLED=falsein CI/testing to keep logs clean - The "Jobs" Pulse tile reads the last 100 rows of codes 29–32; older lifecycle rows are kept indefinitely for audit
Testing:
-
staff_testingdatabase is owned byphp artisan test:init; never DDL inside a test (implicit commit breaks rollback) - Don't parallelize test execution (shared MySQL); parallel read-only diagnosis is fine
-
test:init --freshruns a child process — guard against wrong DB name withif (app()->isProduction()) throw new Error('...')
For code examples and deep dives: see auth_architecture.md, dbf_integrity_system.md, transaction_logger.md, domain_seams.md § "DBF ↔ MySQL" and "Testing".
Part III — Roadmap & Direction
18. Confirmed Directional Rulings
The decisions below represent the team's locked consensus on how Centerpoint works, why it works that way, and what trade-offs were made to get here. Each has been implemented, tested, and approved. Changes to these rulings require team discussion and explicit consensus.
Orders & Carts
✅ D1 — No pull-time repricing. Once a cart is submitted, prices are locked. No repricing happens when the order enters fulfillment. Rationale: The legacy system had post-submit price adjustments as a workaround for missing cart-time pricing logic. That gap is now closed; repricing at pull time creates fraud risk. (20260702)
✅ D2 — Submitted carts stay in webhead until pull. WEBHEAD records are marked SUBMITTED (MARKCART=5555) and copied to alpha_webheads history, not deleted. Separate HEADING records are created for fulfillment. Rationale: Single source of truth; clean separation of shopping vs. fulfillment state. (20260702)
✅ D3 — Backorder conversion creates new TRANSNO. When a backorder becomes available and converts to an active order, it receives a new TRANSNO, not a reused ID. Rationale: Legacy MANAGE_SELECT_BACKORDERS creates new HEADING records; app mirrors this for clear audit trail. (20260702)
✅ D4 — Archival is a sweep, not inline. Completed orders move to archive tables via nightly batch (orders:archive-sweep), respecting legacy ARCHIVE_ORDERS gate. Rationale: Batch operations are faster and less prone to race conditions; offline processing avoids customer-facing latency. (20260702)
✅ D6 — Free shipping: locked at cart, recalculated at pull. The seven free-shipping conditions are evaluated when the customer adds items to cart and again when they submit. Staff can override during master edit in exceptional cases. Rationale: Customers see accurate costs in cart summary; edge cases are rare enough for staff override. Certified parity with legacy ladder 20260703. (20260703, c320cd358)
Standing Orders & Plans
✅ D1 — Parity is non-negotiable. The legacy VFP system is still live production. All ports are parity-or-better, never silent regressions. Every claim in PARITY.md is anchored to legacy code, app code, and certification commit. (20260702)
✅ D2 — SORTORDER is a contract with Pitney Bowes. The legacy grouping hash (NNXX codes) is not a display label — it's the key Pitney Bowes uses to batch orders for weigh-in and label generation. The app must build and materialize it identically to legacy. (2026-06-18, parity matrix)
✅ D3 — FINALINV is availability, not ONHAND. Legacy defines ONHAND = FINALINV − ALLSALES. Fulfillment judges availability against FINALINV (gross inventory), not real on-hand stock. This allows standing orders to be promised against titles publishing this month. (parity matrix, D9 Inventory)
✅ D4 — Staged inventory, not production mutation. Queue build seeds a separate queue_inventories working copy. Production inventories are untouched until Ship. Rationale: Preserves ability to re-run, inspect, and audit the queue without altering shelf facts. Improvement on legacy. (20260702)
✅ D5 — Operator owns routing; system informs. Fulfillment UI flags all concerns (oversized groups, address mismatches, unfulfilled titles) and seeds sensible defaults. The operator decides what ships, where, and when — never automatic rerouting. (20260702)
✅ D6 — Contiguous TRANSNO assignment. TRANSNOs must be contiguous in SORTORDER order so that third-party consumers reading the DBF see grouped orders with adjacent numbers. (parity matrix, legacy renumber pattern)
Trade
✅ D-T-1 — Trade as separate run, collision-free reset. Trade and regular fulfillment use shared queue tables but with INVNATURE scope-tagging. Each run type has distinct ActionRun. Reset operations are scope-aware. Rationale: Neither run disturbs the other's staged rows even if both are in flight. (20260629, user decision)
✅ D-T-2 — Trade selection = cumulative-by-level auto-load. Each Trade Level plan is cumulative: Level 1 (CLASS_H) sources 2 titles/month; Level 2 (CLASS_H + CLASS_I) sources 4 titles/month; Level 3 (CLASS_H + CLASS_I + CLASS_J) sources 6 titles/month. No manual per-title curation in batch path. (parity matrix, D-T-2)
✅ D-T-5 — SORTORDER is added to trade (intentional divergence-as-improvement). Although legacy trade had no SORTORDER or batch routing, the modern trade path assigns SORTORDER via content-hash and joins the same PB-lane routing logic as regular standing orders. Trade heads benefit from grouped-shipment optimization without requiring new sort codes. (2026-06-30, user decision)
✅ D-T-6 — Queue full set, dedup on move, operator decides. Trade preloads titles onto back orders for vendor procurement sizing, so the queue must show the full plan demand without purchasing/back-order gates. The dedup + filter happen on move to production via the ship-stage toggle dedup_open_backorder (default ON). (2026-06-30, user decision)
✅ D-T-8 — Trade move = direct-to-production preorder, tentative pricing. Trade lines skip brokered queues entirely and materialize directly to backhead/backdetail (OSOURCE='TRADE STANDING'). Pricing at queue time is tentative; the final price is recalculated out-of-band at ship time. Rationale: Production brodetail→invoice billing is not replicated in fulfillment system; app-trade tool owns that. (parity matrix, 20260703)
✅ B&T / EDI → Distribution-partner pipeline generalization. Baker & Taylor is defunct (decision resolved 20260703). The legacy B&T EDI console suite (850/855/856/810/997), monthly buyer review, and scan-to-ship workflow are deferred in favor of a generalized abstraction. Future work: research EDI interaction patterns + buyer-review workflow for any future partner, then design reusable "distribution-partner pipeline". (20260703, PARITY.md rows 33 + 108, todo.md)
Inventory & Catalog
✅ D9 — FINALINV = lifetime print baseline, never reset. FINALINV is the print-run commitment; ONHAND = FINALINV − ALLSALES (post-release erosion). Standing-order needed = max(0, requested − FINALINV). Rationale: Mirrors legacy exactly; FINALINV is the decision, ONHAND is derivative. (20260618, parity matrix)
✅ D10 — Monthly make-available gates on FINALINV ≠ 0. Missing-FINALINV warning is non-blocking; operator workflow safety + data integrity. Re-seeds ONHAND := FINALINV at publish. Mirrors legacy :129221/:129236 exactly. (20260702, b58f91238)
✅ D11 — FASTPRINT lane (POD). Stock gated OFF (ships full), 6-month PUBDATE window, HOTBOX='FPR' (C(3) truncation; legacy 'FPRINT' stores as 'FPR'), +1-month invoice date, freeship, PIPACK=3/PEPACK=1. Legacy parity at lane entry (FASTPRINT_CREATE :160254). Note: DBF HOTBOX is C(3) — verified 25 production broheads. (20260703, 2a08a04ec + fcaa106af + ceec2ed1a)
✅ D12 — FINALINV readiness gate at Ship, not Queue. Preview stays open at FINALINV=0 (warned) to discover demand; operator keys FINALINV from preview + buffer; Ship holds any order with unsettled FINALINV. Rationale: Improves legacy's dead-end (forced external FINALINV forecast); preserves safety intent. (20260618, parity matrix)
Pricing, Promotions & Campaigns
✅ D10.1 — Submit locks pricing. All order pricing is locked at submit time. Carts are repriced on pull only if back-ordered. Rationale: Legacy parity; pricing is not recalculated mid-cart. (parity matrix, domain_seams.md)
✅ D10.2 — Pricing is always complete. The 42,873 ISBN × account records must be pre-calculated and cached; no on-demand fallback. Required fields always present; sparse optimization only for purchases (92.8% sparse). Cache TTL 3 hours (cold ~30s / cached 0.55s). (organization_context.md, 20260618)
✅ D10.3 — Promotions are slot-based, not semantic. Column names (western, romance, etc.) are historical convenience; slots are interchangeable. Code asks PromotionsConfiguration for column names, never hardcodes them. (dynamic_promotions_plan.md)
✅ D10.4 — Campaigns are processor-first. Each campaign reason has a dedicated processor class implementing CampaignProcessorInterface. The campaign-builder is orchestration, not logic. New campaign types = new processor + factory registration (no wizard-step cascading). (CAMPAIGNS_REFERENCE.md)
✅ D10.5 — Saved queries are filter combinations, not campaigns. They store account-classification flag selections in the wizard, reducing manual filter entry for recurring audience targeting. They do not save campaign settings or email templates. (saved_queries.md)
✅ D10.6 — Campaign options flow through whitelist. New CampaignOption enum cases must be added to BuildCampaignDrafts::normalizeOptions() or they are silently dropped before reaching build. (campaign_options_and_wizard.md, gotcha section)
Accounts & Organizations
✅ D1 — Account classification is point-in-time, not per-request. The 86-column account_meta classification runs once during OrganizationContextService build (context TTL 3 hours) and is cached. No per-request classification. Rationale: ~30s cold / 0.55s cached; per-request would be prohibitive. (20260702, parity matrix)
✅ D2 — Contact denormalization persists during transition. Accounts and passwords keep their SEX, FIRST, LAST, TITLE, VOICEPHONE, FAXPHONE, EMAIL fields through the transition to canonical contacts pool. Writes trigger soft-link ensure-or-create (when implemented); composer fields stay for legacy reads and DBF mirroring. Rationale: Accounts is DBF-mirrored; removing denormalized fields requires schema changes that can't survive rebuild. (parity matrix, address_contact_architecture.md)
✅ D3 — RECALLD dual-persistence survives rebuild. Callback date changes write to both accounts (immediate views) and accounts_meta (survives rebuild). Custnotes created with locked_at timestamp survive partial rebuilds. Rationale: accounts rebuilt daily from DBF, losing MySQL-only changes. (parity matrix)
✅ D4 — Address composition via fallback chain, not override-only. Default_physical composes from accounts directly; non-physical pivots read via contact FK with fallback to accounts composite. COMPANY override (pivot-only) allows alt-row organizational identity. Rationale: No blank address fields; fallback ensures coverage. (parity matrix)
Notifications & Email
✅ Email on every plan event. One event = one email + notification + custnote. No event batching. Rationale: Transparency + audit trail; each action is atomic. Pace (qty×2mo) prevents notification fatigue. (20260701, todo.md §1)
✅ Contact-type notes = log-only. No auto customer email. Manual contact is staff's action, not customer-facing event. Email goes only to the note author (in-app). Rationale: Clarity; contact-note is internal documentation. (20260701, todo.md §2, decision resolved)
✅ Notifications from shared account (nstewart@). Not from individual staff. Rationale: Single sender reduces support confusion; individual author tracked in transactions.by_email. (20260701, config/cp.php:203)
✅ Check Plan Health is universal. Applies to all plan types (Choice, Trade, Regular), not just Choice. Rationale: Modern adds uniformly; Trade gets its own email summary block (not the Choice "titles selected" template). (20260702, todo.md §1)
✅ Account health derivation, not persistence (for now). Flags computed on-read from transactions rows (codes 38–46) + StandingOrder lifecycle scopes. Permanentization to account_meta columns + batch classification = deferred pending flag-set stabilization. (20260701, todo.md §3)
Reports & Analytics
✅ D12 — No New ISBNs Projection (5-year ISBN lifetime). All depletion projections use fixed 5-year ISBN lifetime from PUBDATE. Titles older than 5 years are "expired" and excluded from recommendations. Rationale: Matches legacy acquisition planning model; forecasts remain realistic. (parity matrix)
✅ D13 — Caching is indefinite unless explicitly bypassed. Reports stored persistently in storage/app/private/reports/ and never expire automatically. Trade-off: accuracy is secondary to performance and predictability. Users needing fresh data must use --no-cache flag. (ReportsController, CompanyReportService)
✅ D14 — Acquisitions spreadsheet = source of truth (transitional). During transition, inventory acquisitions live in CSV (ACQUISITIONS_SPREADSHEET_PATH). Website provides read-only analytics. CRUD operations disabled (ACQUISITIONS_TRANSITION_MODE=true). When ready, --false unlocks full in-app management. (InventoryAcquisitionsController)
✅ D15 — Report generation runs on CLI, not web. Long-running reports (20–30 minutes) generated via artisan experience:generate-report, not web interface. Prevents timeout errors; allows asynchronous scheduling. (ExperienceController, CompanyReportService)
Time Clock & Tickets
✅ D-TC-1 — Single write endpoint. All status transitions (clock in, out, on lunch, etc.) go through one setStatus() endpoint. Controller does NOT hardcode per-action logic; transition guard in User::updateStatus() decides legality from current clock state. Rationale: Reduces surface area; state machine self-evident in config/time_clock.php. (20260615, aa121654b–82aa72aa8)
✅ D-TC-2 — Status events are canonical. UserStatusChanged events fire the TimeClockState read model + TimesheetDraftSync listener. Worker-friendly side effect (automatic draft entry) never reaches back to corrupt event stream. (20260615)
✅ D-TC-3 — Tickets diverge from legacy CUSTNOTE. Rather than extend customer-note infrastructure, tickets are standalone domain with separate models, permissions, and views. Rationale: Allows future enhancements (SLA tracking, KB linking) without entanglement. (20260615)
✅ D-TC-4 — Draft timesheet sync is a safety net, not the system. Real-time updates from ProjectStatusToTimesheet listener. Nightly timeclock:sync-drafts command (23:45 ET) caps "forgot to clock out" at end-of-day and handles edge cases. (20260615, console.php:99–106)
Platform & Infrastructure
✅ Sessions survive password rebuilds. The nightly RebuildUsers job (02:15 ET) rebuilds MySQL passwords table from DBF, but session cookie remains valid. Session store (Redis/file) is independent of password table; session confirms login; password table consulted only on new login. (auth_architecture.md, RebuildApplicationTables.php:200–220)
✅ INDEX is the link (DBF-MySQL bridge). Every record from DBF has INDEX matching DBF record number (RECNO). When a new Laravel model syncs to DBF, INDEX starts NULL; after sync, set to DBF record slot. This link is sacred — never null it out unless about to resync. (dbf_integrity_system.md, feedback_index_dbf_assigned.md)
✅ MySQL is readable; DBF is source of truth. Writes flow MySQL → DBF via $model->dbf()->save(). Daily rebuild (02:15 ET) tears down and rebuilds web*, back*, bro*, all* tables from DBF counterparts. Plain Laravel migrations on these tables will NOT survive rebuild — DBF and rebuild job must both carry the column change. (RebuildApplicationTables.php:240–290)
✅ Three DBF code paths (name before hypothesizing). LEGACY path: Direct fopen in PHP. dbf-service path: 32-bit Lumen on :8080 (VFPOLEDB). RUST path: Rust API on :3636 (not production-ready). A data inconsistency might be legacy-path artifact, dbf-service timeout, or Rust-side issue. Knowing which path is in play cuts debugging time in half. (dbf_three_paths.md, feedback_dbf_service_rw_flag.md)
✅ TransactionLogger is universal gate. Every transactions table row flows through TransactionLogger::record(). Config gates (config/transactions.php) decide what to keep: global kill switch, code-level disable, job-level fnmatch globs, max payload bytes, legacy override. Never edit call sites to silence noise — flip a config gate instead. Fail-soft: exceptions log warning and return null. (transaction_logger.md, config/transactions.php)
✅ Jobs run on Laravel queue with instrumentation. Scheduler (routes/console.php) defines 23 instrumented jobs. Each wraps with $instrument() closure emitting started/completed/failed transaction rows on before/onSuccess/onFailure hooks — no changes to command class needed. (transaction_logger.md, 5c3ebd263 + 33e30878a)
Cross-Domain Meta-Decisions
✅ Submit locks all pricing and policy. Orders submitted (SUBMITTED=5555) become immutable for pricing, free-shipping, and fulfillment policy. Staff can adjust line items, change quantities, override flags during master edit, but the pricing formula is never reapplied. Rationale: Fraud prevention; customer expectation. (D1 Orders, D10.1 Pricing, 20260702)
✅ DBF remains system of record. Modern MySQL provides read-only views and fast queries; DBF is the canonical persistent store. Every app write goes DBF-ward via cascading services. Rebuild job (02:15 ET) asserts this monthly — MySQL is always a fresh mirror. Rationale: 20+ years of legacy data, external integrations (Pitney Bowes), regulatory audit trail. (dbf_integrity_system.md, feedback_dbf_service_rw_flag.md)
✅ Batch operations, not inline. Fulfillment, archival, inventory release, reports, catalog rebuilds all run as nightly or on-demand batch jobs (orders:release-backorders, orders:archive-sweep, inventory:make-available, experience:generate-report, etc.). Rationale: Avoids customer-facing latency; enables auditing and dry-run before commit; improves data consistency. (parity matrix, multiple D4–D15 decisions)
✅ System informs; operator decides. Fulfillment UI flags oversized groups, address mismatches, unfulfilled titles, dedup candidates. Routing defaults are sensible (PB for multi-member, downstairs for singletons). But the operator owns the final routing decision — never automatic rerouting. Rationale: Honors legacy's manual control model while making it explicit + auditable. (D5 Standing Orders, D-T-6 Trade, 20260702)
These rulings are durable; changes require consensus. See PARITY.md for the detailed legacy-parity ledger and feature-by-feature implementation status.
19. Green-field / Future Features
Shipped Green-field Features (✅)
Brutus ubiquitous chat. The legacy app's email console (NEW_EMAIL_CONSOLE, EMBASE.DBF) was a separate staff interface for promotional outreach — staff navigated menus, composed templates, and scheduled sends as a distinct workflow. The modern app replaces that with Brutus, an intent-driven chat dispatcher that embeds action tools directly into the app. Instead of leaving an account page to open a contact form, staff click a button → the chat opens inline → the contact form renders as a canvas widget inside the chat. Intents (account edit, contact callback, plan actions) map to reusable canvas tools; every page is a trigger surface; the chat handles session bootstrap and response routing transparently. Shipped Jul 2026. 🔧 See brutus_ubiquitous_chat.md for invocation primitives (useBrutusTrigger page-button API, canvas-chip instant dispatch, response shape contract).
Unified Time Clock. Entirely green-field — the legacy app had no unified time-clock system. Prior to June 2026, Centerpoint ran three fragmented timekeeping approaches: a defunct TimeCard model, disparate clock endpoints per action (/clock/{in,out,lunch,break}), and manual timesheet entry without automation. The modern system consolidates these into one status-driven state machine (UserStatus enum, TimeClockState read model, single setStatus() endpoint) that projects the status stream into valid next-state actions and auto-drafted timesheet entries. Staff punch in/out via a single widget; the system automatically splits lunch breaks and detects overnight intervals. Shipped Jun 15, 2026. 🔧 Config at config/time_clock.php; test suite covers state-machine matrix, lunch-split, forgot-clock-out edge cases, draft sync.
Support Ticket System. Entirely green-field — the legacy app's SERVICE_HELP and SERVICE_REQUESTS procedures were non-functional workarounds. The modern system is a purpose-built ticketing infrastructure: customers report issues via email or web form; staff track status through a workflow (open → in progress → feedback requested → complete); every status change writes an audit trail; customers view their tickets via secure public links and receive notifications on updates. Ticket messages are threaded; image attachments are recompressed automatically; assignment and SLA tracking are extensible. A complementary foundation for future customer-success operations. Shipped Jun 2026. 🔧 Models at app/Models/Ticket, routes at routes/tickets.php, access gated by TicketPolicy (staff-only by default; public-link guests see only their own).
Global Diagnostics Panel. Staff-only modal that centralizes system health and debugging across all domains — cart parity, user sync status, order reconciliation, DBF drift, job health, and emerging errors. Built as a tabbed modal, extensible by adding new tabs without scattering diagnostic tools across multiple pages. A force multiplier for ops triage. Shipped May 2026. 🔧 Dispatch via useBrutusTrigger({intent: INTENTS.systemDiagnostics}).
Saved Queries in Campaign Wizard. Allows staff to save filter combinations for reuse across campaign builds — e.g., "libraries in TX with recent Trade activity." Eliminates repetition and creates institutional memory of useful segments. A net-new productivity lever for marketing campaigns. Shipped Feb 2026. 🔧 Mutually exclusive: query_id_ OR regular filters, never both; reuse in campaigns:build invocation.
In Progress / Deferred
Account Health Dashboard & Batch Renewal. Staff surface now shows account health flags (plan expiring, short on budget, recently lagging, contact overdue) and purchase history together with batch renewal capability — paralleling the legacy account-rep dashboard but with modern real-time derivation. Surfacing derived flags in account search results and the plan table are open per §5 todo. No target date.
Knowledge Base grounded-answer tier. A new tier in the Brutus answer pipeline: curated FAQ (regex/keyword) → KB grounded answer (BM25 chunk retrieval + optional LLM enrichment) → fallback (admin alert). The corpus is 67 markdown docs from storage/notes, chunked at H2 granularity; data model is doc_chunks table (durable reference + embedding slot) + sparse BM25 index. No LLM generation required for Level 0 (section-as-answer); enrichment and rephrasing are opt-in. Expected Q4 2026 start.
These features represent modern-app innovation; they have no legacy equivalent and define competitive advantage.
20. Open Questions & Undecided Directions
The following features and technical decisions remain open — either unaudited against the legacy app, explicitly deferred, or blocked on information gathering. These are normal design process checkpoints, not failures or bugs.
Feature-Level Decisions
UNDECIDED — Bonus / Gift / Free Book Program: The legacy app tracked earned credits and allowed customers to redeem free books. The modern app has no counterpart. Decision pending: port the full entitlement ledger (earn / redeem / restore) or retire the program entirely. PARITY.md status = none. (20260702)
UNDECIDED — Out-of-Catalog Titles (NOSTOCK): The legacy app maintained a NOSTOCK registry for titles no longer in active inventory but occasionally needed for research/fulfillment. The modern app inventory editor shows no equivalent. Decision pending: whether to port or treat as a legacy artifact. PARITY.md status = unaudited. (20260702)
UNDECIDED — Account Priority System: Planned feature (LOW / MEDIUM / HIGH priority levels) with per-account picker, search filters, and budget-warning triggers. Design and implementation both pending. Would surface on account dashboards and rep call lists to highlight VIP or at-risk accounts.
UNDECIDED — Contact Pool Canonicalization: Planned design to build a single canonical Contact model — deduplicating by name, phone, email — to replace the current account-address-linked contact fields. Full schema + dedup logic design pending.
Technical Decisions Awaiting Approval
UNDECIDED — Memo Field (FPT) Writing: The app reads memo fields from titles and trade notes (FoxPro .FPT format). Writing memos back to the DBF is unimplemented. Decision pending: understand the FoxPro block format (memo chunk layout, offset tables) and decide whether to port writes at all or leave memos read-only. Impacts MARC record editing and editorial content updates. Blocked on format audit. (20260702)
UNDECIDED — Account Health Flag Persistence: Health derivations (plan-health, contact-health, account badge) are currently computed on-read from transactions and standing-order scopes. Permanentizing as account_meta columns + batch classification is deferred pending stabilization of the flag set. Once the useful flag combinations are confirmed, a batch reclassification pass will make searches and dashboards faster.
UNDECIDED — Trade "By Email" Bulk Update Tab: A new tab for the trade orders UI to support bulk edits sent in by email. Currently deferred to its own focused session rather than bolted onto the existing "By Standing Order" tab. Scope and interaction design pending.
Domain Gaps
UNDECIDED — Broader Distribution-Partner Pipeline: The legacy app hardcoded B&T EDI messaging (850/855/856/810/997 consoles, manifests, invoices). B&T is now defunct. Decision pending: design an abstract "distribution-partner pipeline" reusable for future vendor relationships (purchase orders, EDI consoles, receiving, vendor payments). Deferred pending stakeholder input on future partner relationships. (20260703)
UNDECIDED — Receiving & Receiving/PO Domain: The app's PrintRunInventoryService lets operators receive and correct FINALINV at the print stage, but a broader receiving/PO domain (ONORDER tracking, RPURCHASES ownership, supplier management) has no app counterpart yet. Scope and business rules pending. Related: publisher link (royalties) editor is partial — journalkey-link editing surface unverified. (20260702)
UNDECIDED — Payment & Deposit System: Legacy tracked daily deposits, check cashing, and payment accounting. The modern app has no equivalent. Decision pending: whether to build in-app or integrate with an external accounting system. This blocks payment-system reconciliation reports and cashier workflows. (20260702)
Reporting & Analytics
UNDECIDED — Editorial Cost-of-Goods (CGS): Legacy tracked per-title cost (unit vs reprint). The modern app has no equivalent cost_of_goods field. Decision pending: add the field and populate from accounting system, or defer pending business rules clarification. Blocks accurate margin reporting.
UNDECIDED — Editorial Sales & Margin Reports: Legacy included per-title sales metrics and cost-of-goods analysis. Modern INVPROJECT (ISBN quantities/sales) report is built but stubbed for production (~2026-07-15 pending ops sign-off). Editorial margin reporting remains unaudited. PARITY.md status = unaudited. (20260702)
UNDECIDED — Web-Copy Proofing Emails: Legacy supported emailing titles for editorial copy review. The modern campaign/flyer export path exists but the single-title one-off proofing email is unaudited. Decision pending: whether to port or n-a as an outdated workflow. (20260702)
UNDECIDED — Multi-Format Report Exports: Reports currently export as JSON (consumable by web UI and API). Future CSV, Excel, and PDF support is planned but not yet implemented. Design pending: whether to implement in-app or delegate to a third-party reporting service.
UI Polish & UX
UNDECIDED — How-Can-We-Help CTA Block: Expiring/lagging plan emails include a generic action button but lack a richer "How can we help?" block. Deferred pending email-design review. (20260701)
UNDECIDED — Account Health Dashboard Surfacing: Derived health flags (has-expired-choice, has-lagging-expiring-choice, contact-health) exist but are not yet surfaced in account search results, account-rep dashboards, or the account-canvas tool descriptions. Deferred pending UI polish and stakeholder review of which flags are most useful. (20260701)
UNDECIDED — Knowledge Base Grounded-Answer Tier: Chat system will eventually support retrieval + optional LLM generation over internal documentation. Preprocessing is locked; engine choice (BM25 vs semantic embeddings) is deferred. Planned for Q4 2026+. (20260701)
UNDECIDED — Downstairs Reroute Picker UI: Legacy's manual downstairs override for grouped shipments is now expressed in the operator-routing model. CLI and admin tools exist; UX polish (a dedicated picker modal) is deferred. (20260703)
UNDECIDED — Scheduled Reminders for Staff: Native Laravel scheduler integration is available but deferred pending business rules clarification on reminder frequency and content. (20260616)
Teams working on these areas should consult the PARITY.md decision log for context and stakeholder feedback.
Part IV — Appendices
Appendix A — Legacy ↔ New Parity Matrix (durable summary)
[See appA_parity_matrix_summary.md for full content]
Appendix B — Legacy Procedure Glossary
Complete descriptive entry for all 379 top-level PROCEDURE and FUNCTION definitions from webnet.prg (the legacy Visual FoxPro application, 161,252 lines). Organized by domain following Part II taxonomy. Each entry: name, type, line number, and 1–2 sentence purpose statement. Entries are alphabetized within each domain section.
Phase 2 Research Complete — 20260704: All 379 routines extracted, categorized, and documented. PFP Decision: Option A — Full documentation. PFP is an archived subsystem documented for posterity; not intended for rebuild in the modern Laravel/Vue application but may be extracted as a separate application if future business requirements warrant.
[NOTE: Full glossary content (sections B.1–B.12) is in APPENDIX_B_DRAFT.md. For the sake of file size, the complete 1945-line glossary with all 379 routine entries is maintained as a separate reference file. To view the complete glossary with all sections, see: staff/storage/notes/APPENDIX_B_DRAFT.md]
B.1–B.12 [See existing PRD.md Appendix B sections for complete content]
Appendix C — Legacy Views & Pages Index (Client-Side Structure)
[Keep existing complete Appendix C from current PRD.md as-is — no changes needed]
Appendix D — Domain Seams & Gotchas
[See appD_domain_seams_gotchas.md for full content]
Appendix E — Glossary of Terms
[See appE_glossary_of_terms.md for full content]
Appendix F — DBF Table Families & Key Fields
[See appF_data_table_reference.md for full content]
Appendix G — Living-Docs Index
[See appG_living_docs_index.md for full content]
End of PRD.md
VFP Views
Visual FoxPro webnet.prg — Client-Side Structure Map
File: C:\inetpub\wwwroot\FOXISAPI\webnet.prg
Size: 161,252 lines | Procedures: 379
Architecture: URL-routed CGI entry point → procedure dispatcher → HTML form generator
Overview: Navigation & Authorization
Entry Points
-
STARTPOINT (line 685): Main dispatcher; checks authorization (z01/z02/z03, z06), routes to requested procedure via
bzpparameter - MAINMENU (line 14651): Homepage; shows nav menu, featured titles, error messages
- INIT (line 33): Pre-initialization hook
Authorization Parameters
- z01: Username
- z02: Password (hashed)
- z03: Customer Key (account ID) — persists session
- z04: Transaction/Order ID
- z06: Master password (admin/staff only)
- bzp: Target procedure name (routing parameter)
Core Menus
Domain 1: INVENTORY & CATALOG
Primary Views
TD_TITLE_VIEW (line 15019)
Purpose: Catalog browser; search/filter titles; view details; add to cart (if logged in)
Entry Point:
/SGZ?bzp=TD_TITLE_VIEW[&INVQUERY=ONSPOT|CHOICE|TRADE|BACKLIST][&PROMONAME=...][&INVLMNT=CP|TR]
Key Parameters:
-
INVQUERY: Catalog segment (ONSPOT=featured, CHOICE=customer choice, TRADE=trade/wholesale, BACKLIST=older) -
INVLMNT: Inventory limit (CP=Center Point, TR=Trade Publishers, CPBACK=CP backlist, TRBACK=Trade backlist) -
PROMONAME: Promotion filter -
SINGLEISBN: Direct ISBN lookup -
SEARCH_INPUT: Title/author/ISBN search string -
FULLVIEW: Toggle expanded detail (ON/OFF) -
CREATELISTE: Flag to create a new list
Displays:
- Title search results (picture grid or table)
- Individual title detail card: cover image, synopsis, author, pricing
- Purchase options (if logged in & available)
- Related titles / recommendations
- Availability status (In Stock, Out of Print, Backlist, Coming Soon)
User Actions:
- Search by title, author, ISBN, keyword
- Filter by publication date, category, subject
- View full detail / synopsis
- Add to cart (route to GENERAL_VIEW if logged in; redirect to login if not)
- Click through to related Standing Orders / Choice plans if applicable
- Switch between view modes (grid/list)
-
TD_TITLE_WRITE(line 19405): Save catalog edits (admin/staff) -
TD_AT_DISPLAY: Render title grid/list on MAINMENU -
PICTURE_DISPLAY: Render photo gallery -
GENERAL_VIEW_ACCESS_MARC(line 20272): MARC record viewer -
INV_READ,INV_WRITE,INV_DELETE: Direct inventory admin
INV_READ / INV_WRITE / INV_DELETE (lines 84028, 111719, 112936)
Purpose: Inventory admin (staff only); add/edit/delete titles from catalog
Entry Point: bzp=INV_READ|INV_WRITE|INV_DELETE (z06 required)
Parameters:
-
SINGLEISBN: ISBN to edit -
WHATACTION: CREATE, EDIT, DELETE - Form fields: title, author, list price, sale price, pub date, status, etc.
Displays: Admin form with all title fields
User Actions:
- Create new catalog entry
- Bulk update pricing, pub dates, availability
- Delete deprecated titles
- Manage pricing overlays
Secondary Inventory Views
NOSTOCK_READ / NOSTOCK_WRITE (lines 112766, 112845)
Purpose: Manage out-of-stock titles; mark items as unavailable; backorder notifications
OPEN_STANDARD_SHEETS / OPEN_SPECIAL_SHEETS (lines 104041, 104099)
Purpose: Downloadable worksheets (printable order forms)
Domain 2: ORDERS (Web Cart & Transactional)
Primary Views
GENERAL_VIEW (line 61606)
Purpose: Shopping cart manager; order entry; order modification; line-item editor; applies pricing/discounts
Entry Point:
/SGZ?bzp=GENERAL_VIEW[&WHATACTION=CREATENEWORDER|CREATENEWRETURN|MANAGENEWORDER|MANAGENEWRETURN][&z04=TRANSNO][&HEADER_FILE=WEBHEAD|BROHEAD|BACKHEAD|ALLHEAD][&DETAIL_FILE=WEBDETAIL|BRODETAIL|BACKDETAIL|ALLDETAIL]
Key Parameters:
-
CREATENEWORDER: Create new wholesale order (new TRANSNO, init WEBHEAD/WEBDETAIL) -
CREATENEWRETURN: Create return (NEW RMA, new BROHEAD/BRODETAIL) -
MANAGENEWORDER: Edit in-progress order -
MANAGENEWRETURN: Edit in-progress return -
WHATACTION: Secondary action (CHANGEMA=update item, DELETEITEM=remove line, etc.) -
SEARCHBY: BYTITLE or BYISBN for item lookup -
HEADER_FILE/DETAIL_FILE: Which order tables to use (WEB=web orders, BRO=broker returns, BACK=backorders, ALL=consolidated) -
NEEDSAPO: Require PO# before checkout -
TPOTHREE: Age filter (3-day old orders)
Displays:
- Order header: customer name, billing address, date, status, total
- Order line items: ISBN, title, qty, unit price, extended price, status per item
- Search panel: find titles by ISBN or title to add
- Pricing breakdown: subtotal, discounts, shipping calc, tax, final total
- Order status flags: Open, In Process, Paid, Shipped, Returned, Archived
User Actions:
- Create new order / return
- Search & add titles to order
- Change quantities, unit prices, line discounts
- Remove line items
- Update PO# / order special instructions
- Submit order (POST to ORDER_POST_FINAL)
- View order history / previous orders
- Mark items as shipped (fulfillment staff)
- Apply promotional discounts
- Split order into multiple shipments
-
GENERAL_VIEW_SPECIAL(line 55562): View order history / special searches -
GENERAL_VIEW_WINDOW_DESTROY(line 56376): Close order (archive/finalize) -
GENERAL_VIEW_ORDER_ADJUST(line 59154): Price override / manual adjustments -
GENERAL_VIEW_SHOW_HISTORY(line 64749): Show archived orders -
GEN_ORDER_MENU(line 120589): Staff order entry interface -
GEN_ORDER_MENU_WRITE(line 122054): Process order form submission -
GEN_ORDER_MENU_INVPROJECT(line 122921): Special inventory project orders -
ORDER_DELETE(line 32708): Remove entire order -
ORDER_POST_FINAL(line 34507): Submit order; create transaction record; update DBF
ORDER_DELETE (line 32708)
Purpose: Cancel/delete order; reverse charges; mark as voided
Entry Point: bzp=ORDER_DELETE[&z04=TRANSNO]
User Actions:
- Confirm order cancellation
- Refund applied (if paid)
- Return inventory to available
ORDER_POST_FINAL (line 34507)
Purpose: Finalize & submit order; validate all required fields; create transaction; update inventory; trigger email confirmation
Entry Point: bzp=ORDER_POST_FINAL[&z04=TRANSNO]
Validation:
- Customer key present
- Billing address complete
- At least one line item
- Payment method specified (if required)
Actions on Submit:
- Write transaction record (TRANSACT table)
- Update WEBHEAD/WEBDETAIL status to "In Process"
- Trigger inventory hold (if on-demand)
- Generate order confirmation email
- Route to FOH if fulfillment needed
- Redirect to GENERAL_VIEW with confirmation
FOH_DETAIL (line 27031)
Purpose: Line-item detail viewer; shows item-level status during fulfillment
Entry Point: bzp=FOH_DETAIL[&z04=TRANSNO]
Domain 3: STANDING ORDERS (Subscription & Auto-Ship)
Primary Views
STANDING_PROFILE (line 25895)
Purpose: View standing order summary; subscription settings; auto-renewal options; customer profile photos
Entry Point:
/SGZ?bzp=STANDING_PROFILE[&z03=CUSTKEY][&SEE_CONTROLS=YES][&HIDE_BUTTONS=YES]
Key Parameters:
-
z03: Customer key -
SEE_CONTROLS: Show admin buttons (YES for staff, omit for customers) -
HIDE_BUTTONS: Suppress checkout buttons -
TSENDPROFILE: Email profile (staff action)
Displays:
- Account holder name, address, contact info
- Standing order plan type (Club A, Club B, Choice, etc.)
- Subscription status (Active, Paused, Cancelled, Expired)
- Plan details: # of selections per month, format, pricing tier
- Account photos (staff pic, email, title, phone)
- Customer class / promotional tier (Class A, Class B, etc.)
User Actions:
- View profile details (read-only for customers)
- Edit profile (route to AM_NEWMHEAD if admin)
- Email profile (staff action)
- Manage standing order settings
-
STANDING_WRITE(line 88165): Edit standing order header fields -
STANDING_HEAD(line 89282): Create new standing order / change plan -
STANDING_CLEAN(line 95495): Billing consistency review / cleanup -
STANDING_RENEW(line 96021): Renew expiring subscription -
STANDING_CONFIRMATION(line 114317): Confirm standing order after edit -
STANDING_NOTE_DISPLAY(line 27603): View account notes & communications -
STANDING_DESTINATION(line 139622): Manage shipping address for standing order -
CLEARSOWRITE(line 95959): Archive/close standing order
STANDING_HEAD (line 89282)
Purpose: Standing order creation; plan selection; billing setup
Entry Point: bzp=STANDING_HEAD[&z03=CUSTKEY]
Displays:
- Plan type selector (Club A, Club B, Choice, etc.)
- Billing address form
- Selection preference (automatic, manual, hybrid)
- Frequency selector (monthly, quarterly, etc.)
- Payment method
User Actions:
- Select plan type
- Set up billing
- Choose auto-ship options
- Save & activate plan → STANDING_CONFIRMATION
STANDING_CLEAN (line 95495)
Purpose: Billing consistency audit; find orphaned/duplicate records; fix account state
Entry Point: bzp=STANDING_CLEAN (staff only, z06 required)
Displays:
- Account billing state (summary)
- Orphaned standing order entries
- Duplicate payment records
- Mismatch warnings
User Actions:
- Review billing anomalies
- Consolidate / merge records
- Archive stale entries
Secondary Standing Order Views
VIEW_BACKORDERS (line 95149)
Purpose: Show backorder items on customer's standing order; when they'll ship
STANDING_NOTE_DISPLAY (line 27603)
Purpose: View account notes & communications history
Domain 4: TRADE HOUSE (B2B Wholesale Orders)
Primary Views
TRADE_HOUSE_CUSTOMER_VIEW (line 49592)
Purpose: Trade/wholesale customer dashboard; show plan participation, order history, inventory for upcoming titles, manage selections
Entry Point:
/SGZ?bzp=TRADE_HOUSE_CUSTOMER_VIEW[&z03=CUSTKEY][&SORTMETHOD=...][&MONTHLIMIT=...][&SHOWBACKLIST=ON|OFF]
Key Parameters:
-
SORTMETHOD: Sort display by title, author, publication date, etc. -
MONTHLIMIT: Show only titles within N months -
SHOWBACKLIST: Include backlist titles (ON/OFF) -
TEMPFULL: Show full detail (OFF to compact) -
CHANGELISTING: Pagination control
Displays:
- Trade publisher customer account name & location
- Upcoming trade titles (publication schedule)
- Per-title information: cover, ISBN, title, author, pub date, list price, trade discount %
- Selection checkboxes / quantity fields (if plan allows custom selections)
- Order history summary
- Account rep contact info
User Actions:
- Browse upcoming trade titles
- Select titles for standing order / shipment
- Change quantities per title
- Sort/filter display
- Submit selections → TRADE_HOUSE_SELECT_DISPLAY
- View backorders & in-process shipments
-
TRADE_HOUSE_SELECT_DISPLAY(line 52522): Save selections; confirm order -
NEW_TRADE_HOUSE_CUSTOMER_VIEW_WRITE(line 50800): Write selection changes -
TRADE_HOUSE_SELECT_HEADER(line 40911): Header tier for selection flow -
TRADE_HOUSE_ADMIN_ORDER_UPDATE(line 131884): Staff-side order adjustment -
TRADE_HOUSE_GENERAL_SCREEN(line 130316): Admin view of all orders -
TRADE_HOUSE_PLAN_PRINT_LOOK(line 134685): Print-ready order summary -
TRADE_HOUSE_LOAD_LEVEL(line 133536): Load inventory tier/allocation -
TRADE_HOUSE_PUBDATE(line 131884): Filter titles by pub date range -
TRADE_HOUSE_PICTURES_COPY(line 42679): Cache cover images -
TRADE_HOUSE_ZEBRA(line 42875): Barcode/shipping label integration -
TRADE_HOUSE_EMAIL_SUBSYSTEM(line 41943): Bulk email to trade customers
TRADE_HOUSE_SELECT_DISPLAY (line 52522)
Purpose: Confirm & save trade customer selections before checkout
Entry Point: bzp=TRADE_HOUSE_SELECT_DISPLAY[&z03=CUSTKEY][&SWITCHVALUE=...]
Displays:
- Selected titles (list with qty, total)
- Order total (with trade discounts applied)
- Billing address (confirm)
- Shipping address
User Actions:
- Review selections
- Adjust quantities (back to TRADE_HOUSE_CUSTOMER_VIEW)
- Confirm & submit → creates transaction
REVIEW_TRADE_PLANS (line 152822)
Purpose: Staff view; review all trade plans, allocations, customer participation
Entry Point: bzp=REVIEW_TRADE_PLANS (z06 required)
Secondary Trade Views
TRADE_HOUSE_SWITCH_WRITE / TITLE (lines 130020, 130088)
Purpose: Switch between trade publishers (staff function); manage plan routing
TRADE_PREPAID_DATE / PREPAIDS (lines 151951, 142157)
Purpose: Track trade customer prepayments; aging; collection status
NEW_TRADE_SUMMARY (line 32106)
Purpose: Summary view of new trade orders in process
Domain 5: FULFILLMENT & BACK-ORDER MANAGEMENT
Primary Views
FOH (line 56387)
Purpose: Fulfillment Operations Hub; staff view of all orders needing shipment; fulfillment status tracking; shipment creation
Entry Point:
/SGZ?bzp=FOH[&z04=TRANSNO|ORDER_TRANSNO=...][&HEADER_FILE=...][&DETAIL_FILE=...][&TPOTHREE=...|SHOWARCH|SHOWPAY|CANARCH|TRADEBO|BTPAID|BTUNCONFIRMED|INCURRENT|INPAID|FORARCH]
Key Parameters:
-
ORDER_TRANSNO: Specific order to show -
TPOTHREE: Filter by age (3+ days old) -
SHOWARCH: Include archived orders -
SHOWPAY: Show unpaid orders -
TRADEBO: Trade orders only -
BTPAID: Paid orders ready to ship -
BTUNCONFIRMED: Orders awaiting payment confirmation
Displays:
- Order list (WEBHEAD/WEBDETAIL or BROHEAD/BRODETAIL or BACKHEAD/BACKDETAIL)
- For each order:
- Customer name, account number
- Order date, PO#
- Line items with qty ordered vs. qty allocated vs. qty shipped
- Status per line (Open, Partial, Complete, Back-ordered, On-hold)
- Total books/units, dollar value
- Fulfillment notes / special instructions
User Actions:
- Filter orders by status (open, paid, unpaid, archived, etc.)
- View order detail
- Mark items as shipped (create fulfillment record)
- Apply fulfillment holds
- Merge orders into single shipment
- Generate packing slips / labels
- Archive completed orders
-
FOH_DETAIL(line 27031): Item-level fulfillment detail -
FOH_BT_MARK_WOULD(line 128660): Mark item for fulfillment -
FOH_BT_LINE_ITEMS(line 128701): Edit fulfillment quantities -
CPBACKORDERS(line 60095): Backorder-specific fulfillment -
BACKMANAGEMENT(line 154206): Backorder aging, reneging, cancellation -
BACKMANAGEMENT_MARK(line 155330): Mark backorder action -
BACKMANAGEMENT_WRITE(line 155753): Write backorder changes
CPBACKORDERS (line 60095)
Purpose: Backorder dashboard; show all unfulfilled items; aging; projected fulfillment dates
Entry Point: bzp=CPBACKORDERS[&SHOWARCH=ON]
Displays:
- Backorder line items (customer, ISBN, title, qty, date ordered, expected ship date)
- Backorder age (days outstanding)
- Projected availability (from INVENT.pubdate for future titles)
- Customer contact info (for follow-up)
User Actions:
- Sort by age, customer, title
- View fulfillment status
- Mark as shipped (when inventory arrives)
- Renege / cancel (if title cancelled / unlikely to restock)
- Bulk email to customers with status updates
-
BACKMANAGEMENT(line 154206): Backorder admin -
BACKMANAGEMENT_MARK/WRITE(lines 155330, 155753): Change backorder status -
MAKE_AVAILABLE(line 145548): Trigger fulfillment when stock arrives
BACKMANAGEMENT (line 154206)
Purpose: Comprehensive backorder management; aging reports; collection workflow
Entry Point: bzp=BACKMANAGEMENT (z06 required)
Displays:
- Backorder inventory by ISBN (total demand vs. available qty)
- Backorder customer list (name, qty on order, date ordered)
- Aging buckets (0–30 days, 30–60, 60–90, 90+)
- Alert flags (titles unlikely to be published; customers unresponsive)
User Actions:
- Bulk mark items as shipped
- Bulk cancel backorders
- Generate aging reports
- Email customers with updates
Domain 6: ACCOUNT MANAGEMENT & PROFILES
Primary Views
ACCOUNT_MANAGEMENT_FULLVIEW (line 76615)
Purpose: Complete account admin dashboard; staff view for customer profiles; notes, contact info, subscription status, history
Entry Point:
/SGZ?bzp=ACCOUNT_MANAGEMENT_FULLVIEW[&z06=MASTERPASS][&z03=CUSTKEY][&MENUMODE=LESS|FULL][&RETURNPAGE=...][&NOTELIMIT=...][&NOTEACTION=...]
Key Parameters:
Displays:
- PROFILE tab: Name, address, email, phone, contact history
- NOTES tab: Customer communication log (internal memos, follow-ups, service requests)
- CONTACTS tab: Multiple shipping/billing addresses
- BILLING tab: Payment history, account balance, payment method
- STANDING ORDERS tab: Current subscriptions, plan details, renewal dates
- PURCHASES tab: Order history, lifetime value
- CLASS tab: Account classification (A/B/C/D/F/G/K/L/Q/R/S), promotional tier
- BONUSES tab: Free/gift books earned, bonus selections
User Actions:
- View all account data
- Add note (internal comment, follow-up flag)
- Edit address (EDTSETLINK)
- Change contact info
- Update account classification
- View order history
- Manage linked contacts
- Archive / close account
-
ACCOUNT_DISPLAY(line 24783): Customer card view (summary) -
AM_CONTACTS(line 25567): Manage secondary addresses -
AM_WORK(line 29646): Account edit form (detailed entry) -
AM_WORK_DETAILS(line 30024): Address details editor -
AM_WORK_SPECIAL(line 30670): Special handling flags -
AM_BASE_LETTER(line 31611): Account communication letter -
AM_COMMCODE(line 23926): Set customer comm code (sales rep assignment) -
AM_SETCALL(line 24379): Set call priority -
ACCOUNT_KEYED_ACCESS(line 97212): Direct key lookup -
EDTLINKPUB(line 25312): Link account to publisher -
EDTSETLINK(line 25460): Set primary address -
NEWMCHANGECOMMCODE(line 68305): Change comm code -
AM_NEWMHEAD(line 68318): Staff account edit form (new style) -
AM_NEWMHEAD_TARGETS(line 74669): Account targeting / segmentation
AM_NEWMHEAD (line 68318)
Purpose: Comprehensive account editor (newer UI); customer class, promotions, preferences, contact mgmt
Entry Point: bzp=AM_NEWMHEAD[&z06=MASTERPASS][&z03=CUSTKEY]
Displays:
- Account header (customer name, key, type)
- Tabs:
- Basic info (name, address, phone, email)
- Contact mgmt (ship-to addresses, contacts)
- Account settings (call priority, comm code, promotional flags)
- Standing orders (if applicable)
- Special handling
- Account history
User Actions:
- Edit customer details
- Manage addresses
- Assign sales rep
- Set promotional flags (promote_western, promote_romance, etc.)
- Mark inactive / do-not-contact
ACCOUNT_LOG_ON (line 35395)
Purpose: Customer login; authentication; session creation
Entry Point:
/SGZ?bzp=ACCOUNT_LOG_ON[&THISUSERNAME=...][&THISPASSWORD=...][&WHERETOGO=MAINMENU|GENERAL_VIEW|...]
Parameters:
-
THISUSERNAME: Customer username (email or account number) -
THISPASSWORD: Password -
WHERETOGO: Redirect target after login (default: MAINMENU)
Flow:
- Validate credentials against PASSWORD table (z01, z02 hash, z03 key)
- Create session (persisted in z03)
- Redirect to WHERETOGO
Error Handling:
- Invalid username/password → show login form with error
- Expired session → force re-login
- Locked account → show message; contact staff
-
ACCOUNT_LOG_OFF(line 46709): Customer logout; clear session -
ACCOUNT_LOG_OFF_WRITE(line 47106): Finalize logout
ACCOUNT_LOG_OFF (line 46709)
Purpose: Customer logout; session cleanup
Entry Point: bzp=ACCOUNT_LOG_OFF[&z03=CUSTKEY]
Actions:
- Clear z03 session
- Redirect to MAINMENU
- Show "logged out" confirmation
Secondary Account Views
ACCOUNT_ACCESS (line 22453)
Purpose: Account lookup; select customer from list
ACCOUNT_DISPLAY (line 24783)
Purpose: Condensed customer card; summary info
ACCOUNT_MANAGEMENT_ONESCREEN_WRITE (line 78214)
Purpose: Single-screen account edit form submission
ACCOUNT_MANAGEMENT_READ (line 78576)
Purpose: Account lookup / search interface
ACCOUNT_MANAGEMENT_KICKBACK (line 81219)
Purpose: Referral / kickback commission tracking
ACCOUNT_MANAGEMENT_CALLSTAT (line 81744)
Purpose: Call history / follow-up tracking
AM_DELETEBASES (line 83457)
Purpose: Account cleanup; archive old accounts
AM_REVIEW_DETAILS (line 114744)
Purpose: Review account details before finalizing changes
AM_WORK_CHOICE (line 113456)
Purpose: Customer choice plan selection (part of standing order flow)
Domain 7: PRICING & DISCOUNTS
Integration Points (Not Standalone Procedures)
Pricing is embedded throughout order workflows:
- GENERAL_VIEW: Applies pricing based on customer class (A/B/C), promotional tier, purchase quantity, standing order membership
- TD_TITLE_VIEW: Shows list price, sale price, discount % (if applicable) per title per customer class
- FOH: Displays pricing in order summary (subtotal, discounts, shipping, tax)
- ORDER_POST_FINAL: Recalculates & validates pricing before submission
Pricing Calculation Components:
- Base list price (from INVENT.listprice)
- Customer discount (from PASSFILE.discount by account)
- Promotional overlays (from PRICINGOVERLAY by ISBN × account type)
- Volume discounts (if qty >= threshold)
- Standing order tier discounts
- Coupon discounts (from COUPON tables)
Pricing Sources:
- INVENT.listprice (base)
- PASSFILE.company, discount, priceoverride
- PRICINGOVERLAY (promo pricing per ISBN × account class)
- COUPON (coupon code discounts)
Domain 8: REPORTS & EXPORTS
Primary Views
PRINTCAT (line 98465)
Purpose: Catalog export / print; downloadable booklist in PDF or plain text
Entry Point:
/SGZ?bzp=PRINTCAT[&z03=CUSTKEY][&PROMONAME=...][&INVQUERY=...][&FORMAT=PDF|TEXT][&COLUMNS=...]
Key Parameters:
-
FORMAT: PDF, Text, CSV, or HTML -
COLUMNS: Select which fields to export (ISBN, title, author, price, status, etc.) -
PROMONAME: Limit to specific promotion
Displays (in browser or downloadable file):
- Title list with selected columns
- Sortable by ISBN, title, author, pub date, price
- Prices shown based on customer class (if logged in)
User Actions:
- Select export format
- Choose columns
- Download file
-
PRINTTABLE(line 97611): Generic table export -
CREATE_OUTPUT_DATA(line 98544): Build export dataset -
PROOF_WEB_COPY(line 102948): Proof / QA copies (internal) -
CREATE_PROMOTIONS(line 108480): Promo booklet generator -
CREATE_PROMOTIONS_WRITE(line 108706): Process promo creation form
PRINT_STANDING (line 129164)
Purpose: Print standing order summary; customer standing order statement / renewal notice
Entry Point: bzp=PRINT_STANDING[&z03=CUSTKEY][&FORMAT=PDF]
Displays:
- Customer standing order profile
- Current plan (type, selections/month, pricing)
- Recent shipments (dates, titles, totals)
- Renewal date / expiration notice
- Payment method
GEN_EDITORIAL_MENU (line 117941)
Purpose: Editorial staff interface; manage published content, reviews, author info
Entry Point: bzp=GEN_EDITORIAL_MENU (staff only, z06 required)
Displays:
- Editorial content list (by topic, author, title)
- Edit/delete controls
-
GEN_EDITORIAL_SHOW(line 120589): Display editorial content -
GEN_EDITORIAL_UPDATE_COMPUTER_SALES(line 119991): Update sales tracking
GEN_ONIX_MENU (line 115725)
Purpose: ONIX metadata management; export ONIX format (industry standard book data)
Entry Point: bzp=GEN_ONIX_MENU (z06 required)
-
GEN_ONIX_WRITE(line 116055): Export ONIX data
GEN_ORDER_MENU (line 120589)
Purpose: Staff order entry; quick add orders for customers (phone orders, manual entry)
Entry Point: bzp=GEN_ORDER_MENU[&z06=MASTERPASS]
Displays:
- Customer lookup/selection
- Title search panel
- Order form (similar to GENERAL_VIEW but staff-driven)
Domain 9: EDI & DATA EXCHANGE
Primary Views
EDI_MENU (line 139788)
Purpose: EDI (Electronic Data Interchange) admin; manage inbound/outbound order feeds; file management
Entry Point: bzp=EDI_MENU (z06 required)
Displays:
- Incoming EDI files (orders from trading partners)
- In-process files (being parsed/imported)
- Outgoing files (acknowledgments, shipping notices, invoices)
- File status, timestamps, line count, error logs
User Actions:
- View incoming order feed
- Trigger import
- Resend confirmation / shipping notice
- Download error report
EDI_MANIFEST (line 142884)
Purpose: Generate EDI shipping manifest; structured shipment data for EDI partners
Entry Point: bzp=EDI_MANIFEST[&z03=CUSTKEY|z06=MASTERPASS]
Displays:
- Shipment items (line count, totals)
- Structured EDI format (ORDERS, ORDERS message, line items, pricing)
- Print-ready manifest
Domain 10: NOTIFICATIONS, EMAILS & MESSAGING
Primary Views
EMZ_SERVICES (line 66208)
Purpose: Email marketing / promotion management; bulk email campaigns
Entry Point: bzp=EMZ_SERVICES[&z03=CUSTKEY|z06=MASTERPASS]
Displays:
- Service offerings (email templates, announcement options)
- Customer opt-in status
- Email history
-
EMZ_SERVICES_VIEW(line 66906): Email template viewer -
EMZ_SERVICES_WRITE(line 68201): Save email preferences -
EMZ_SERVICES_CHARACTERS(line 54634): Character limit check for email -
EMZ_UPCOMING_TITLES(line 54756): Auto-email upcoming titles to customer
NEW_EMAIL_CONSOLE (line 136450)
Purpose: Unified email management; compose & send emails to customers
Entry Point: bzp=NEW_EMAIL_CONSOLE[&z06=MASTERPASS]
-
NEW_EMAIL_BASIC_UPDATE(line 136961): Send basic notification email -
NEW_EMAIL_START_UPDATE(line 137021): Send order confirmation email -
NEW_EMAIL_INVENTORY_UPDATE(line 137131): Send inventory notification -
NEW_EMAIL_BACKORDER_UPDATE(line 137722): Send backorder status update -
NEW_EMAIL_CREATE_FILE_UPDATE(line 139514): Create email template file
MAIL_CONSOLE_MENU (line 85519)
Purpose: Internal staff communication; mail/note system
Entry Point: bzp=MAIL_CONSOLE_MENU[&z06=MASTERPASS]
Displays:
- Staff inbox (messages from other staff)
- Message threads (by customer, project, topic)
- Unread count, recent activity
-
MAIL_CONSOLE_WRITE(line 85783): Compose staff message -
MAIL_CONSOLE_READ(line 126260): Read staff message -
MAIL_CONSOLE_CS(line 126260): Customer service variant -
DELETEMAILHEAD/DELETEMAILWRITE(lines 94671, 94729): Delete message
MAIL_CONSOLE_MENU (variants)
Domain 11: PLATFORM & UTILITY FUNCTIONS
Core UI Procedures
WEBSITE_HEAD (line 45014)
Purpose: Page header template; includes nav, logo, session info
Displays:
- Site logo & branding
- Top navigation (Home, Catalog, My Account, Log Out)
- User greeting (if logged in)
- Search bar
WEBSITE_TAIL (line 45796)
HEADER_INFO (function, not procedure)
Purpose: Build HTML header with