[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. GenerateDocumentPdfJob — public 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)
Research/scoping pass — sub-issues A–K to be created separately.
[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
AbstractReportDataMapperin Core and one concrete mapper per module that isselected when a report for that subject is requested.
Track B of epic #506. Builds on
feature/130-report-builder(PR #720).1. Where the Report Builder is hardcoded to 2 types today
Modules/Core/Enums/ReportTemplateType.phpcase INVOICE / QUOTE; exhaustivematchinlabel()/color()Modules/Core/Services/ReportDataMapper.phpforInvoice()/forQuote()methods, ~530 linesModules/Core/Services/PdfGenerationService.phpInvoice|Quoteunions throughout; method-per-type (renderInvoiceHtml,invoicePdf,downloadInvoice,handleInvoiceDownload,storeInvoicePdf, + quote twins)Modules/Core/Jobs/GenerateDocumentPdfJob.phppublic Invoice|Quote $document;matchinhandle()Modules/Core/ReportBuilder/ReportBrick.php+ 8 overridesallowedTypes(): array<ReportTemplateType>Modules/Core/ReportBuilder/ReportBricksCollection.phpforBand(ReportBand, ?ReportTemplateType)companies.invoice_template/companies.quote_templatecolumnsReportTemplateStorage::path()system/<type>/<slug>, driven byReportTemplateType->valueBaseReportBuilderPage::$slug = 'report-builder/{scope}/{type}/{slug}'ReportTemplateType::tryFrom($type)inmount()resources/report-templates/{invoice,quote}/default/Modules/Core/Console/ReportsSyncSystemCommand.phpFile::allFiles, already genericThe builder page,
ReportRenderer,MasonDocumentConverter,ReportTemplateStoragesave/load, and
reports:sync-systemare already subject-agnostic — they key offReportTemplateType->valueas an opaque string. The closed enum is the pinch point.2. Target architecture
2a.
ReportSubjectRegistry+ReportSubjectcontract (Core)Replace the closed enum with a registry each module contributes to from its
ServiceProvider
boot():ReportTemplateTypestays as a thin value object / string wrapper, or is retiredin favour of
ReportSubjectRegistry::get(string $key): ReportSubject. Everythingthat 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/AbstractReportDataMappercontracts, 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 genericwants(string $brickId, array $brickIds): boolgate.2c. Per-module concrete mappers
Modules/Invoices/Services/InvoiceReportDataMapperforInvoice()body here unchanged)Modules/Quotes/Services/QuoteReportDataMapperforQuote()body here unchanged)Modules/Projects/Services/ProjectReportDataMapperModules/Projects/Services/TaskReportDataMapperModules/Expenses/Services/ExpenseReportDataMapperModules/Clients/Services/ClientReportDataMapperModules/Payments/Services/PaymentReportDataMapperModules/Products/Services/ProductReportDataMapper2d.
PdfGenerationService— generic surfaceCollapse the method-per-type API to:
renderHtml(Model),pdf(Model),download(Model): ?Response,store(Model): string,resolveTemplate(Model): array. KeepdownloadInvoice()/downloadQuote()as2-line shims (2 call sites) or update the call sites and delete them.
2e.
GenerateDocumentPdfJob—public Model $documentuniqueId()is already$document::class . ':' . $document->getKey()— generic.handle()→$service->store($this->document).SerializesModelsalready coversany Eloquent model.
2f. Company default templates — stop adding columns
companies.invoice_template+quote_templatedon't scale to 8 subjects. Add acompany_report_defaultstable (company_id,subject,slug, unique oncompany_id+subject) or a single JSON column; migrate the two existing columnsinto it.
resolveTemplate()reads the default from there by subject key.2g. Bricks
ReportBrick::allowedTypes()→allowedSubjects(): array<string>(subjectkeys). Touches the base + 8 existing overrides +
ReportBricksCollection::forBand.HeaderCompanyBrick,SpacerBrick,PageBreakBrick,FooterNotesBrick,FooterTermsBrick.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}.jsonper subject.reports:sync-systemneeds no change.download_pdf+ edit-page header action onProjectsTable,TasksTable,ExpensesTable,RelationsTable,PaymentsTable,ProductsTable,gated on each module's existing
EXPORT_*/VIEW_*permission.<subject>/defaultships,BaseReportTemplatesPage::cloneAction()alreadysupports "clone and build" for that subject with no change.
3. Sharp edges (decide before building)
3.1 No
updated_aton any of the new subject modelsProject,Task,Expense,Payment,Product,Relationall declarepublic $timestamps = false;.PdfGenerationService::storedPdfIsFresh()compares$disk->lastModified($path) >= $document->updated_at?->timestamp ?? 0— with noupdated_atthe stored PDF is always considered fresh and edits neverinvalidate it. Options: (a) new subjects are inline-render only,
allowsQueue() => false, until this is solved; (b)fingerprintFor()returns a content hash ormax(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-
$recordentry path. Decide whether this epic covers onlyper-record reports and collection/tabular reports stay with the unbuilt Reports
module (#506), or whether
ReportSubject::isCollection()+ a param schema is inscope here.
3.3
totals/summary/terms/footerInvoice/quote have real columns for these; the other six don't.
AbstractReportDataMapperdefaults them to empty strings / zeroed totals; eachsubject mapper overrides only what it has (project billing total, client open
balance, payment amount).
3.4 Enum → registry is itself a refactor
ReportTemplateTypeis 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)
AbstractReportDataMapperextraction; move Invoice/Quote mappers into their modulesReportSubjectRegistry+ReportSubjectcontract; switch enum→registry inPdfGenerationService,GenerateDocumentPdfJob, builder route, brickscompany_report_defaultsstorage; migratecompanies.invoice_template/quote_templateproject/default+ bricks +ProjectsTableaction + tests)fingerprintFor) — see §3.1allowsQueue()can flip onAcceptance (epic-level)
resources/report-templates/<subject>/default/folder + one registry line + one row action. No edit to Core enums,PdfGenerationService, orGenerateDocumentPdfJob.php artisan test --filter='AdminReportBuilderTest|CompanyReportBuilderTest|MasonDocumentConverterTest|MasonBricksTest'pdf()returns%PDF…%%EOF, list-page row action streams (queue off) / dispatches once (queue on, whereallowsQueue()), 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.