CompanyInvoiceGenerator
in package
Translates the selected work sheets of a job into an {@see InvoiceComposer} request.
A job is stored as WorkList (historical class name); a work sheet lives inside a job.
All invoice knowledge (seller/customer snapshot, billing line, PDF variant matrix,
calculator, PDF + QR) sits in Invoicer; this class only says "these work items, for this
customer, for this company". Another module must not rely on the e-shop library or build
its own copy of invoice composition.
Contract:
- Pure builder. No write, transaction, lock or number from a billing line. The returned
invoice is not saved: the caller (
WorkListManager::invoiceSheets()) saves it under the job lock after the sheets move toclosing, so every build error happens while the sheets are still open and bad input never leaves them half-processed. - No idempotency (see the composer contract). Two calls make two invoices; whether the
sheets were already invoiced is decided by
WorkListManager::invoiceSheets(). - Guards are specific. A missing seller, invoice type, currency or customer and an
unusable work item return
Result::failure()with anerrorCodeand a message that names what to fill in. Invoice type and currency resolve through the chain job -> customer billing profile -> company settings; the guard runs after the whole chain, so the message names all three places. Per-item VAT (WorkItem::getVatPercents()) bypasses the guards: null tells the composer to use the request default. - One line per work item. Hourly work:
quantity= actual hours,unit= hour,price= hourly rate (the calculator multiplies). A fixed price (manual_price) wins: quantity 1, unit piece, price = fixed price. Totals are not sent; the Invoicer calculator is authoritative. - N sheets make one invoice. The customer is on the job, not on the sheet. The line
descriptioncarries the sheet name;namestays the item title andcodestays empty. Without it the items of three sheets would merge into one indistinguishable column and the customer could not see which batch they pay for. - The caller selects and orders. The generator neither filters sheets and items (the
filter is in
WorkItemManager::findBillableForSheets()) nor reorders them. If both did it their own way, the invoice would differ from what the admin confirmed in the invoice modal, which is the worst kind of error with money: a silent one.
IMPORTANT: until cms_mod_invoicer_invoice_items.quantity is migrated to decimal(8,2),
DecimalQuantityGuardTrait in InvoiceManager::beforeSave() rejects fractional hours.
The generator deliberately neither bypasses it nor rounds hours, because rounding would
silently change the invoice amount (2.5 h -> 3 h = +250 at a rate of 500). Whole hours
pass either way; WorkListManager::invoiceSheets() turns the rejection into a readable
message.
Table of Contents
Constants
- UNIT_HOUR : string = 'hod'
- Unit of hourly work. Fixed in Czech for now; the invoice language is not resolved yet.
- UNIT_PIECE : string = 'ks'
- Unit of fixed-price work: one piece of the delivered service.
Properties
Methods
- __construct() : mixed
- generate() : Result
- Builds an unsaved invoice for the work items of the selected work sheets of one job.
- buildLine() : InvoiceComposerLine|Result
- Maps one work item to one invoice line. Returns a `Result` failure when a human must fix the item.
- buildRequest() : InvoiceComposerRequest|Result
- Runs the header and line guards and builds the composer request. Returns a `Result` on invalid input.
- failure() : Result
- Builds a failure `Result` carrying `errorCode` in its data (composer contract).
- sheetLabel() : string
- Returns the work sheet label for the line `description`.
Constants
UNIT_HOUR
Unit of hourly work. Fixed in Czech for now; the invoice language is not resolved yet.
public
string
UNIT_HOUR
= 'hod'
UNIT_PIECE
Unit of fixed-price work: one piece of the delivered service.
public
string
UNIT_PIECE
= 'ks'
Properties
$composer read-only
private
InvoiceComposer
$composer
$invoicerCustomerManager read-only
private
CustomerManager
$invoicerCustomerManager
Methods
__construct()
public
__construct(InvoiceComposer $composer, CustomerManager $invoicerCustomerManager) : mixed
Parameters
- $composer : InvoiceComposer
- $invoicerCustomerManager : CustomerManager
generate()
Builds an unsaved invoice for the work items of the selected work sheets of one job.
public
generate(Company $company, WorkList $list, array<int, WorkItem>}> $sheetsWithItems) : Result
Success: a Result with an Invoice entity in its data (composer contract).
Failure: Result::failure() with ['errorCode' => ...]; the codes are permanent (a
configuration or data defect that retrying does not fix):
customerMissing- the job has no customer (an internal job cannot be invoiced),sellerMissing- the company has noinvoicer_seller_id(invoice header),invoiceTypeMissing- neither job, customer nor company has an invoice type,currencyMissing- neither job, customer nor company has a currency,noLines- empty list of work items (the caller reports it asnothingToInvoice),invalidWorkItem- the price cannot be computed (missing rate or hours),- plus any code of InvoiceComposer::compose() (e.g.
numberSourceMissing).
The input is N work sheets of one job, not a flat list of items: the caller passes
[['sheet' => ..., 'items' => [...]], ...] already ordered by work_sheets.order and
within a sheet by work_items.order. The generator neither reorders nor filters. The
sheet a line belongs to is carried by its description (the sheet name).
Parameters
- $company : Company
-
company of the job; carries the invoicing identity. The templates are covariant on purpose: only scalar fields are read (seller, invoice type, currency, billing line, VAT rate), never translations or gallery, so it does not matter which layer the company came from; an invariant
<object, object>would force pointless casts - $list : WorkList
- $sheetsWithItems : array<int, WorkItem>}>
-
billable items per sheet, selected by the caller (
billable = 1 and deleted = 0 and price != 0); the generator does not filter them, so the invoice cannot differ from what the invoice modal showed
Return values
ResultbuildLine()
Maps one work item to one invoice line. Returns a `Result` failure when a human must fix the item.
private
buildLine(WorkItem $item, WorkSheet $sheet) : InvoiceComposerLine|Result
A fixed price wins over the calculation, exactly as WorkItemManager::computePrice()
does. If the two diverged, the invoice would state a different amount than the price
column the admin summed in the grid.
actual_hours goes to quantity as the decimal string from the database ("2.50"), not
as a float: a float conversion is a needless risk with money, and both the composer and
the entity accept strings
(DecimalQuantityTrait).
description is the work sheet name. On an invoice built from N sheets it is the only
hint of which batch the item comes from; name is the item title and code stays empty.
The line supply kind is work_items.supply_kind_id; when null the composer uses the
company default (companies.default_supply_kind_id, sent in the request header). The VAT
engine derives the rate from it on the supply date, so vat applies only where neither
the item nor the company gives a supply kind.
Per-line VAT: WorkItem::getVatPercents() goes to InvoiceComposerLine::$vat without any
chain; null tells the composer to use the request default, which already carries the
customer -> company chain (see vatPercents in InvoiceComposerRequest::create()).
Parameters
Return values
InvoiceComposerLine|ResultbuildRequest()
Runs the header and line guards and builds the composer request. Returns a `Result` on invalid input.
private
buildRequest(Company $company, WorkList $list, array<int, WorkItem>}> $sheetsWithItems) : InvoiceComposerRequest|Result
Kept apart from generate() on purpose: building the request is a pure function
over entities (no database, no DI), whereas compose() touches the Invoicer directory.
So all local logic (line order, description, mapping of hours and fixed price) can be
verified without a container: tests/Manual/Company/w4_generator_check.php.
Parameters
Return values
InvoiceComposerRequest|Resultfailure()
Builds a failure `Result` carrying `errorCode` in its data (composer contract).
private
failure(ResultCode $code, string $errorCode, string $message) : Result
Parameters
- $code : ResultCode
- $errorCode : string
- $message : string
Return values
ResultsheetLabel()
Returns the work sheet label for the line `description`.
private
sheetLabel(WorkSheet $sheet) : string
An empty sheet name is replaced by a deterministic placeholder: an invoice line without any hint of its sheet is worse than a fallback number.
Parameters
- $sheet : WorkSheet