PRD Page 2
13.5 Decisions & directional rulings
| Ruling | Date | Rationale |
|---|---|---|
| Email on every plan event — one event = one email + notification + custnote. No event batching. | 20260701 | Transparency + audit trail; each action is atomic and logged. Pace (qty×2mo) prevents notification fatigue. |
| Contact-type notes = log-only — no auto customer email. Manual contact is staff's action, not a customer-facing event. | 20260701 | Clarity: contact-note is internal documentation; email goes only to the note author (in-app). |
| Notifications from a shared account (nstewart@), not individual staff. | 20260701 | Single sender reduces support confusion; individual author tracked in transactions.by_email. |
| Check Plan Health is universal — applies to all plan types (Choice, Trade, Regular), not just Choice. | 20260702 | Parity: legacy had no equivalent; modern adds uniformly. Trade gets its own email summary block (not the Choice "titles selected" template). |
Account health derivation, not persistence (for now). Flags computed on-read from transactions rows (codes 38–46) + StandingOrder lifecycle scopes. |
20260701 | Faster iteration; permanentization to account_meta columns + batch classification = deferred pending flag-set stabilization. |
13.6 🔧 For builders
Core event pipeline:
-
Handler entry:
CheckPlanHealthController::check()(app/Http/Controllers/CheckPlanHealthController.php:31, routeweb_auth.php:715) — the on-demand health check; delegates toPlanLifecycleEvaluatorand fires the sameAccountTransactionOccurredevents as the daily scan. (There is noAskBrutus::checkPlanHealth().) -
Event dispatcher:
AccountTransactionEvent::dispatch(…)— fires once per event code (38–46); side effects auto-discovered via Laravel listeners. -
Side effects:
app/AccountEvents/SideEffects/—SendEventEmail,NotifyAccountOwner,WriteEventCustnote,LogEventTransaction. -
Test suite:
tests/Feature/AccountEvents/— domain-specific + pipeline integration tests (264+ assertions across SO + contact). -
Config:
config/cp.php→notifications_sender_email.
Plan lifecycle + health:
-
Detection:
ScanPlanLifecycle(command),PlanLifecycleEvaluator(service). Scopes:StandingOrder::expiringChoice(),::upcomingCanceling(), etc. (:3155–3249). -
Health check:
CheckPlanHealthController, invoked fromPlanActionsWidget.vue(chat canvas) +StandingOrderEdit.vue(page). -
Derived flags:
AccountHealthService::contactHealth()(RECALLD ±7-day due window),::planHealthBadge()(has_expired_choice, has_short_choice, etc.),::lastNotified()(MAX transaction created_at per code).
Brutus chat architecture:
-
Intent registry:
AskBrutus.phpconstants (INTENT_*) +config/chat.phpaliases +resources/js/composables/brutusIntents.js. -
Page-button invocation:
useBrutusTrigger({intent, mode, contextPage, onCompleted}).invoke(account)— composes → opens modal/dock → boots session → handler response → canvas render. -
Canvas chip invocation:
emit('drill', {intent, params})oremit('open-side', {intent, params})— instant, no session init (chat already open). -
Response handler:
ChatSurface.loadWidgetFromToolCode()readschrome.title+type+ widget-specific payload; dispatches toCanvasPanel.vuewidget selector. -
Completion: Widget emits
tool-completed({intent, status, payload})→useBrutusTriggersubscriber callback → custom cleanup (reload, redirect, etc.). -
Rendering modes:
modeprop onChatSurface:full(thread + canvas + composer) |canvas(canvas + composer) |canvas-bare(canvas only) |minimal(composer only) |deferred(lazy, sized-to-content). Seebrutus_inline_embed_modes.md.
Knowledge Base (future):
-
Chunk store:
doc_chunkstable (durablechunk_id,doc_path,anchor,heading_path,raw_text,audience,embeddingslot). -
Sparse index: Positional postings (
term → (chunk_id, tf, positions)) + corpus stats for BM25. - Tokenization: Lowercase/unicode-fold; preserve identifiers (RECALLD, ISBN, account KEY); Snowball stem + protected list; idf-soft stopwords; query-time synonym expansion.
-
Service:
KnowledgeBaseSearchinterface (swappable engine behind it). -
Command:
chat:eval-docs-search(hit@1 / hit@3 / MRR againstquestion → expected chunkfixture). -
UI integration: Upgrade existing
DocumentationWidgetsearch to the new chunk search (section cards w/ snippet + anchor link). - See
KNOWLEDGE_BASE_AND_LLM_INTEGRATION.md(design locked, pre-implementation).
References:
- Legacy email:
LEGACY_VFP_STANDING_ORDER_PIPELINE.md§ "Email notifications" - Parity matrix:
PARITY.mdrows "Notifications & Email" - Todo ledger:
todo.md§ "Notifications & email content — core SHIPPED" + "Manual staff contact-notes → partial pipeline" - Ubiquitous chat:
brutus_ubiquitous_chat.md(intent dispatch, invocation surfaces, primitives reference) - Embed modes:
brutus_inline_embed_modes.md(mode prop, sizing, height gotcha, composer laziness) - KB design:
KNOWLEDGE_BASE_AND_LLM_INTEGRATION.md(data architecture locked, retrieval-first, Levels 0–2 generation)
14. Reports & Analytics
14.1 Purpose
Reports and analytics provide operations and management teams with visibility into sales trends, inventory velocity, standing order health, account performance, and fulfillment metrics. These dashboards and exports allow staff to monitor business health, plan inventory acquisitions, track fulfillment, analyze marketing effectiveness, and manage standing orders.
14.2 Legacy behavior
The legacy VFP application offered a limited set of hardcoded reports accessible through menu systems. Key legacy reporting features included:
- Order/sales summaries by type
- Sales-by-plan-type breakdowns
- Inventory lifecycle overviews (title availability, new releases, clearance stock)
- Limited ad-hoc filtering (by account key, date range, or territory)
Inventory projection (GEN_ORDER_MENU_INVPROJECT :122921):
- ISBN quantities sold and sales projections
- Basic depletion estimates based on historical velocity
- Export to spreadsheet for planning meetings
Specialized reports:
- Bulk mailing-list generation (
BULKMAIL_MENU_*:98795–99713) - Standing Order sales ledger (
TD_VIEW_LEDGER_SALES:158435) - Promotion series text files (
CREATE_PROMOTIONS:108480) - Catalog and title-list spreadsheet exports (
PRINTCAT:98465,CREATE_OUTPUT_DATA:98544)
Reports were often run ad-hoc, output to files on disk, or printed directly. Caching and reusability were manual (recalculate from scratch each time or maintain cached spreadsheets).
14.3 Ported forward + enhancements
The modern application consolidates and significantly expands the legacy reporting capability through a unified Analytics Dashboard service architecture, with 24 fully implemented reports across eight categories.
Services — Reporting is organized into domain-specific services (app/Services/Reports/Analytics/):
-
SalesService— Annual/monthly comparisons, multi-year trends, all-time metrics -
SOPStatisticsService— Standing order vital signs, year-over-year analysis, 12+ month projections -
InventoryVelocityService— Sales velocity calculations for depletion forecasts -
InventoryDepletionService— "No new ISBNs" scenario modeling (5-year ISBN lifetime, urgency categorization) -
InventoryAcquisitionsService— Acquisitions pipeline and timeline tracking -
PromotionOverlayService— Marketing promotion tracking and performance -
RoyaltiesReportService— Annual billing and royalty status -
DataLensService— Unified API backend for analytics endpoints
All services support consistent filtering across reports: date range, account keys, inventory nature (CENTE/TRADE), and order type (daily/standing).
Report categories — Ported from legacy ad-hoc menus and expanded:
- Sales & Performance (5 reports) — Annual performance, monthly comparison, multi-year trends, sales-by-plan-type, executive dashboard ("Big Picture")
- Standing Order Plans (3 reports) — Vital signs, year-over-year, 12+ month projections
- Inventory (4 reports) — Lifecycle metrics (Not Yet Published → Promotional → Clearance → Expired), CENTE vs TRADE comparison, heavy stock analysis, historical snapshots
- Analytics Breakdowns (10 reports) — Performance by bestseller flag, territory, series, format, division, non-consignment returns, order type, campaign, marketing channel, sales presentations
- Account Analytics (7 reports) — Summary, age breakdown, type breakdown, plan type breakdown, purchase metrics, health indicators, activity tracking
- Projections (3 reports) — No New ISBNs scenario, depletion calendar, acquisition recommendations
- Acquisitions & Production (3 reports) — Acquisitions status, acquisition timeline, production timeline
- Royalties (2 reports) — Annual billing and royalties planning
Enhancements over legacy:
-
INVPROJECT parity achieved — ISBN quantities/sales projection (ported from
GEN_ORDER_MENU_INVPROJECT:122921) now implemented inapp/Services/Reports/Analytics/InventoryDepletionService.php; production deployment pending (ReportsController::STUB_TARGET_DATE~2026-07-15) -
Persistent caching — Reports are saved to
storage/app/private/reports/as JSON files; 24-hour indefinite cache prevents regeneration (unless explicitly bypassed with--no-cacheflag) -
Scheduled CLI execution — Long-running reports (20–30 minutes for 2-year datasets) no longer timeout on the web;
artisan experience:generate-report [--from=DATE --to=DATE]generates with progress tracking -
Unified service pattern — All report logic is encapsulated in
CompanyReportServiceand domain-specific analytics services, eliminating controller sprawl - Rich filtering — All reports support account key, date range, inventory nature, and order type filters (legacy supported ad-hoc filtering on a few reports only)
- Multi-format exports — Reports available as JSON (default, for web), with future CSV/Excel/PDF support planned
- No-new-ISBNs scenario — Forecasts what happens if no acquisitions occur (depletion timeline, urgency recommendations) — no legacy counterpart
Divergences from legacy (all approved):
- Mailing-list generation simplified ("pare-by-year" and "1-contact-per-account" advanced shaping = open todo, not yet ported)
- Catalog/spreadsheet exports replaced by
inventory lists + downloads— legacy.TXTper-series generation deprecated (confirm if consumers still exist) - Memo-field (FPT) writing still unimplemented on Rust API side (memo reads exist; writes stripped)
- Editorial cost-of-goods (per-title CGS) not located app-side; deferred pending accounting system clarification
14.4 Current state
Shipped:
- ✅ Analytics Dashboard (
/analytics) with 24 reports fully implemented - ✅ Report service architecture with persistence and caching (
CompanyReportService,app/Services/Reports/Analytics/) - ✅ Artisan command for scheduled/long-running report generation (
artisan experience:generate-report) - ✅ SOP statistics, year-over-year, and projection reports (
SOPStatisticsService) - ✅ Sales reports (annual, monthly, multi-year trends, by-plan-type) (
SalesService) - ✅ Inventory lifecycle, CENTE/TRADE comparison, and heavy-stock analysis
- ✅ Account analytics (summary, age/type/plan breakdowns, health, activity)
- ✅ Campaign performance and marketing channel attribution
- ✅ Acquisitions and production timeline reports
- ✅ Royalties reporting
- ✅ Depletion calendar and acquisition recommendations
In progress / Partial:
- 🧪 INVPROJECT (ISBN quantities/sales) — production stub — implemented and tested; production deployment deferred to ~2026-07-15 pending final ops sign-off (
ReportsController::STUB_TARGET_DATE) - 📋 Mailing-list generation — advanced shaping (pare-by-year, 1-contact-per-account) open (todo §3)
- 📋 Spreadsheet-designer upgrade — catalog/title-list exports use CSV; advanced filtering/formatting = open todo
- 📋 Acquisitions management — transitional phase: spreadsheet is source of truth (
ACQUISITIONS_SPREADSHEET_PATH=storage/app/acquisitions/inventory_acquisitions.csv); read-only website metrics viaInventoryAcquisitionsService(full CRUD will be enabled whenACQUISITIONS_TRANSITION_MODE=false)
Not yet ported:
- Editorial cost-of-goods analysis (per-title CGS unit vs reprint cost) — awaiting accounting system clarification
- Flier title spreadsheets with purchase-history checks (CHECKHIST/ISCHOICE parity unverified)
- SO-series title XLS transfer (CLASS_* mapping :105947–106065 in webnet.prg; parity unaudited)
- Web-copy proofing emails (legacy
PROOF_WEB_COPY:102948; decide port vs n-a)
14.5 Decisions & directional rulings
D12 — No New ISBNs Projection (5-year ISBN lifetime)
All depletion projections use a fixed 5-year ISBN lifetime from PUBDATE. Titles older than 5 years are considered "expired" and excluded from recommendations. This matches legacy's acquisition planning model and ensures forecasts remain realistic across the catalog.
D13 — Caching is indefinite unless explicitly bypassed
Reports are stored persistently in storage/app/private/reports/ and never expire automatically. This is a deliberate trade-off: accuracy is secondary to performance and predictability. Users who need fresh data must explicitly use --no-cache flag (which archives the old report with a timestamp and regenerates). This prevents accidental multiple-hour regenerations when a report is requested twice in the same session.
D14 — Acquisitions spreadsheet = source of truth (transitional)
During the transition period, inventory acquisitions data lives in a CSV spreadsheet (ACQUISITIONS_SPREADSHEET_PATH). The website provides read-only analytics over that file. CRUD operations are disabled (ACQUISITIONS_TRANSITION_MODE=true). When ready, ACQUISITIONS_TRANSITION_MODE=false will unlock full in-app management. This prevents dual-source data conflicts during the bridge period.
D15 — Report generation runs on CLI, not web
Long-running reports (20–30 minutes for 2-year datasets) are generated via artisan experience:generate-report, not the web interface. This prevents timeout errors and allows asynchronous scheduling. The web interface (/analytics/reports/company/download) retrieves cached reports or shows generation-in-progress status if a lock is active.
D16 — Report JSON format (future: CSV/Excel/PDF)
Current reports export as JSON (consumable by web UI and by programmatic API). Future support for CSV, Excel, and PDF formats is planned. The service layer (CompanyReportService::generateReport()) is format-agnostic; output format is a parameter.
14.6 🔧 For builders
Services and key methods:
-
app/Services/Reports/Analytics/DataLensService— Unified API backend for all analytics endpoints-
resolve(string $reportKey, array $filters)— Dispatch to domain-specific service based on report key - Returns report data ready for JSON response or export
-
-
app/Services/CompanyReportService— Persistent report orchestrator-
generateReport(string $dateFrom, string $dateTo, bool $useCache = true): array— Generate or retrieve cached report -
getReport(string $dateFrom, string $dateTo): ?array— Get cached report without generation -
listReports(): array— List all available cached reports - Stores reports in
storage/app/private/reports/(configurable disk viaconfig('filesystems.reports_disk'))
-
-
app/Services/Reports/Analytics/SalesService— Sales metrics-
getAnnualPerformance(),getMonthlyComparison(),getMultiYearMonthlyBreakdown(),getSalesByPlanType(),getBigPicture()
-
-
app/Services/Reports/Analytics/SOPStatisticsService— Standing order metrics-
getSOPStatistics(),getSOPYearOverYearComparison(),getSOPProjections() - Active SOP logic:
EDATE > today OR EDATE is null,SDATE is not null,CANCELDATE is null
-
-
app/Services/Reports/Analytics/InventoryVelocityService,InventoryDepletionService,InventoryAcquisitionsService— Inventory forecasting -
app/Services/Reports/Analytics/RoyaltiesReportService— Royalties reporting-
getRoyaltiesReport(),getRoyaltiesPlanning() - Prepaid calculation: 10% of list price
-
Controllers:
-
app/Http/Controllers/AnalyticsDashboardController— Web dashboard endpoints (/analytics)- Queries
DataLensServicefor report data - Returns Inertia-rendered
Dashboard.vue
- Queries
-
app/Http/Controllers/InventoryAcquisitionsController— Acquisitions management (/inventory/acquisitions)- CRUD operations conditional on
ACQUISITIONS_TRANSITION_MODEconfig
- CRUD operations conditional on
-
app/Http/Controllers/ExperienceController— Long-running company reports-
companyReport()— Display report summary -
download()— Retrieve or generate report; usesCompanyReportService
-
Routes:
-
GET /analytics— Analytics dashboard (dispatches to multiple reports) -
GET /analytics/{reportKey}— Single report endpoint (usesDataLensService) -
GET /analytics/snapshots— Historical inventory snapshots -
GET /analytics/projections/no-new-isbns— Depletion scenario analysis -
GET /inventory/acquisitions— Acquisitions management page -
GET /experience/reports/company— Company report summary -
GET /experience/reports/company/download— Download company report (with caching)
Artisan commands:
-
php artisan experience:generate-report [--from=DATE --to=DATE] [--no-cache]— Generate or retrieve company report- Default date range: last 2 years
-
--from/--to: Override date range (YYYY-MM-DD format) -
--no-cache: Force regeneration, archive old report with timestamp - Output: JSON file in
storage/app/private/reports/ - Duration: 20–30 minutes for 2-year datasets
Filter parameters (supported across all reports):
-
invnature— null | 'CENTE' | 'TRADE' -
date_range— [start, end] date strings -
account_keys— Array of account KEY values -
order_type— null | 'daily' | 'standing'
Caching behavior:
- All-time metrics: 1 hour (3600s)
- Current statistics: 15 minutes (900s)
- Projections: 30 minutes (1800s)
- Company reports: Indefinite (manual cache clear required)
Configuration:
-
config/filesystems.php— Definereports_disk(defaults to 'local', can be S3 or custom) -
.env REPORTS_DISK— Customize storage disk -
.env ACQUISITIONS_SPREADSHEET_PATH— Path to acquisitions CSV -
.env ACQUISITIONS_TRANSITION_MODE— true = read-only, false = full CRUD -
app/Http/Controllers/API/ReportsController.php—STUB_TARGET_DATEconstant at line 38 (INVPROJECT production deployment gate)
Development references:
- Source material:
storage/notes/ANALYTICS_REFERENCE.md,storage/notes/ANALYTICS_REPORTS_INDEX.md,storage/notes/REPORT-SERVICE-ARCHITECTURE.md - Legacy VFP legacy source: webnet.prg
GEN_ORDER_MENU:120589,MIS_ORDER_MENU:126571,GEN_ORDER_MENU_INVPROJECT:122921,BULKMAIL_MENU_*:98795–99713 - Parity tracking:
storage/notes/PARITY.md"Reports" section (rows 112–126) - Vue components:
resources/js/Pages/Analytics/Dashboard.vue,resources/js/components/Analytics/*.vue
Testing:
- Test INVPROJECT deployment gate:
ReportsController::STUB_TARGET_DATE - Cache behavior: Generate report twice to verify cache hit on second run
- CLI timeout handling: Run 2-year report via artisan (verify web timeout does not occur)
- Filter combinations: Verify all filters work independently and in combination
- Acquisitions transition: Test both
ACQUISITIONS_TRANSITION_MODE=true(read-only) andfalse(full CRUD)
15. EDI / ONIX / MARC — Metadata Ingestion & Distribution
Centerpoint receives, processes, and distributes bibliographic metadata through three overlapping channels: ONIX (book metadata from publishers), MARC (library-formatted catalogs), and EDI (order/fulfillment messages with trading partners). This chapter documents each pipeline, their current implementation status, and the architecture for builders extending them.
15.1 Purpose
The metadata problem: Centerpoint serves libraries with book orders, but the source data (titles, pricing, publication status, BISAC categories, contributor information, cover images) must flow in from publishers and be reformatted for multiple downstream audiences:
- Librarians searching Centerpoint's public catalog
- Staff reviewing inventory and pricing
- Library systems expecting standardized MARC records
- Trading partners (Baker & Taylor, wholesalers) expecting EDI messages or custom formats
The solution: Three independent pipelines transform vendor data into MySQL, then export in multiple formats on demand.
Scope of this chapter:
- ONIX ingestion (Books-in-Print / publisher feeds) ✅ full parity
- MARC record delivery (library catalog exports) ✅ full parity
- EDI messaging (B&T orders/fulfillment) ⏸️ deferred (generalize for future partners)
15.2 Background: Legacy Implementation
The legacy app (webnet.prg) supported three separate metadata paths, each hardcoded to a specific vendor or format.
ONIX:
Legacy feature GEN_ONIX_MENU (:115721 in webnet.prg, with GEN_ONIX_WRITE :116051) offered a manual UI to export the complete inventory as an ONIX 2.1 file (a date-range / catalog-nature / single-ISBN form → confirm-and-write to a network share, for fulfillment-partner consumption).
⚠️ The legacy menu implemented only this export path. The file-drop ingestion and per-ISBN message-view described in §15.3–15.4 are new-system capabilities (onix-cli +
OnixReconciliationService), not ports of legacy functionality — no ingestion trigger exists inwebnet.prg.
The legacy pipeline was synchronous, file-driven, and tightly bound to Ingram's ONIX 3.0 schema. No scheduling, no message queue, no re-processing — ingest happened once per upload.
MARC:
Legacy feature GENERAL_VIEW_ACCESS_MARC (:20272) provided catalog access through:
- An FTP domain (
marc.centerpointlargeprint.com) - Directory structure matching library codes (e.g.,
/ALI_COUNTY/ALI_COUNTY_2024.mrc) - Legacy staff manually built MARC files using a FoxPro record-to-MARC conversion routine, storing them on the FTP drive
EDI:
Legacy featured EDI_MENU (:139788) as a hardcoded console for Baker & Taylor (B&T) only:
- Inbound: EDI 850 (purchase orders), 856 (advance ship notice), 997 (functional acknowledgment)
- Outbound: EDI 810 (invoices), custom 997 responses
- Key ID: B&T's hardcoded account key
6095400000001(embedded throughout the legacy code)
B&T is now defunct (as of 2026); the EDI infrastructure remains but is deferred pending a broader "distribution-partner pipeline" design.
15.3 Key Concepts
Metadata formats:
- ONIX 3.0 — Industry-standard XML for book metadata. Encodes title, author, pricing, availability, classification (BISAC). Publishers push ONIX messages; Centerpoint injects into the catalog.
- MARC — Library-standard binary/text format for catalog records. Fields are numeric tags (020 = ISBN, 245 = title, 521 = target audience, etc.). Centerpoint reads legacy MARC from publishers; exports MARC to library systems.
- EDI (X12/EDIFACT) — Structured text-based message format for B2B transactions. Segments encode purchase orders, invoices, shipment notices. (EDIFACT = European variant; X12 = North American.)
Pipeline architecture: All three follow a similar pattern:
- Ingest — Raw vendor file arrives (ONIX XML, EDI text, MARC binary/MRK)
- Parse — Format-specific parser extracts structured data
-
Reconcile — Cross-reference with MySQL
inventorytable; resolve ISBNs, pricing, availability flags - Store — Intermediate results cached in a transient store (onix-cli SQLite, EDI file system)
- Export — On-demand output in target format (ONIX XML, MARC file, EDI message, or spreadsheet)
ONIX as source of truth: The ONIX ingestion pipeline is the primary path for new metadata. MARC is read-only input (from legacy data or publishers) but does not auto-update inventory. EDI is order/fulfillment messaging, not title metadata.
15.4 Current State in the App
ONIX Ingestion (✅ Full Parity)
Architecture:
- Standalone CLI tool (
onix-cliat../onix-cli/) handles heavy lifting: XML parsing, ISBN resolution, BISAC mapping, deduplication - Laravel
OnixService(app/Services/OnixService.php:13–74) wraps the CLI, calls it as a subprocess, and streams results back -
OnixReconciliationServicecompares inbound ISBNs against the MySQLinventorytable and flags missing or outdated titles
Routes & controllers (routes/onix_edi.php):
-
GET /onix— List all ingestion runs and their results (OnixController:index) -
GET /onix/{isbn}/pipeline— Show pipeline status for a specific ISBN -
GET /onix/{file_name}/download-file— Download raw XML or result spreadsheet -
POST /onix-receive/{message}— Webhook for inbound ONIX messages from Ingram -
DELETE /onix/{run_id}— Purge a run and its cached data
Scheduled sync (routes/console.php:306–331):
onix:sync --time-limit=540 # Friday 03:00 ET, 9-minute budget
onix:sync --time-limit=540 # Monday 03:00 ET, 9-minute budget
Full pipeline: OnixSync command (app/Console/Commands/OnixSync.php:13–31 class/signature; orchestration in handle() at :33–157) orchestrates:
- Ingest new ONIX files from the watch directory (
config('cp.TRADE_MESSAGES_PATH')) - Resolve ISBNs against inventory via
OnixReconciliationService - Cache results in
storage/onix-store.sqlite(managed by onix-cli) - Export reconciliation report as spreadsheet (CSV)
- Optionally email a daily status report (
OnixDailyReportmail class)
Options (command flags):
-
--dry-run— Show what would be done, no mutations -
--limit=N— Process only the first N ISBNs -
--fresh— Delete the pipeline store and re-ingest from scratch -
--reprocess— Re-resolve all cached ISBNs against current inventory -
--time-limit=S— Time budget in seconds (defer remainder to next run) -
--status— Show pipeline store status and exit -
--history=ISBN— Show message history for an ISBN and exit
Known limitation:
The legacy OnixProduct class (app/Onix/OnixProduct.php:1–12) is marked @deprecated. It was an earlier attempt to parse ONIX in-app; the onix-cli tool supersedes it. Do not use for new work.
MARC Record Delivery (✅ Full Parity)
Architecture:
-
MarcFtpController(app/Http/Controllers/MarcFtpController.php:17–54) gates access to legacy MARC files stored on a network FTP drive -
MarcParser(app/Marc/MarcParser.php:15–99) parses binary.mrcor text.mrkfiles intoMarcRecordvalue objects -
MarcComposer(app/Services/MarcComposer.php) converts between ISO 2709 (binary) and MRK (text) formats -
MarcValidator(app/Marc/Validation/MarcValidator.php) enforces field length and structure constraints
Routes & access:
- Virtual domain:
marc.centerpointlargeprint.com(routes/web.php:28) -
GET /marc— List available library directories -
GET /marc/{library}/{file}— Download a MARC file by library code and filename - Staff console:
GET /marc-records(web_auth.php:447) — Browse MARC records, trigger export - Staff action:
POST /marc-action/{action}(web_auth.php:760) — Mark record status, queue for export
File locations:
Legacy FTP path: \\Webnet\ftp\From Cecilia\Processing orders - Direct to libraries\{LIBRARY_CODE}
Example: \\Webnet\ftp\From Cecilia\Processing orders - Direct to libraries\ALI_COUNTY\ALI_COUNTY_2024.mrc
Centerpoint staff do not auto-generate MARC records. The system exposes pre-built MARC files from the legacy FTP drive and provides a query interface. If libraries require fresh MARC exports (e.g., from a custom title list or standing order), the export path is via the BuildList flyer export (app/Http/Controllers/BuildListController.php) or a custom query tool.
Validation:
MarcValidator enforces field 020 (ISBN) length constraints; other rules can be added as needed. Violations are logged but do not block ingestion or delivery.
EDI Messaging (⏸️ Deferred / B&T Legacy)
Status: Deferred pending generalization for future distribution partners.
Current state:
-
EdiController(app/Http/Controllers/EdiController.php:15–60) provides a legacy message browser -
Edihelper class (app/Helpers/Edi.php) encodes/decodes EDI segments -
BaseMessage(app/Edi/BaseMessage.php) is the base class for EDI message types - Routes (routes/onix_edi.php:32–45):
GET /edi,POST /edi-send, etc. - FTP storage:
Storage::disk('edi')(config/filesystems.php) maps to a local EDI message directory
Hardcoded B&T context:
Baker & Taylor's account key 6095400000001 is embedded throughout the legacy code (webnet.prg:42875, :149330, etc.). The app mirrors this hard-coding in the EDI controller's FTP credential store.
Decision (approved 20260703): The B&T EDI pipeline is deferred and will be generalized into a broader "distribution-partner pipeline" design. This will abstract:
- Vendor account key & credential management
- Inbound/outbound message type routing
- Per-partner schedule and transformation rules
- Mutual exclusion with other order paths (no redundant EDI + in-app order for the same ISBN)
Until then: EDI functionality stays read-only, and B&T orders are routed through the legacy system or manually entered into Centerpoint.
15.5 Architecture & Design
ONIX Pipeline Layers
Publisher ONIX feed (XML)
↓
[watch directory]
↓
onix-cli tool (standalone Rust/Node binary)
- XML parsing
- ISBN resolution (Ingram catalog)
- BISAC classification
- Deduplication
↓
[SQLite message store: onix-store.sqlite]
↓
OnixReconciliationService (Laravel)
- Cross-ref vs inventory MySQL
- Pricing/availability reconcile
- Flag missing or outdated titles
↓
[Result spreadsheet + cache]
↓
Export paths:
• CSV (staff reporting)
• ONIX XML (fulfill partner consumption)
• MySQL inventory update (future phase)
Key design rules:
-
Subprocess isolation: onix-cli runs as a separate process; failure does not crash Laravel.
OnixService::centerpointUpdate()streams results withProcess::forever()(no timeout). - Deferred reconciliation: CLI outputs raw ONIX data; reconciliation happens in Laravel where we can query MySQL. This keeps onix-cli stateless and portable.
-
Time budgeting:
--time-limit=Sallows long-running ingests to be split across multiple runs without re-parsing already-processed messages. -
Reprocessing:
--reprocessre-resolves all cached ISBNs against current inventory, useful after inventory schema changes or pricing updates.
MARC Access Architecture
Library staff request MARC file
↓
MarcFtpController routes request to network FTP
↓
[FTP shared folder: \\Webnet\ftp\...\{LIBRARY}]
↓
MarcParser detects format (.mrc binary vs .mrk text)
↓
MarcComposer converts binary→MRK or MRK→binary
↓
[MarcRecord value objects in memory]
↓
MarcValidator checks field constraints (optional)
↓
Serve file to client (download or render in UI)
Design decisions:
- Read-only: Centerpoint does NOT auto-generate MARC from inventory. Legacy MARC files are the source; the app exposes them.
- Lazy validation: MARC validation is optional per request; errors are logged but do not block delivery (libraries may accept malformed MARC and fix it client-side).
-
FTP network path: On Windows, SMB paths (
\\Webnet\ftp\...) are accessible directly from PHP; no separate FTP client needed.
EDI (Future: Generalized Distribution-Partner Pipeline)
Planned design (not yet implemented):
Vendor account config (encrypted)
↓
[accounts → vendor relationship table]
↓
DistributionPartnerService
- Credential lookup
- Schedule management
- Mutual exclusion (no dual-order)
↓
Inbound: {Vendor}MessageService
- EDI 850 (purchase orders)
- EDI 856 (advance ship)
- Custom vendor formats
↓
[Message queue: jobs.messages_pending]
↓
Route to order pipeline OR skip if conflict
↓
Outbound: {Vendor}ExportService
- EDI 810 (invoices)
- Shipment notices
- Custom manifests
Decoupling benefits:
- No hardcoded vendor IDs or credentials in code
- Easy to onboard new partners (add config row, swap service class)
- Clear error handling per partner
- Audit trail of all vendor interactions
15.6 🔧 For Builders
Adding ONIX Support for a New Publisher
-
Update the watch directory path:
- Check
config('cp.TRADE_MESSAGES_PATH')inconfig/centerpoint.php - Coordinate with ops: where will the publisher's ONIX files land?
- Check
-
Extend
OnixReconciliationService:- Add publisher-specific field mappings if needed (e.g., custom BISAC override logic)
- Test with sample ONIX file using
php artisan onix:sync --dry-run --limit=10
-
Add a scheduled sync if needed:
- Example:
routes/console.php:306–331shows Friday/Monday schedules - Use
Schedule::command('onix:sync --time-limit=540')->fridays()->at('03:00') - Set a time limit (
--time-limit) if the publisher sends many files
- Example:
-
Test reconciliation:
php artisan onix:sync --dry-run --status php artisan onix:sync --history=9780743273565 # Check one ISBN php artisan onix:sync --limit=100 # Process first 100 -
Monitor & alert:
- Add publisher email to
--notify=so staff get daily reports - Set up Sentry monitoring for process failures (timeout, OOM, parsing errors)
- Add publisher email to
Exporting ONIX from Inventory
The OnixExport action (app/Actions/OnixExport.php) generates ONIX XML from the MySQL inventory table. Status: Deferred pending builder review of schema coverage.
To export a title subset:
$action = new OnixExport();
$onix_xml = $action->forIsbn('9780743273565'); // Returns ONIX 3.0 XML string
Delivering MARC to Libraries
-
Verify FTP path access:
- Test SMB connectivity:
ls \\Webnet\ftp\From Cecilia\Processing orders\ - Confirm library folders exist (e.g.,
ALI_COUNTY,WAYNE_COUNTY)
- Test SMB connectivity:
-
Trigger export at MarcFtpController:
- Route:
GET /marc/{library}/{file}automatically downloads the file - For batch downloads: use
POST /marc-action/zipto create a batch archive (if implemented)
- Route:
-
Parse a MARC file in code:
$parser = new \App\Marc\MarcParser(); $records = $parser->parseFile('path/to/file.mrc'); foreach ($records as $record) { $isbn = $record->getField('020')->subfield('a'); // field 020 = ISBN $title = $record->getField('245')->subfield('a'); // field 245 = title } -
Validate MARC:
$validator = new \App\Marc\Validation\MarcValidator(); $violations = $validator->validate($record); if (count($violations) > 0) { Log::warning('MARC validation failed', ['record' => $record, 'violations' => $violations]); }
Preparing for EDI Generalization
When ready to onboard a new distribution partner (beyond B&T):
-
Audit the EDI folder structure:
- Identify which message types the partner sends/receives
- Get a sample file to parse
- Document credential/FTP location
-
Create a partner-specific service:
namespace App\Services\Edi\Partners; class {PartnerName}MessageService { public function parseInbound(string $raw): array { ... } public function formatOutbound(Order $order): string { ... } } -
Register in a partner factory:
class DistributionPartnerFactory { public static function for(string $accountKey): PartnerService { ... } } -
Add configuration row:
- Table:
distribution_partners(new) - Columns:
id,name,account_key,credential_encrypted,supported_message_types,schedule_expression
- Table:
-
Test with sample messages:
php artisan edi:process {partner} --file=sample_850.txt --dry-run
Code Anchors (Cross-Reference)
| Feature | File | Line |
|---|---|---|
| ONIX pipeline entry | app/Services/OnixService.php | 39–74 |
| ONIX sync command | app/Console/Commands/OnixSync.php | 13–31 |
| ONIX scheduled runs | routes/console.php | 306–331 |
| MARC FTP controller | app/Http/Controllers/MarcFtpController.php | 17–54 |
| MARC parser | app/Marc/MarcParser.php | 15–99 |
| MARC parser format detection | app/Marc/MarcParser.php | 29–48 |
| EDI controller | app/Http/Controllers/EdiController.php | 15–60 |
| ONIX routes | routes/onix_edi.php | 14–43 |
| MARC routes | routes/static_files.php | 24, 42–43 |
| MARC domain | routes/web.php | 28 |
| Legacy ONIX menu | webnet.prg | 115725 |
| Legacy MARC access | webnet.prg | 20272 |
| Legacy EDI (B&T) | webnet.prg | 139788 |
16. Time Clock, Tickets & Internal Ops
16.1 Purpose
This domain covers three operational staff tools: (1) Interactive Time Clock — staff members punch in/out, with a single status-change endpoint; (2) Support Ticket System — customer-facing issue tracking; (3) Internal Operations Dashboards — aggregated account management and operational views.
16.2 Legacy Behavior
This domain is entirely green-field — the legacy Visual FoxPro application (webnet.prg, lines 1–161252) had no unified time-clock system or formal ticketing subsystem.
The legacy system did have a workaround: procedures SERVICE_HELP (line 4447) and SERVICE_REQUESTS (line 151553) attempted to piggyback service requests onto the CUSTNOTE infrastructure, but this was not a designed system and not used operationally. The legacy application provided no interactive status tracking or draft timesheet projection.
There is no PARITY row for time clock or a modern equivalent of SERVICE_HELP/SERVICE_REQUESTS, because the modern systems diverge by design from non-systems into purpose-built infrastructure.
16.3 Ported Forward + Enhancements
Since these systems are green-field, the story is in-app modernization: three fragmented time-tracking approaches (legacy TimeCard model, chat status endpoints, manual timesheet entry) were unified in June 2026 into one cohesive status-driven state machine (shipped commit aa121654b–82aa72aa8, Jun 15).
Time Clock unification:
-
Before: TimeCard model (never used in production), separate clock endpoints per action (legacy routes
/clock/{in,out,lunch,break}), manual timesheet entry without state projection. -
Now: Single
setStatus()endpoint (routes/users.php:82-83) that accepts any legal status slug; theTimeClockStateread model projects the status stream into valid next-state actions + draft timesheet. One state machine (UserStatus enum + StatusEffect transition rules in config/time_clock.php) is the source of truth. The TimeCard model was retired (~2177 lines of dead code removed).
Tickets enhancement: The custom ticketing system (TicketController, routes/tickets.php:9–21) supersedes the non-system legacy workaround. It adds: public customer links (UUID tokens per Ticket::public_token), threaded message history, status-change audit trails (TicketStatusHistory), and image attachment processing with recompression (ImageProcessor service).
16.4 Current State
Shipped (Jun 15):
- Interactive Time Clock: full state machine, widget (resources/js/components/TimeClockWidget.vue), endpoints (GET/POST /u/clock, /u/clock/set), nightly draft sync guard (timeclock:sync-drafts command, 23:45 ET).
- Support Tickets: create, display, message threads, attachment upload/display, status workflow (open/in_progress/feedback_requested/complete/closed), assignee management, public customer view.
- Test coverage: 40 green integration tests in tests/Feature/TimeClock/ (mapping, projector state matrix, lunch-split & forgot-clock-out edge cases, draft sync, write gates).
Planned / Out of scope:
- Scheduled reminders (integration with native Laravel scheduler, not AI agents); deferred pending business rules clarification.
- Time approval/manager sign-off workflows; currently draft-only.
- Integration of tickets into the unified chat (currently separate Blade surface).
16.5 Decisions & Directional Rulings
D-TC-1: Single write endpoint. All status transitions (clock in, clock out, on lunch, etc.) go through one setStatus() endpoint (TimeClockController::setStatus, routes/users.php:83). The controller does NOT hardcode per-action logic; instead, the transition guard in User::updateStatus() decides legality from the current clock state. This reduces surface area and makes the state machine self-evident in config/time_clock.php.
D-TC-2: Status events are canonical. UserStatusChanged events (fired from the controller) trigger the TimeClockState read model + TimesheetDraftSync listener. The worker-friendly side effect (automatic draft entry for clocked-in intervals) never reaches back to corrupt the event stream.
D-TC-3: Tickets diverge from legacy CUSTNOTE. Rather than extend the customer-note infrastructure to handle service requests, tickets are a standalone domain with separate models, permissions (TicketPolicy), and Blade views. This allows future enhancements (SLA tracking, knowledge-base linking) without entanglement.
D-TC-4: Draft timesheet sync is a safety net, not the system. Real-time updates come from the ProjectStatusToTimesheet listener (AppServiceProvider::boot, registered via Event::listen). The nightly timeclock:sync-drafts command (console.php:99–106) caps "forgot to clock out" at end-of-day and runs at 23:45 ET to handle edge cases.
16.6 🔧 For Builders
Time Clock:
-
Read model:
App\TimeClock\TimeClockState(unified clock payload + valid next-state actions).App\TimeClock\WorkedTimeProjector(state machine: out → working → paused → out).App\TimeClock\UserStatusenum +StatusEffect(transition rules). -
Write:
App\Models\User::updateStatus()(guarded by the UserStatus transition matrix).App\Events\UserStatusChanged(triggers TimesheetDraftSync). -
Config:
config/time_clock.phpholds only operator-tunable numbers (timezone,pay_anchor= biweekly pay-period start,paid_break_minutes_per_day= the lunch/break budget). The status map + clock effects live in theApp\TimeClock\UserStatusenum (clockEffect(),app/TimeClock/UserStatus.php:138) +StatusEffect, not the config. Routes:routes/users.php:82–83. -
Integration: Status is stored in
users.status_id(nullable, foreign key to a status_id enum or a string enum depending on migration). Checkmigrations/*_create_users_table.phpanddatabase/migrations/*_add_status_to_users.phpfor schema. -
Widget:
resources/js/components/TimeClockWidget.vue+resources/js/components/Chat/TimeClockWidget.vue(embedded chat variant). Self-loads viashow()endpoint, live ticker with refesh on status change. -
Tests:
tests/Feature/TimeClock/*— state matrix coverage, lunch-split, overnight intervals, forgot-clock-out cap, draft sync, endpoint gates. -
Nightly command:
timeclock:sync-drafts(console.php:105–106, scheduled 23:45 ET).
Tickets:
-
Models:
App\Models\Ticket(subject, status, priority, requester_email, public_token UUID).TicketMessage(threaded replies).TicketAttachment(stored on disk 'public', with mime + size).TicketStatusHistory(audit trail). -
Routes:
routes/tickets.php:9–21(staff-only view/create/show/message). Public guest route:routes/tickets.php:22–24(TicketPublicController, token-gated). -
Controller:
App\Http\Controllers\TicketController(index with filters: q, status, priority, assignee_id, mine, updated_from/to, sort). Status lifecycle handled byTicket::markStatus()(fires TicketStatusHistory). -
Attachment handling:
App\Services\ImageProcessor(downscales + recompresses images on upload). Non-image files stored raw. Base64 paste support (TicketController::store lines 181–205). -
Notifications:
App\Notifications\TicketCreatedPublicLink,TicketReplyAdded,TicketStatusUpdatedsent to requester_email. - Access: Gated by TicketPolicy (viewAny/updateAssignee/updateStatus permissions). Customers see only tickets they created via the public token route.
Internal Ops:
-
Dashboard:
App\Http\Controllers\DashboardController(routes/users.php:51–57). Widget pinning: PUT/dashboard/widgets(routes/web_auth.php:745–746, DashboardWidgetController::update). -
Customer Service Dashboard:
App\Services\CustomerServiceDashboardService+ ~14-panel widget mounted atapi.php:57under the DynamicDashboard infra. See staff/docs/memory/ACCOUNT_REP_DASHBOARD_GUIDE.md for usage. - Memory: project_unified_time_clock.md (status-driven design), project_manual_order_backstop.md (TimeClockState integration for chat plan actions).
17. Platform — Auth, Permissions, DBF↔MySQL Bridge, Jobs
17.1 Purpose
Centerpoint runs on a hybrid infrastructure: a modern web application reading from MySQL for speed and reporting, while the legacy DBF files remain the system of record. The Platform layer is the invisible backbone that keeps these systems synchronized, ensures users can authenticate and use the app reliably, and logs every action for audit and operational monitoring.
17.2 Authentication & Permissions
The User Model is the entry point. The User class at app/Models/User.php implements Laravel's Authenticatable contract directly (not via the trait, to avoid naming conflicts with the password() relationship). When a user logs in via the login form, Laravel validates their credentials against the Password model's DBF-mirror data and creates a session.
The Password Model (app/Models/Password.php) is not a pure data store — it implements AuthorizableContract, uses the Authorizable + HasApiTokens traits, and carries auth/authorization logic (getAuthPassword(), a custom createToken(), createCredentialsFromPasswordsTable()) plus authz-gated CRUD and cart helpers. It mirrors the DBF passwords table and holds UPASS (the encrypted password hash), UNAME (username), and EMAIL. On each backend request, HandleInertiaRequests middleware calls $request->user()->viewProps() and $request->user()->currentAccountInfo() to populate the inertia props the frontend uses.
Sessions survive password table rebuilds. The nightly RebuildUsers job rebuilds the MySQL passwords table from the DBF at 02:15 (app/Actions/RebuildApplicationTables.php line 200–220), but the session cookie remains valid. This is because the session store (Redis or file-based) is independent of the password table — the session just confirms the user is logged in; the password table is consulted only on new login.
Permissions are managed via a HasPermissionsTrait on the User model (app/Models/Traits/HasPermissionsTrait.php). The trait provides methods like canNow($permission) that check against policies defined in app/Policies/. A user's role (users.role_id) determines which policies allow or deny access. Feature-level checks typically run in controller middleware or policy methods; data mutation checks run in policy update() and delete() methods.
Configuration lives at config/auth.php. The custom password-provider driver's model is App\Models\User::class (config/auth.php:69), not Password; the web guard uses session-based authentication. No changes to this config are needed for most use cases — it's set once and rarely touched.
17.3 The DBF ↔ MySQL Bridge: Three Paths, One Schema
Centerpoint has three code paths that read/write DBF files, each with different mechanics but the same table schemas:
-
LEGACY path — Direct
fopenin PHP (app/Models/Dbf/Table.php). Used by the legacy VFP app and fallback code paths. -
dbf-service path — 32-bit Lumen server running VFPOLEDB on :8080 (
staff/dbf-service/config atconfig/dbf.php). Used when the proxy is healthy. -
RUST path — Rust API on :3636 (
centerpoint_apirepository). Not production-ready; reserved for future migration.
Name the path before hypothesizing. A data inconsistency that looks like a sync bug might be a legacy-path artifact, a timeout in dbf-service, or a Rust-side issue. Knowing which path is in play cuts debugging time in half. Check logs for DBF sync handler: or grep the request context.
INDEX is the link. In MySQL, every record that comes from the DBF has an INDEX column matching the DBF record number (RECNO). When a new Laravel model syncs to DBF, INDEX starts as NULL; after the sync, it's set to the DBF record slot. This link is sacred — never null it out unless you're about to resync. Cascade keys vary by table:
-
webheadsandwebdetails: keyed onREMOTEADDR(the IP or session ID) -
backheads,broheads,tradeheads: keyed onTRANSNO(the order number) -
standing_orders(the DBF table name isstanding.DBF): keyed onid(the Laravel primary key)
MySQL is readable; DBF is the source of truth. Writes always flow MySQL → DBF via $model->dbf()->save(). The daily rebuild at 02:15 (RebuildApplicationTables::daily_tables() in app/Actions/RebuildApplicationTables.php lines 240–290) tears down and rebuilds web*, back*, bro*, all* tables from their DBF counterparts. A plain Laravel migration on these tables will not survive the rebuild — the DBF and the rebuild job must both carry the column change.
17.4 DBF Integrity & Health Checking
Corruption is detected and healed automatically. The system has three defense layers:
1. parity:fix command (php artisan carts:fix). Runs on-demand or scheduled. It performs a full DBF↔MySQL parity check in two stages:
-
Step 1: Find MySQL rows sharing the same INDEX. Read the DBF slot to determine the real owner (match on KEY + REMOTEADDR). Losers get
INDEX=NULLand resync to a fresh slot. -
Step 2: Loop all DBF records. Check if MySQL claims each. For orphaned records: recover (create MySQL row from DBF), soft-delete stale ones, or link unlinked slots to MySQL rows with
NULL INDEX.
Healing logic at app/Console/Commands/CartsFix.php (class CartsFix) around lines 116–224.
2. data:check command (php artisan data:check). Broader weekly health check with named checks:
-
users— UNAME mismatches, missing EMAIL -
remoteaddrs— Duplicate REMOTEADDRs in MySQL and DBF -
carts— Cart integrity + stale user REMOTEADDRs -
booktext— Book text table coverage
With --fix, it cascades to carts:fix for duplicates and dispatches SyncInventoryText job for missing rows. Scheduled weekly Sunday 8am with --fix (at routes/console.php line 180).
3. PAID flag compatibility (a subtle gotcha). The allheads table stores PAID as '0' or '1', not 'T' or 'F' like VFP booleans. Use Misc::isTruthy($value) in PHP or isTruthy(value) in Vue (Helpers/Strings.js) to normalize. Both accept 'T', '1', 'TRUE', 'Y' — a guard against future copy-paste bugs (used in 15 locations: AlphaCalculateInvoiceService, OrderTrait, Allhead, etc.).
17.5 Jobs & Transaction Logging
Jobs run on Laravel's queue. The scheduler in routes/console.php defines 23 instrumented jobs (e.g., RebuildApplicationTables::daily_tables at line 60, BuildCatalogList::build at line 100). Each scheduled job wraps its Schedule::* chain with an $instrument() closure that emits started/completed/failed transaction rows on before/onSuccess/onFailure hooks — no changes to the command class needed.
TransactionLogger is the primary gate (app/Support/TransactionLogger.php). Most transactions rows flow through TransactionLogger::record() (config-driven gating, fail-soft). (Not strictly universal — a few production call sites still write via Transaction::create() directly, bypassing the gate: app/Listeners/CreateOrderTransaction.php, app/Domain/Orders/History/OrderHistoryService.php:227, and the BulkAddressUpdate services.) Client code calls TransactionLogger::record(int $code, array $data, ...) and the logger decides what to keep based on config/transactions.php gates:
-
gates.enabled— global kill switch -
gates.codes_disabled— drop specific transaction codes -
gates.jobs_disabled— fnmatch globs (e.g.,'noisy-*') -
gates.max_data_bytes— truncate or drop oversize payloads (default 64 KB) -
ignore_blacklist— legacy override to always write
Never edit call sites to silence a noisy transaction — flip a config gate instead. All methods fail-soft: exceptions log a warning and return null so the caller never blocks.
Schedule-level lifecycle uses helper methods:
TransactionLogger::started('job-name', $data);
TransactionLogger::completed('job-name', $data);
TransactionLogger::failed('job-name', $error, $data);
TransactionLogger::bracket('job-name', $work, $startData); // auto-bracket a callable
Pulse monitoring integrates via the "Jobs" widget. It reads codes 29 (started), 30 (completed), 31 (failed), 32 (warned) and renders them with ⚙ (normal) or ⚠ (failed/warned) icons. Commits: 5c3ebd263 (refactor), 33e30878a (instrument 23 jobs).
17.6 🔧 For Builders
Auth gotchas:
-
User->password()is ahasOnerelationship, not the authentication method -
$request->user()returns aUserinstance with role and permissions loaded - Sessions survive
RebuildUsers— test login across a rebuilds to verify this works in your environment
DBF↔MySQL gotchas:
- Always check which of the three paths is active before blaming data inconsistency
-
INDEX = nullmeans "MySQL-only, not yet synced"; never create it manually - The daily rebuild at 02:15 wipes
web*,back*,bro*,all*— migrations on these tables must also land inRebuildApplicationTables - VFP reserved words (
WHERE,REPLACE, etc.) must be aliased in VFPOLEDB queries; never bracket them
Health & repair:
-
php artisan carts:fix --dry-runpreviews what will be fixed - Healing logic assumes INDEX is the primary join — never change the cascade-key strategy without re-auditing all three paths
-
data:checkruns hourly (weekdays 10am–5pm, report only) and Sunday 8am (with--fix)
Job instrumentation:
- Add new scheduled jobs via
$instrument(...)wrapper inroutes/console.php - Transaction logging is fire-and-forget; never await confirmation
- Config gates let you dial verbosity per environment — use
TRANSACTIONS_GATE_ENABLED=falsein CI/testing to keep logs clean - The "Jobs" Pulse tile reads the last 100 rows of codes 29–32; older lifecycle rows are kept indefinitely for audit
Testing:
-
staff_testingdatabase is owned byphp artisan test:init; never DDL inside a test (implicit commit breaks rollback) - Don't parallelize test execution (shared MySQL); parallel read-only diagnosis is fine
-
test:init --freshruns a child process — guard against wrong DB name withif (app()->isProduction()) throw new Error('...')
For code examples and deep dives: see auth_architecture.md, dbf_integrity_system.md, transaction_logger.md, domain_seams.md § "DBF ↔ MySQL" and "Testing".
Part III — Roadmap & Direction
18. Confirmed Directional Rulings
The decisions below represent the team's locked consensus on how Centerpoint works, why it works that way, and what trade-offs were made to get here. Each has been implemented, tested, and approved. Changes to these rulings require team discussion and explicit consensus.
Orders & Carts
✅ D1 — No pull-time repricing. Once a cart is submitted, prices are locked. No repricing happens when the order enters fulfillment. Rationale: The legacy system had post-submit price adjustments as a workaround for missing cart-time pricing logic. That gap is now closed; repricing at pull time creates fraud risk. (20260702)
✅ D2 — Submitted carts stay in webhead until pull. WEBHEAD records are marked SUBMITTED (MARKCART=5555) and copied to alpha_webheads history, not deleted. Separate HEADING records are created for fulfillment. Rationale: Single source of truth; clean separation of shopping vs. fulfillment state. (20260702)
✅ D3 — Backorder conversion creates new TRANSNO. When a backorder becomes available and converts to an active order, it receives a new TRANSNO, not a reused ID. Rationale: Legacy MANAGE_SELECT_BACKORDERS creates new HEADING records; app mirrors this for clear audit trail. (20260702)
✅ D4 — Archival is a sweep, not inline. Completed orders move to archive tables via nightly batch (orders:archive-sweep), respecting legacy ARCHIVE_ORDERS gate. Rationale: Batch operations are faster and less prone to race conditions; offline processing avoids customer-facing latency. (20260702)
✅ D6 — Free shipping: locked at cart, recalculated at pull. The seven free-shipping conditions are evaluated when the customer adds items to cart and again when they submit. Staff can override during master edit in exceptional cases. Rationale: Customers see accurate costs in cart summary; edge cases are rare enough for staff override. Certified parity with legacy ladder 20260703. (20260703, c320cd358)
Standing Orders & Plans
✅ D1 — Parity is non-negotiable. The legacy VFP system is still live production. All ports are parity-or-better, never silent regressions. Every claim in PARITY.md is anchored to legacy code, app code, and certification commit. (20260702)
✅ D2 — SORTORDER is a contract with Pitney Bowes. The legacy grouping hash (NNXX codes) is not a display label — it's the key Pitney Bowes uses to batch orders for weigh-in and label generation. The app must build and materialize it identically to legacy. (2026-06-18, parity matrix)
✅ D3 — FINALINV is availability, not ONHAND. Legacy defines ONHAND = FINALINV − ALLSALES. Fulfillment judges availability against FINALINV (gross inventory), not real on-hand stock. This allows standing orders to be promised against titles publishing this month. (parity matrix, D9 Inventory)
✅ D4 — Staged inventory, not production mutation. Queue build seeds a separate queue_inventories working copy. Production inventories are untouched until Ship. Rationale: Preserves ability to re-run, inspect, and audit the queue without altering shelf facts. Improvement on legacy. (20260702)
✅ D5 — Operator owns routing; system informs. Fulfillment UI flags all concerns (oversized groups, address mismatches, unfulfilled titles) and seeds sensible defaults. The operator decides what ships, where, and when — never automatic rerouting. (20260702)
✅ D6 — Contiguous TRANSNO assignment. TRANSNOs must be contiguous in SORTORDER order so that third-party consumers reading the DBF see grouped orders with adjacent numbers. (parity matrix, legacy renumber pattern)
Trade
✅ D-T-1 — Trade as separate run, collision-free reset. Trade and regular fulfillment use shared queue tables but with INVNATURE scope-tagging. Each run type has distinct ActionRun. Reset operations are scope-aware. Rationale: Neither run disturbs the other's staged rows even if both are in flight. (20260629, user decision)
✅ D-T-2 — Trade selection = cumulative-by-level auto-load. Each Trade Level plan is cumulative: Level 1 (CLASS_H) sources 2 titles/month; Level 2 (CLASS_H + CLASS_I) sources 4 titles/month; Level 3 (CLASS_H + CLASS_I + CLASS_J) sources 6 titles/month. No manual per-title curation in batch path. (parity matrix, D-T-2)
✅ D-T-5 — SORTORDER is added to trade (intentional divergence-as-improvement). Although legacy trade had no SORTORDER or batch routing, the modern trade path assigns SORTORDER via content-hash and joins the same PB-lane routing logic as regular standing orders. Trade heads benefit from grouped-shipment optimization without requiring new sort codes. (2026-06-30, user decision)
✅ D-T-6 — Queue full set, dedup on move, operator decides. Trade preloads titles onto back orders for vendor procurement sizing, so the queue must show the full plan demand without purchasing/back-order gates. The dedup + filter happen on move to production via the ship-stage toggle dedup_open_backorder (default ON). (2026-06-30, user decision)
✅ D-T-8 — Trade move = direct-to-production preorder, tentative pricing. Trade lines skip brokered queues entirely and materialize directly to backhead/backdetail (OSOURCE='TRADE STANDING'). Pricing at queue time is tentative; the final price is recalculated out-of-band at ship time. Rationale: Production brodetail→invoice billing is not replicated in fulfillment system; app-trade tool owns that. (parity matrix, 20260703)
✅ B&T / EDI → Distribution-partner pipeline generalization. Baker & Taylor is defunct (decision resolved 20260703). The legacy B&T EDI console suite (850/855/856/810/997), monthly buyer review, and scan-to-ship workflow are deferred in favor of a generalized abstraction. Future work: research EDI interaction patterns + buyer-review workflow for any future partner, then design reusable "distribution-partner pipeline". (20260703, PARITY.md rows 33 + 108, todo.md)
Inventory & Catalog
✅ D9 — FINALINV = lifetime print baseline, never reset. FINALINV is the print-run commitment; ONHAND = FINALINV − ALLSALES (post-release erosion). Standing-order needed = max(0, requested − FINALINV). Rationale: Mirrors legacy exactly; FINALINV is the decision, ONHAND is derivative. (20260618, parity matrix)
✅ D10 — Monthly make-available gates on FINALINV ≠ 0. Missing-FINALINV warning is non-blocking; operator workflow safety + data integrity. Re-seeds ONHAND := FINALINV at publish. Mirrors legacy :129221/:129236 exactly. (20260702, b58f91238)
✅ D11 — FASTPRINT lane (POD). Stock gated OFF (ships full), 6-month PUBDATE window, HOTBOX='FPR' (C(3) truncation; legacy 'FPRINT' stores as 'FPR'), +1-month invoice date, freeship, PIPACK=3/PEPACK=1. Legacy parity at lane entry (FASTPRINT_CREATE :160254). Note: DBF HOTBOX is C(3) — verified 25 production broheads. (20260703, 2a08a04ec + fcaa106af + ceec2ed1a)
✅ D12 — FINALINV readiness gate at Ship, not Queue. Preview stays open at FINALINV=0 (warned) to discover demand; operator keys FINALINV from preview + buffer; Ship holds any order with unsettled FINALINV. Rationale: Improves legacy's dead-end (forced external FINALINV forecast); preserves safety intent. (20260618, parity matrix)
Pricing, Promotions & Campaigns
✅ D10.1 — Submit locks pricing. All order pricing is locked at submit time. Carts are repriced on pull only if back-ordered. Rationale: Legacy parity; pricing is not recalculated mid-cart. (parity matrix, domain_seams.md)
✅ D10.2 — Pricing is always complete. The 42,873 ISBN × account records must be pre-calculated and cached; no on-demand fallback. Required fields always present; sparse optimization only for purchases (92.8% sparse). Cache TTL 3 hours (cold ~30s / cached 0.55s). (organization_context.md, 20260618)
✅ D10.3 — Promotions are slot-based, not semantic. Column names (western, romance, etc.) are historical convenience; slots are interchangeable. Code asks PromotionsConfiguration for column names, never hardcodes them. (dynamic_promotions_plan.md)
✅ D10.4 — Campaigns are processor-first. Each campaign reason has a dedicated processor class implementing CampaignProcessorInterface. The campaign-builder is orchestration, not logic. New campaign types = new processor + factory registration (no wizard-step cascading). (CAMPAIGNS_REFERENCE.md)
✅ D10.5 — Saved queries are filter combinations, not campaigns. They store account-classification flag selections in the wizard, reducing manual filter entry for recurring audience targeting. They do not save campaign settings or email templates. (saved_queries.md)
✅ D10.6 — Campaign options flow through whitelist. New CampaignOption enum cases must be added to BuildCampaignDrafts::normalizeOptions() or they are silently dropped before reaching build. (campaign_options_and_wizard.md, gotcha section)
Accounts & Organizations
✅ D1 — Account classification is point-in-time, not per-request. The 86-column account_meta classification runs once during OrganizationContextService build (context TTL 3 hours) and is cached. No per-request classification. Rationale: ~30s cold / 0.55s cached; per-request would be prohibitive. (20260702, parity matrix)
✅ D2 — Contact denormalization persists during transition. Accounts and passwords keep their SEX, FIRST, LAST, TITLE, VOICEPHONE, FAXPHONE, EMAIL fields through the transition to canonical contacts pool. Writes trigger soft-link ensure-or-create (when implemented); composer fields stay for legacy reads and DBF mirroring. Rationale: Accounts is DBF-mirrored; removing denormalized fields requires schema changes that can't survive rebuild. (parity matrix, address_contact_architecture.md)
✅ D3 — RECALLD dual-persistence survives rebuild. Callback date changes write to both accounts (immediate views) and accounts_meta (survives rebuild). Custnotes created with locked_at timestamp survive partial rebuilds. Rationale: accounts rebuilt daily from DBF, losing MySQL-only changes. (parity matrix)
✅ D4 — Address composition via fallback chain, not override-only. Default_physical composes from accounts directly; non-physical pivots read via contact FK with fallback to accounts composite. COMPANY override (pivot-only) allows alt-row organizational identity. Rationale: No blank address fields; fallback ensures coverage. (parity matrix)
Notifications & Email
✅ Email on every plan event. One event = one email + notification + custnote. No event batching. Rationale: Transparency + audit trail; each action is atomic. Pace (qty×2mo) prevents notification fatigue. (20260701, todo.md §1)
✅ Contact-type notes = log-only. No auto customer email. Manual contact is staff's action, not customer-facing event. Email goes only to the note author (in-app). Rationale: Clarity; contact-note is internal documentation. (20260701, todo.md §2, decision resolved)
✅ Notifications from shared account (nstewart@). Not from individual staff. Rationale: Single sender reduces support confusion; individual author tracked in transactions.by_email. (20260701, config/cp.php:203)
✅ Check Plan Health is universal. Applies to all plan types (Choice, Trade, Regular), not just Choice. Rationale: Modern adds uniformly; Trade gets its own email summary block (not the Choice "titles selected" template). (20260702, todo.md §1)
✅ Account health derivation, not persistence (for now). Flags computed on-read from transactions rows (codes 38–46) + StandingOrder lifecycle scopes. Permanentization to account_meta columns + batch classification = deferred pending flag-set stabilization. (20260701, todo.md §3)
Reports & Analytics
✅ D12 — No New ISBNs Projection (5-year ISBN lifetime). All depletion projections use fixed 5-year ISBN lifetime from PUBDATE. Titles older than 5 years are "expired" and excluded from recommendations. Rationale: Matches legacy acquisition planning model; forecasts remain realistic. (parity matrix)
✅ D13 — Caching is indefinite unless explicitly bypassed. Reports stored persistently in storage/app/private/reports/ and never expire automatically. Trade-off: accuracy is secondary to performance and predictability. Users needing fresh data must use --no-cache flag. (ReportsController, CompanyReportService)
✅ D14 — Acquisitions spreadsheet = source of truth (transitional). During transition, inventory acquisitions live in CSV (ACQUISITIONS_SPREADSHEET_PATH). Website provides read-only analytics. CRUD operations disabled (ACQUISITIONS_TRANSITION_MODE=true). When ready, --false unlocks full in-app management. (InventoryAcquisitionsController)
✅ D15 — Report generation runs on CLI, not web. Long-running reports (20–30 minutes) generated via artisan experience:generate-report, not web interface. Prevents timeout errors; allows asynchronous scheduling. (ExperienceController, CompanyReportService)
Time Clock & Tickets
✅ D-TC-1 — Single write endpoint. All status transitions (clock in, out, on lunch, etc.) go through one setStatus() endpoint. Controller does NOT hardcode per-action logic; transition guard in User::updateStatus() decides legality from current clock state. Rationale: Reduces surface area; state machine self-evident in config/time_clock.php. (20260615, aa121654b–82aa72aa8)
✅ D-TC-2 — Status events are canonical. UserStatusChanged events fire the TimeClockState read model + TimesheetDraftSync listener. Worker-friendly side effect (automatic draft entry) never reaches back to corrupt event stream. (20260615)
✅ D-TC-3 — Tickets diverge from legacy CUSTNOTE. Rather than extend customer-note infrastructure, tickets are standalone domain with separate models, permissions, and views. Rationale: Allows future enhancements (SLA tracking, KB linking) without entanglement. (20260615)
✅ D-TC-4 — Draft timesheet sync is a safety net, not the system. Real-time updates from ProjectStatusToTimesheet listener. Nightly timeclock:sync-drafts command (23:45 ET) caps "forgot to clock out" at end-of-day and handles edge cases. (20260615, console.php:99–106)
Platform & Infrastructure
✅ Sessions survive password rebuilds. The nightly RebuildUsers job (02:15 ET) rebuilds MySQL passwords table from DBF, but session cookie remains valid. Session store (Redis/file) is independent of password table; session confirms login; password table consulted only on new login. (auth_architecture.md, RebuildApplicationTables.php:200–220)
✅ INDEX is the link (DBF-MySQL bridge). Every record from DBF has INDEX matching DBF record number (RECNO). When a new Laravel model syncs to DBF, INDEX starts NULL; after sync, set to DBF record slot. This link is sacred — never null it out unless about to resync. (dbf_integrity_system.md, feedback_index_dbf_assigned.md)
✅ MySQL is readable; DBF is source of truth. Writes flow MySQL → DBF via $model->dbf()->save(). Daily rebuild (02:15 ET) tears down and rebuilds web*, back*, bro*, all* tables from DBF counterparts. Plain Laravel migrations on these tables will NOT survive rebuild — DBF and rebuild job must both carry the column change. (RebuildApplicationTables.php:240–290)
✅ Three DBF code paths (name before hypothesizing). LEGACY path: Direct fopen in PHP. dbf-service path: 32-bit Lumen on :8080 (VFPOLEDB). RUST path: Rust API on :3636 (not production-ready). A data inconsistency might be legacy-path artifact, dbf-service timeout, or Rust-side issue. Knowing which path is in play cuts debugging time in half. (dbf_three_paths.md, feedback_dbf_service_rw_flag.md)
✅ TransactionLogger is universal gate. Every transactions table row flows through TransactionLogger::record(). Config gates (config/transactions.php) decide what to keep: global kill switch, code-level disable, job-level fnmatch globs, max payload bytes, legacy override. Never edit call sites to silence noise — flip a config gate instead. Fail-soft: exceptions log warning and return null. (transaction_logger.md, config/transactions.php)
✅ Jobs run on Laravel queue with instrumentation. Scheduler (routes/console.php) defines 23 instrumented jobs. Each wraps with $instrument() closure emitting started/completed/failed transaction rows on before/onSuccess/onFailure hooks — no changes to command class needed. (transaction_logger.md, 5c3ebd263 + 33e30878a)
Cross-Domain Meta-Decisions
✅ Submit locks all pricing and policy. Orders submitted (SUBMITTED=5555) become immutable for pricing, free-shipping, and fulfillment policy. Staff can adjust line items, change quantities, override flags during master edit, but the pricing formula is never reapplied. Rationale: Fraud prevention; customer expectation. (D1 Orders, D10.1 Pricing, 20260702)
✅ DBF remains system of record. Modern MySQL provides read-only views and fast queries; DBF is the canonical persistent store. Every app write goes DBF-ward via cascading services. Rebuild job (02:15 ET) asserts this monthly — MySQL is always a fresh mirror. Rationale: 20+ years of legacy data, external integrations (Pitney Bowes), regulatory audit trail. (dbf_integrity_system.md, feedback_dbf_service_rw_flag.md)
✅ Batch operations, not inline. Fulfillment, archival, inventory release, reports, catalog rebuilds all run as nightly or on-demand batch jobs (orders:release-backorders, orders:archive-sweep, inventory:make-available, experience:generate-report, etc.). Rationale: Avoids customer-facing latency; enables auditing and dry-run before commit; improves data consistency. (parity matrix, multiple D4–D15 decisions)
✅ System informs; operator decides. Fulfillment UI flags oversized groups, address mismatches, unfulfilled titles, dedup candidates. Routing defaults are sensible (PB for multi-member, downstairs for singletons). But the operator owns the final routing decision — never automatic rerouting. Rationale: Honors legacy's manual control model while making it explicit + auditable. (D5 Standing Orders, D-T-6 Trade, 20260702)
These rulings are durable; changes require consensus. See PARITY.md for the detailed legacy-parity ledger and feature-by-feature implementation status.
19. Green-field / Future Features
Shipped Green-field Features (✅)
Brutus ubiquitous chat. The legacy app's email console (NEW_EMAIL_CONSOLE, EMBASE.DBF) was a separate staff interface for promotional outreach — staff navigated menus, composed templates, and scheduled sends as a distinct workflow. The modern app replaces that with Brutus, an intent-driven chat dispatcher that embeds action tools directly into the app. Instead of leaving an account page to open a contact form, staff click a button → the chat opens inline → the contact form renders as a canvas widget inside the chat. Intents (account edit, contact callback, plan actions) map to reusable canvas tools; every page is a trigger surface; the chat handles session bootstrap and response routing transparently. Shipped Jul 2026. 🔧 See brutus_ubiquitous_chat.md for invocation primitives (useBrutusTrigger page-button API, canvas-chip instant dispatch, response shape contract).
Unified Time Clock. Entirely green-field — the legacy app had no unified time-clock system. Prior to June 2026, Centerpoint ran three fragmented timekeeping approaches: a defunct TimeCard model, disparate clock endpoints per action (/clock/{in,out,lunch,break}), and manual timesheet entry without automation. The modern system consolidates these into one status-driven state machine (UserStatus enum, TimeClockState read model, single setStatus() endpoint) that projects the status stream into valid next-state actions and auto-drafted timesheet entries. Staff punch in/out via a single widget; the system automatically splits lunch breaks and detects overnight intervals. Shipped Jun 15, 2026. 🔧 Config at config/time_clock.php; test suite covers state-machine matrix, lunch-split, forgot-clock-out edge cases, draft sync.
Support Ticket System. Entirely green-field — the legacy app's SERVICE_HELP and SERVICE_REQUESTS procedures were non-functional workarounds. The modern system is a purpose-built ticketing infrastructure: customers report issues via email or web form; staff track status through a workflow (open → in progress → feedback requested → complete); every status change writes an audit trail; customers view their tickets via secure public links and receive notifications on updates. Ticket messages are threaded; image attachments are recompressed automatically; assignment and SLA tracking are extensible. A complementary foundation for future customer-success operations. Shipped Jun 2026. 🔧 Models at app/Models/Ticket, routes at routes/tickets.php, access gated by TicketPolicy (staff-only by default; public-link guests see only their own).
Global Diagnostics Panel. Staff-only modal that centralizes system health and debugging across all domains — cart parity, user sync status, order reconciliation, DBF drift, job health, and emerging errors. Built as a tabbed modal, extensible by adding new tabs without scattering diagnostic tools across multiple pages. A force multiplier for ops triage. Shipped May 2026. 🔧 Dispatch via useBrutusTrigger({intent: INTENTS.systemDiagnostics}).
Saved Queries in Campaign Wizard. Allows staff to save filter combinations for reuse across campaign builds — e.g., "libraries in TX with recent Trade activity." Eliminates repetition and creates institutional memory of useful segments. A net-new productivity lever for marketing campaigns. Shipped Feb 2026. 🔧 Mutually exclusive: query_id_ OR regular filters, never both; reuse in campaigns:build invocation.
In Progress / Deferred
Account Health Dashboard & Batch Renewal. Staff surface now shows account health flags (plan expiring, short on budget, recently lagging, contact overdue) and purchase history together with batch renewal capability — paralleling the legacy account-rep dashboard but with modern real-time derivation. Surfacing derived flags in account search results and the plan table are open per §5 todo. No target date.
Knowledge Base grounded-answer tier. A new tier in the Brutus answer pipeline: curated FAQ (regex/keyword) → KB grounded answer (BM25 chunk retrieval + optional LLM enrichment) → fallback (admin alert). The corpus is 67 markdown docs from storage/notes, chunked at H2 granularity; data model is doc_chunks table (durable reference + embedding slot) + sparse BM25 index. No LLM generation required for Level 0 (section-as-answer); enrichment and rephrasing are opt-in. Expected Q4 2026 start.
These features represent modern-app innovation; they have no legacy equivalent and define competitive advantage.
20. Open Questions & Undecided Directions
The following features and technical decisions remain open — either unaudited against the legacy app, explicitly deferred, or blocked on information gathering. These are normal design process checkpoints, not failures or bugs.
Feature-Level Decisions
UNDECIDED — Bonus / Gift / Free Book Program: The legacy app tracked earned credits and allowed customers to redeem free books. The modern app has no counterpart. Decision pending: port the full entitlement ledger (earn / redeem / restore) or retire the program entirely. PARITY.md status = none. (20260702)
UNDECIDED — Out-of-Catalog Titles (NOSTOCK): The legacy app maintained a NOSTOCK registry for titles no longer in active inventory but occasionally needed for research/fulfillment. The modern app inventory editor shows no equivalent. Decision pending: whether to port or treat as a legacy artifact. PARITY.md status = unaudited. (20260702)
UNDECIDED — Account Priority System: Planned feature (LOW / MEDIUM / HIGH priority levels) with per-account picker, search filters, and budget-warning triggers. Design and implementation both pending. Would surface on account dashboards and rep call lists to highlight VIP or at-risk accounts.
UNDECIDED — Contact Pool Canonicalization: Planned design to build a single canonical Contact model — deduplicating by name, phone, email — to replace the current account-address-linked contact fields. Full schema + dedup logic design pending.
Technical Decisions Awaiting Approval
UNDECIDED — Memo Field (FPT) Writing: The app reads memo fields from titles and trade notes (FoxPro .FPT format). Writing memos back to the DBF is unimplemented. Decision pending: understand the FoxPro block format (memo chunk layout, offset tables) and decide whether to port writes at all or leave memos read-only. Impacts MARC record editing and editorial content updates. Blocked on format audit. (20260702)
UNDECIDED — Account Health Flag Persistence: Health derivations (plan-health, contact-health, account badge) are currently computed on-read from transactions and standing-order scopes. Permanentizing as account_meta columns + batch classification is deferred pending stabilization of the flag set. Once the useful flag combinations are confirmed, a batch reclassification pass will make searches and dashboards faster.
UNDECIDED — Trade "By Email" Bulk Update Tab: A new tab for the trade orders UI to support bulk edits sent in by email. Currently deferred to its own focused session rather than bolted onto the existing "By Standing Order" tab. Scope and interaction design pending.
Domain Gaps
UNDECIDED — Broader Distribution-Partner Pipeline: The legacy app hardcoded B&T EDI messaging (850/855/856/810/997 consoles, manifests, invoices). B&T is now defunct. Decision pending: design an abstract "distribution-partner pipeline" reusable for future vendor relationships (purchase orders, EDI consoles, receiving, vendor payments). Deferred pending stakeholder input on future partner relationships. (20260703)
UNDECIDED — Receiving & Receiving/PO Domain: The app's PrintRunInventoryService lets operators receive and correct FINALINV at the print stage, but a broader receiving/PO domain (ONORDER tracking, RPURCHASES ownership, supplier management) has no app counterpart yet. Scope and business rules pending. Related: publisher link (royalties) editor is partial — journalkey-link editing surface unverified. (20260702)
UNDECIDED — Payment & Deposit System: Legacy tracked daily deposits, check cashing, and payment accounting. The modern app has no equivalent. Decision pending: whether to build in-app or integrate with an external accounting system. This blocks payment-system reconciliation reports and cashier workflows. (20260702)
Reporting & Analytics
UNDECIDED — Editorial Cost-of-Goods (CGS): Legacy tracked per-title cost (unit vs reprint). The modern app has no equivalent cost_of_goods field. Decision pending: add the field and populate from accounting system, or defer pending business rules clarification. Blocks accurate margin reporting.
UNDECIDED — Editorial Sales & Margin Reports: Legacy included per-title sales metrics and cost-of-goods analysis. Modern INVPROJECT (ISBN quantities/sales) report is built but stubbed for production (~2026-07-15 pending ops sign-off). Editorial margin reporting remains unaudited. PARITY.md status = unaudited. (20260702)
UNDECIDED — Web-Copy Proofing Emails: Legacy supported emailing titles for editorial copy review. The modern campaign/flyer export path exists but the single-title one-off proofing email is unaudited. Decision pending: whether to port or n-a as an outdated workflow. (20260702)
UNDECIDED — Multi-Format Report Exports: Reports currently export as JSON (consumable by web UI and API). Future CSV, Excel, and PDF support is planned but not yet implemented. Design pending: whether to implement in-app or delegate to a third-party reporting service.
UI Polish & UX
UNDECIDED — How-Can-We-Help CTA Block: Expiring/lagging plan emails include a generic action button but lack a richer "How can we help?" block. Deferred pending email-design review. (20260701)
UNDECIDED — Account Health Dashboard Surfacing: Derived health flags (has-expired-choice, has-lagging-expiring-choice, contact-health) exist but are not yet surfaced in account search results, account-rep dashboards, or the account-canvas tool descriptions. Deferred pending UI polish and stakeholder review of which flags are most useful. (20260701)
UNDECIDED — Knowledge Base Grounded-Answer Tier: Chat system will eventually support retrieval + optional LLM generation over internal documentation. Preprocessing is locked; engine choice (BM25 vs semantic embeddings) is deferred. Planned for Q4 2026+. (20260701)
UNDECIDED — Downstairs Reroute Picker UI: Legacy's manual downstairs override for grouped shipments is now expressed in the operator-routing model. CLI and admin tools exist; UX polish (a dedicated picker modal) is deferred. (20260703)
UNDECIDED — Scheduled Reminders for Staff: Native Laravel scheduler integration is available but deferred pending business rules clarification on reminder frequency and content. (20260616)
Teams working on these areas should consult the PARITY.md decision log for context and stakeholder feedback.
Part IV — Appendices
Appendix A — Legacy ↔ New Parity Matrix (durable summary)
[See appA_parity_matrix_summary.md for full content]
Appendix B — Legacy Procedure Glossary
Complete descriptive entry for all 379 top-level PROCEDURE and FUNCTION definitions from webnet.prg (the legacy Visual FoxPro application, 161,252 lines). Organized by domain following Part II taxonomy. Each entry: name, type, line number, and 1–2 sentence purpose statement. Entries are alphabetized within each domain section.
Phase 2 Research Complete — 20260704: All 379 routines extracted, categorized, and documented. PFP Decision: Option A — Full documentation. PFP is an archived subsystem documented for posterity; not intended for rebuild in the modern Laravel/Vue application but may be extracted as a separate application if future business requirements warrant.
[NOTE: Full glossary content (sections B.1–B.12) is in APPENDIX_B_DRAFT.md. For the sake of file size, the complete 1945-line glossary with all 379 routine entries is maintained as a separate reference file. To view the complete glossary with all sections, see: staff/storage/notes/APPENDIX_B_DRAFT.md]
B.1–B.12 [See existing PRD.md Appendix B sections for complete content]
Appendix C — Legacy Views & Pages Index (Client-Side Structure)
[Keep existing complete Appendix C from current PRD.md as-is — no changes needed]
Appendix D — Domain Seams & Gotchas
[See appD_domain_seams_gotchas.md for full content]
Appendix E — Glossary of Terms
[See appE_glossary_of_terms.md for full content]
Appendix F — DBF Table Families & Key Fields
[See appF_data_table_reference.md for full content]
Appendix G — Living-Docs Index
[See appG_living_docs_index.md for full content]
End of PRD.md