Skip to content

[Reports] Epic: Report Builder — arbitrary report subjects (Abstract + per-module DataMappers) #765

Description

@nielsdrost7

[Reports] Epic: Report Builder — arbitrary report subjects

Was: "[Reports]: Project report type — end-to-end" (single story).
Now: the Report Builder should be able to produce a report for any module
subject — Invoice, Quote, Project, Task, Expense, Client, Payment, Product — via
one AbstractReportDataMapper in Core and one concrete mapper per module that is
selected when a report for that subject is requested.

Track B of epic #506. Builds on feature/130-report-builder (PR #720).

Status: research only — nothing built yet. This document is the scoping
pass. Sub-issues below are proposed, not created.


1. Where the Report Builder is hardcoded to 2 types today

Seam File Hardcoded to invoice/quote how
Type enum (closed set) Modules/Core/Enums/ReportTemplateType.php case INVOICE / QUOTE; exhaustive match in label() / color()
Data mapping Modules/Core/Services/ReportDataMapper.php one class, forInvoice() / forQuote() methods, ~530 lines
PDF service Modules/Core/Services/PdfGenerationService.php Invoice|Quote unions throughout; method-per-type (renderInvoiceHtml, invoicePdf, downloadInvoice, handleInvoiceDownload, storeInvoicePdf, + quote twins)
Queue job Modules/Core/Jobs/GenerateDocumentPdfJob.php public Invoice|Quote $document; match in handle()
Bricks Modules/Core/ReportBuilder/ReportBrick.php + 8 overrides allowedTypes(): array<ReportTemplateType>
Brick palette Modules/Core/ReportBuilder/ReportBricksCollection.php forBand(ReportBand, ?ReportTemplateType)
Company default template companies.invoice_template / companies.quote_template columns one nullable string column per type
Disk layout ReportTemplateStorage::path() system/<type>/<slug>, driven by ReportTemplateType->value
Builder route BaseReportBuilderPage::$slug = 'report-builder/{scope}/{type}/{slug}' ReportTemplateType::tryFrom($type) in mount()
Shipped defaults resources/report-templates/{invoice,quote}/default/ only two subjects ship
Sync command Modules/Core/Console/ReportsSyncSystemCommand.php none — globs File::allFiles, already generic

The builder page, ReportRenderer, MasonDocumentConverter, ReportTemplateStorage
save/load, and reports:sync-system are already subject-agnostic — they key off
ReportTemplateType->value as an opaque string. The closed enum is the pinch point.


2. Target architecture

2a. ReportSubjectRegistry + ReportSubject contract (Core)

Replace the closed enum with a registry each module contributes to from its
ServiceProvider boot():

interface ReportSubject
    key(): string                     // 'project', 'expense', …  (disk path + route segment)
    label(): string
    modelClass(): class-string<Model>
    mapper(): class-string<AbstractReportDataMapper>
    numberFor(Model $record): string  // for the stored-PDF filename
    fingerprintFor(Model $record): int // freshness key for the queue path — see §3.1
    allowsQueue(): bool               // false until a fingerprint exists
    indexRoute(): ?string             // where the "print" row action lives
    isCollection(): bool              // false = one record → one PDF; see §3.2

ReportTemplateType stays as a thin value object / string wrapper, or is retired
in favour of ReportSubjectRegistry::get(string $key): ReportSubject. Everything
that currently does ReportTemplateType::tryFrom($x) becomes a registry lookup;
everything that does match ($type) disappears.

Dependency direction stays clean: modules depend on Core (register their subject +
mapper); Core depends only on the ReportSubject / AbstractReportDataMapper
contracts, never on a concrete module mapper.

2b. AbstractReportDataMapper (Core)

Pull the shared helpers out of today's ReportDataMapper:
companyData, clientData, money, communication, logoPath, cap,
maxRows, agingData, formatAgingTotals, plus a generic
wants(string $brickId, array $brickIds): bool gate.

abstract class AbstractReportDataMapper
    abstract public function map(Model $record, array $brickIds = []): array

2c. Per-module concrete mappers

Subject Mapper (new home) Report meaning Anchor
Invoice Modules/Invoices/Services/InvoiceReportDataMapper (move forInvoice() body here unchanged) 1 record
Quote Modules/Quotes/Services/QuoteReportDataMapper (move forQuote() body here unchanged) 1 record
Project Modules/Projects/Services/ProjectReportDataMapper project sheet: header, task table, billing totals 1 record
Task Modules/Projects/Services/TaskReportDataMapper single task detail / work order 1 record
Expense Modules/Expenses/Services/ExpenseReportDataMapper expense voucher: category, vendor, items, amount 1 record
Client Modules/Clients/Services/ClientReportDataMapper statement: their invoices + payments + aging + open balance over a period 1 record + period
Payment Modules/Payments/Services/PaymentReportDataMapper payment receipt: amount, method, the invoice it settled, client 1 record
Product Modules/Products/Services/ProductReportDataMapper product spec sheet or price list 1 record or collection

2d. PdfGenerationService — generic surface

Collapse the method-per-type API to:
renderHtml(Model), pdf(Model), download(Model): ?Response, store(Model): string,
resolveTemplate(Model): array. Keep downloadInvoice() / downloadQuote() as
2-line shims (2 call sites) or update the call sites and delete them.

2e. GenerateDocumentPdfJobpublic Model $document

