Skip to main content

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.

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 User model implements Laravel's Authenticatable contract 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 separate Password model — purely as a data mirror, without authentication logic. A HasPermissionsTrait on the User model provides permission-checking methods (canNow(), viewProps(), etc.) used across the authorization system. Permissions are checked via policies (in app/Policies/) that guard feature access and data mutation. Configuration is in config/auth.php with 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 in staff/dbf-service/config/dbf.php and the rebuild logic in RebuildApplicationTables.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-audit skill 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-service on :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):

  1. Purpose — what the domain does for users (plain language)
  2. Legacy behavior — how the VFP app did it
  3. Ported forward + enhancements — "Legacy does X; carried forward as Y; enhanced with Z; diverged at W and why"
  4. Current state — shipped / in progress / planned (durable roadmap intent, not live ticket status)
  5. Decisions & directional rulings for this domain
  6. > 🔧 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/submit or 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:8816596021, STANDING_WRITESTANDING_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 manual HANDLING='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 > 0 per legacy logic)
  • Orders are grouped by a SORTORDER hash — the key contract with Pitney Bowes (external shipping app) — built by concatenating NN (2-digit zero-padded plan quantity) + a 2-letter SERIES code (the sosmart map: 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,1 flags) awaiting Pitney Bowes; PB reads groups by SORTORDER, makes labels + weights, then flips PSHIP→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 a BACKHEAD (back-order head) with OSOURCE='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_orders mirror and links via a code in the RESTATE field, 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 NEWPRODUCT so 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 from inventories with ONHAND=FINALINV (legacy parity for availability basis); no production inventories mutation during build
  • SORTORDER v1 (regular-series) reproduces the legacy NNXX hash exactly — source of truth is Plan::plansInfo()'s sortorder_code per class; Pest-verified against two ground-truth samples
  • SORTORDER v2 (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 SORTORDER order 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_at marker)

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-picks cron, : reserved; manual TradeOrderService::ensureTradeCart on account first touch)
  • Title exclusions (TradeTitleExclusion, mirroring legacy ORDREASON=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 TradeBudgetResolver as 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 NNXX codes) 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 commands standing_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_orders mirror; 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-consistency tab flags address inconsistencies across eligible plans; dismiss per plan (legacy parity: STANDING_CLEAN :95495StandingOrderBillingConsistencyService)

Known Gaps / Open Decisions:

  • FINALINV in-app freshness — the app trusts the DBF-mirrored FINALINV for 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 & lifecycleapp/Models/StandingOrder.php, app/Models/HeldStandingOrder.php (hold audit mirror)
  • Invoicingapp/Http/Controllers/InvoiceThisPlanController.php, app/Services/StandingOrderInvoiceGenerationService.php (prepaid pipeline)
  • Fulfillmentapp/Actions/FulfillStandingOrders.php (2-stage action: queue_inventory + queue_orders; the former create_special/create_regular stages were retired in M5 → operator-driven ship via CMSPlansController::fulfillShip), app/Models/Queue/StandingOrder.php (builder + materializer), app/Services/Pricing/TradeBudgetResolver.php (trade ceiling)
  • Custom-mixapp/Services/CustomMixProfileService.php (profile/fallback resolution), app/Models/Configuration.php (per-plan configs)
  • Tradeapp/Models/TradeCMS/TradeTitleExclusion.php, app/Models/TradeCMS/TradeAuthorSubscription.php, app/Services/TradeAuthorAutoPickService.php
  • Routesroutes/web_auth.php: cms.plans.* (editor + fulfill tab), db.standing-orders.* (detail editor), db.held_standing_orders.* (hold audit)

Source Documentation (the spec):

  1. LEGACY_VFP_STANDING_ORDER_PIPELINE.md — 9-stage legacy reconstruction, SORTORDER decode map, Pitney Bowes contract, parity/regression matrix, locked forward design (v1 + v2 + auto-gating)
  2. STANDING_ORDERS_REFERENCE.md — staff-facing plan lifecycle overview, fulfillment mechanics, trade plans, custom-mix config, gap matrix
  3. 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. nonefull, 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, applyShipmentHandoff collapse
  • 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_testing DB is owned by test:init; never mutate schema in tests

Critical Gotchas:

  • Legacy is still the validator. The app fulfillment is a working reimplementation, but webnet.prg STILL runs production and still writes the DBF. The legacy SORTORDER stays authoritative until the app can prove monthly scorecard parity (same distinct values for the same month).
  • SORTORDER is 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 ONHAND is 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_inventories and queue_broheads with INVNATURE scoping (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 with zombie_prev_purchased, ensuring consistency.
  • Direct-to-production preorder materialization (D-T-8): trade lines skip the brokered (bro*) queue entirely and go straight to backhead/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: getTradeTitles selection + wiring to FulfillmentScope (: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-trade UI surface (:fe16c8227, shares Fulfill.vue via scope prop, no fork)
    • Stage 5: parity verification scorecard (:9a47e265c + :9f5da77b1, all dimension verdicts green; 268 test assertions)
  • Trade Title Exclusion & Author Subscriptions (REMDETAIL + AUTPICKS) (20260704, :PARITY.md row 49):
    • TradeTitleExclusion (trade_title_exclusions table: account_key + nullable isbn/author, mirroring legacy ORDREASON discriminator) enforced on new-title add (silent skip, legacy-faithful) (:TradeCMS\Models\TradeTitleExclusion)
    • TradeAuthorSubscription (trade_author_subscriptions table: account_key + author) + TradeAuthorAutoPickService (daily 08:30 ET schedule via trade:sync-author-picks) matching Not-Yet-Published TRADE inventory by subscribed author (:TradeCMS\Models\TradeAuthorSubscription, :TRADE_AUTPICKS_SCOPE.md)
  • Trade stock reconciliation (20260704, :PARITY.md row 54): count_returned / count_shrinkage columns + accessors on Book model, Advanced Search filters, mirroring legacy TradeBooksReturnsProcessed / TradeBooksShrinkage exactly (: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.md row 51 "Trade PO admin + publisher emails" — unaudited): CMS resources exist (purchase_orders + suppliers) but parity with legacy TRADE_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, TradeAuthorSubscription models + 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 with StandingOrdersPreview::setPlans() (stage-gated by auto_build plan classification).
  • FulfillTradeStandingOrders action class (extends FulfillStandingOrders, overrides scope() only) + corresponding ActionRun (distinct action_id='fulfill_trade_standing_orders' for parity-isolation, D-T-1) (:app/Actions/FulfillTradeStandingOrders.php).
  • TradeLinePricer (:45e0dbf89) — final class with a static price() 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 materialized Backhead matches 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, sliceCoverage take $scope parameter and filter by INVNATURE (:queue.standing_order.php routes).

UI routes & controllers:

  • GET /standing-orders-cms/fulfill-tradeCMSPlansController::fulfillTrade($scope='trade') (Stage 4, :fe16c8227) → delegates to Pages/Plans/Fulfill.vue with scope prop (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 optional scope request field, redirect back to whichever page posted (Stage 4).
  • Ship form: surfaces both dedup_open_backorder (trade-only) and zombie_prev_purchased toggles (Stage 3/4, D-T-6).
  • CmsTabs.vue — new "Fulfill Trade Plans" tab alongside regular Fulfill tab (Stage 4).

DB models / migrations:

  • trade_title_exclusions table — columns account_key, isbn (nullable), author (nullable), created_by; no type/value columns 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_subscriptions table (account_key + author, uniqued).

Commands:

  • trade:sync-author-picks (daily 08:30 ET, routes/console.php) — scheduled command running TradeAuthorAutoPickService (Stage 1 / AUTPICKS, :TRADE_AUTPICKS_SCOPE.md).

Ops checklist (live first batch):

  • Verify queue seeding (seedInventory TRADE 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_backorder toggle 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 − ALLSALES and re-seeded before standing-order runs (onhand := finalinv at :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/inventory admin 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-available Artisan 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_inventories and queue_order tables (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; uses PredictiveQueryBuilderService to detect ISBN/scope/text and chooses strategy. Confidence thresholds at 0.85 (HIGH), 0.72 (MEDIUM, rejected). InventorySearchScoringService weights matches; CompareInventoryTitle::bestMatches() powers text search relevance.
  • /search GET endpoint — customer + staff, returns books with pricing + standing-order-plan eligibility.
  • /db/inventory/{id}/edit PATCH endpoint — per-role field gating (inventory controller); DBF sync via PrintRunInventoryService receive/correct actions.
  • inventory:make-available {YYYYMM} Artisan command (20260702, :b58f91238) — idempotent; runs AFTER orders:release-backorders and orders:archive-sweep (run-last doctrine). Performs the bulk ONHAND/ALLSALES recompute and status flip inline in MakeAvailable::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. FastprintVendorRow staging + 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.mddefinitive — 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)) and standing_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 promotions table row claims exactly one slot (western, romance, etc.) via inventory_slot_id and carries metadata, pricing config, and templates (Promotion model).
  • 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_assignments JSON column, keyed by channel + bucket scope, with fallbacks to system universals (zero PromotionTemplate pivot 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: CampaignOption enum drives wizard step creation; a hardcoded whitelist in BuildCampaignDrafts::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:

  • Template authoring UI (promotion-specific email/mail template editor) — Phase 1 core (§8.5 dynamic_promotions_plan.md).
  • Per-campaign template strategy selection (universal vs. per-bucket rendering) — wired, awaiting template resolution at send time.

Deferred:

  • Slot pool expansion UI (Tier 13) — only needed when 11/17 free slots exhaust.
  • Legacy StandardPromotion enum 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 CampaignOption enum cases must be added to BuildCampaignDrafts::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). Extend BaseCampaignProcessor for standard filtering; AbstractCampaignProcessor for custom logic.
  • BuildCampaignDrafts::normalizeOptions(options) — Whitelist gate on CampaignOption enums; consumed by all BuildCampaignDrafts-based paths (⚠️ BuildCustomEmailList in app/Jobs has its own separate, non-whitelisted normalizeOptions) (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; findOrCreate and makeStringId helpers not yet implemented.
  • ⏳ Account_addresses contact_id FK — pivot rows will reference canonical contacts instead of inline VOICEPHONE/FAXPHONE; migration splits default_plans into 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-key command — scope with --limit= / --only=, or use php 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-available auto-marks eligible titles (6+ months past publication, no prior fastprint mark, FASTAVAIL≠'NO').
  • Create batch: orders:fastprint create generates 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-check polls 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:

  1. 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.
  2. 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.
  3. 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 (with PEPACK=PIPACK=PINVOICE=1): order is awaiting Pitney Bowes (labels + weight calculation).
  • PSHIP=0 (with PEPACK=PIPACK=PINVOICE=1): Pitney Bowes has processed the order and flipped the flag; the app may now proceed with in-house printing.
  • PSHIP=0 (with PEPACK=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 distinct SORTORDER values.
  • 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' PSHIP flag from 1 to 0, 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 .dot templates (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:

  1. Stage 0 — Queue Orders

    • StandingOrdersPreview::buildOrders() iterates enrolled plans for each account, selects eligible titles from the month's FINALINV inventory (gated on INVNATURE='CENTE'), and builds queue_brohead / queue_brodetail rows.
    • For each plan, calls getRegularTitles(), getBestSellerTitles(), or getCustomMixTitles() 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 DESTINATION flag (Pitney Bowes vs. downstairs vs. held).
    • (Source: app Queue\StandingOrder::buildOrders(), StandingOrdersPreview::buildSortorderCode().)
  2. 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 DESTINATION on each head (FulfillmentDestination::PitneyBowes, Downstairs, ManualReview, or Held).
    • Operator can manually override individual heads or entire groups (the moveOrders UI allows setting routing_approved_at for safety gates).
    • (Source: app Queue\StandingOrder::shipQueue(), CMSPlansController routing UI.)
  3. Stage 2 — Materialize

    • Queue\StandingOrder::materializeHead() (operator-triggered) converts queued heads to live brohead / brodetail rows with assigned TRANSNO identifiers.
    • Reads the DESTINATION flag and sets the PSHIP + PEPACK / PIPACK / PINVOICE flags: 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 HeadRetallyService to recalculate totals (AMOUNT, TAX, SHIP, etc.) and StandingOrderBillingConsistencyService to ensure address consistency across the plan.
    • (Source: app Queue\StandingOrder::materializeHead(), HeadRetallyService, flag-setting logic.)
  4. 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:

  1. Pre-clean: delete orphaned detail rows from prior months (no matching head).
  2. Load back-order heads (where PSHIP=4 or similar held flag) and inventory for the target publication month.
  3. Merge: for each account, combine multiple held heads into one (via HeadMergeService).
  4. FIFO allocate: sort by TRANSNO (oldest first), distribute available stock.
  5. Partial fulfillment: lines that don't get stock remain held; lines that do are stamped for fulfillment.
  6. Discount overlay: apply any standing-order or promotion discounts.
  7. Append to live heads.
  8. 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:

  1. Flag-available (orders:fastprint flag-available): scan titles by publication date, find those without a prior fastprint mark and with FASTAVAIL='YES' (or unset, subject to auto-rule gate), and set FASTPRINT='Y' on the title row.
  2. 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.
  3. Export & send to vendor: the FastPrintVendorRow staging table accumulates rows; a combined CSV is exported and handed off to the print vendor (usually monthly).
  4. 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:

  1. Select batch: operator chooses a date range, order type, or account scope via the console.
  2. Render PDFs: fetch order data, render invoices and packing lists via .dot templates, accumulate into a batch print job.
  3. Generate manifest: extract shipment metadata (addresses, carrier, tracking numbers if known) and format as EDI or CSV for the carrier.
  4. Preview & commit: operator previews the batch before printing to PDF or sending to the printer.
  5. 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):

  1. ArchiveProcessedOrderAction or orders:archive-sweep moves shipped heads (where all lines have shipped) to archive.
  2. 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.
  3. Soft-lifecycle audit (legacy bridge): SoftLifecycleAuditService infers timestamps for legacy writes that bypassed the app (e.g., Pitney Bowes flipping PSHIP=0 directly on the DBF).
  4. 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-key option).
  • 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 by PROD_NO; FINALINV, ONHAND, ALLSALES columns; FASTAVAIL for POD eligibility).
  • Standing orders: standing_orders (plan subscriptions), plans registry in Plan::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 ActionRun model (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..16 loop) ↔ 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

  1. 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) and 01AA01BB (2-series group).
  2. Pitney Bowes handoff: Orders bound for Pitney Bowes must be written with PSHIP=1 (1,1,1,1) at materialization (not 0). Only Pitney Bowes, when done, flips PSHIP→0. Non-Pitney (downstairs, fast-print) orders skip to 0,1,1,1 directly.
  3. FINALINV gate: Standing-order title selection and back-order release must gate on INVNATURE='CENTE' and target FINALINV (not ONHAND or current stock). FINALINV is set once per month by inventory:make-available (after orders:release-backorders + daily rebuild).
  4. 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 in SORTORDER order.
  5. Never mutate production inventory: The queue build uses a staged queue_inventories snapshot; ONHAND is never decremented during fulfillment (only the queue snapshot is). FINALINV is reset to zero at month-end (after fulfillment and archive).
  6. Idempotency & resumability: Each fulfillment stage is an ActionRun that can be resumed without duplicating heads. Guard against re-running with materialized_at timestamps or a run_id foreign 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-46isExpiring()/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 by PlanEventContext::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:

  • Page button (useBrutusTrigger({intent}).invoke(account) in any Vue component) — opens the chat modal/dock + boots the session + fires /chat/sessions/init.
  • Canvas chip (inside an existing widget, emit('drill', {intent, params})) — swaps the panel in-harness, instant (chat already open).

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. AccountTransactionEvent domain-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, useBrutusTrigger composable, unified tool-completed event.
  • ✅ 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 (bulkReschedule in DashboardController.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.