Phase 1 of Peppol BIS Advanced Ordering 3.0 support: a real UBL Order
document received over AS4 is now parsed and turned into a SalesOrder +
SalesOrderItems. This app only ever plays Seller for Ordering — it
never issues Order/OrderChange/OrderCancellation itself, only ever
sends OrderResponseAdvanced back (Phase 2, see
docs/PEPPOL_ADVANCED_ORDERING_OUTBOUND_AUGUST_2026.md).
UblOrderData/UblOrderLineDataDTOs,UblOrderXmlParser(Buyer party,cac:OrderLine/cac:LineItemstructure — verified against the real Peppol docs),As4OrderImportService(As4PayloadHandlerInterface).As4PayloadRouteris new too:As4UserMessageHandlerServiceonly ever held one handler before (always Invoice); it now routes by the payload's root element to the existing Invoice importer or the new Order one.Schema::ORDER_NSadded alongside the existing UBL namespace constants.- Extracted
UblXmlHelperTraitandAs4PartyIdSplitTraitup front to avoid duplicating the small XPath/party-id-splitting helpers across both parsers/importers.
SalesOrder.quote_idhad a genuineNOT NULLFK toquote.id— everySalesOrderwas required to descend from a staff-authoredQuote, which an inboundOrdernever has. Fixed with a real migration (Cycle'sBUILD_DATABASEschema sync only adds columns, it doesn't relax an existing one's nullability — had to hand-write theALTER TABLE). Cycle relation changed tonullable: true; all 5 pre-existing rows'quote_idvalues untouched.ClientPeppolRepositoryInterfacehad no DI binding anywhere in the codebase, meaningAs4InvoiceImportService(which also depends on it) was likely never actually resolvable through the container in any context. Added the missing binding inconfig/common/di/as4.php.
SalesOrder.peppol_order_response_code (nullable) tracks the eventual
AB/AP/RE/CA OrderResponseAdvanced status — deliberately orthogonal to
the pre-existing status_id/SalesOrderStatusTrait, which has no state
meaning "awaiting a Peppol response". SalesOrderItem's existing
peppol_po_itemid/peppol_po_lineid fields (already present from the
Invoice-side pattern) are reused for the buyer's own line id. Every
imported line gets TaxRateRepository::repoFirstByIdQuery()'s rate as a
placeholder, since a Peppol Order line carries no tax information at all
(confirmed against the real element tree).
Live-verified end-to-end with a throwaway console command (written, run
once against the real DB, then deleted) using a real seeded
client_peppol row: correct SalesOrder (client resolved, quote_id
NULL confirming the FK fix, buyer's Order number captured) and
SalesOrderItem (tax rate resolved, buyer's line id preserved). Test
rows cleaned up after. Also fixed 3 unrelated pre-existing PHPUnit
notices found while re-running the full suite (stub-without-expectations
in StripeWebhookHandlerTest/AdyenWebhookHandlerTest/
GoCardlessWebhookHandlerTest) via
#[AllowMockObjectsWithoutExpectations], per the project's standing
convention for that notice class. Full-project Psalm clean, PHPUnit
3936/3936 (0 notices), Testo 943/943.
Not yet built: OrderResponseAdvanced generation/sending (see Phase 2),
inbound OrderChange/OrderCancellation, any staff UI to action an
imported SalesOrder.