uniqueId() is already $document::class . ':' . $document->getKey() — generic.
handle()$service->store($this->document). SerializesModels already covers
any Eloquent model.

2f. Company default templates — stop adding columns

companies.invoice_template + quote_template don't scale to 8 subjects. Add a
company_report_defaults table (company_id, subject, slug, unique on
company_id+subject) or a single JSON column; migrate the two existing columns
into it. resolveTemplate() reads the default from there by subject key.

2g. Bricks

  • Widen ReportBrick::allowedTypes()allowedSubjects(): array<string> (subject
    keys). Touches the base + 8 existing overrides + ReportBricksCollection::forBand.
  • Generic bricks unchanged (apply to every subject): HeaderCompanyBrick,
    SpacerBrick, PageBreakBrick, FooterNotesBrick, FooterTermsBrick.
  • New subject bricks (rough list): HeaderProjectBrick (exists — restrict),
    DetailTasksBrick (exists), HeaderClientStatementBrick,
    DetailInvoiceListBrick, DetailPaymentListBrick, HeaderPaymentReceiptBrick,
    HeaderExpenseBrick, DetailExpenseItemsBrick, HeaderProductBrick,
    DetailProductListBrick.

2h. Shipped defaults + entry points

  • resources/report-templates/<subject>/default/{manifest,bands}.json per subject.
    reports:sync-system needs no change.
  • Row action download_pdf + edit-page header action on ProjectsTable,
    TasksTable, ExpensesTable, RelationsTable, PaymentsTable, ProductsTable,
    gated on each module's existing EXPORT_* / VIEW_* permission.
  • Once <subject>/default ships, BaseReportTemplatesPage::cloneAction() already
    supports "clone and build" for that subject with no change.

3. Sharp edges (decide before building)

3.1 No updated_at on any of the new subject models

Project, Task, Expense, Payment, Product, Relation all declare
public $timestamps = false;
. PdfGenerationService::storedPdfIsFresh() compares
$disk->lastModified($path) >= $document->updated_at?->timestamp ?? 0 — with no
updated_at the stored PDF is always considered fresh and edits never
invalidate it. Options: (a) new subjects are inline-render only, allowsQueue() => false, until this is solved; (b) fingerprintFor() returns a content hash or
max(related updated_at); (c) add nullable timestamps to these tables. Recommend
(a) for the first cut.

3.2 Single-record vs collection / parameterised reports

Project / Task / Expense / Payment map to "one record → one PDF" cleanly. Client
statement
needs a period parameter. Product price list and "all expenses
Q3" are collection reports with no anchor record. The band/brick model already
handles repeating detail rows; what's missing is a parameter form (date range,
status filter) and a no-$record entry path. Decide whether this epic covers only
per-record reports and collection/tabular reports stay with the unbuilt Reports
module (#506), or whether ReportSubject::isCollection() + a param schema is in
scope here.

3.3 totals / summary / terms / footer

Invoice/quote have real columns for these; the other six don't.
AbstractReportDataMapper defaults them to empty strings / zeroed totals; each
subject mapper overrides only what it has (project billing total, client open
balance, payment amount).

3.4 Enum → registry is itself a refactor

ReportTemplateType is referenced in ~15 files incl. tests, sanitizeBands,
brick signatures. The registry swap is a discrete piece of work that should land
before any new subject, with invoice/quote behaviour byte-identical after it.


4. Proposed sub-issues (create separately)

# Title Depends on Outcome
A AbstractReportDataMapper extraction; move Invoice/Quote mappers into their modules pure refactor, invoice/quote render byte-identical, RB regression green
B ReportSubjectRegistry + ReportSubject contract; switch enum→registry in PdfGenerationService, GenerateDocumentPdfJob, builder route, bricks A builder route + service keyed by registry; 2 subjects registered; no behaviour change
C company_report_defaults storage; migrate companies.invoice_template / quote_template B company default lookup is subject-generic
D Project report subject (mapper + project/default + bricks + ProjectsTable action + tests) B, C projects printable + buildable
E Task report subject B, C tasks printable
F Expense report subject B, C expenses printable
G Client statement subject (needs §3.2 period decision) B, C client statements
H Payment receipt subject B, C payment receipts
I Product sheet / price list subject (needs §3.2 collection decision) B, C product sheets
J Queue-path freshness for timestamp-less subjects (fingerprintFor) — see §3.1 D–I allowsQueue() can flip on
K Collection / parameterised report support (date range, filters) — or defer to #506 B decision + spec

Acceptance (epic-level)

  • Adding a new report subject = one new module class + one resources/report-templates/<subject>/default/ folder + one registry line + one row action. No edit to Core enums, PdfGenerationService, or GenerateDocumentPdfJob.
  • Invoice + quote PDFs render byte-identical to pre-refactor across the existing suite (golden-file check).
  • Mandatory RB regression filter green:
    php artisan test --filter='AdminReportBuilderTest|CompanyReportBuilderTest|MasonDocumentConverterTest|MasonBricksTest'
  • Each subject: builder mounts for its type, palette shows only its allowed bricks, preview renders non-empty, pdf() returns %PDF%%EOF, list-page row action streams (queue off) / dispatches once (queue on, where allowsQueue()), tenant isolation holds, CompanyObserver::deleted() still wipes the disk.
  • pint --dirty + phpstan clean on touched files.


Research/scoping pass — sub-issues A–K to be created separately.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions