Appearance
Architecture & extension points
For developers
Namespace Kommandhub\LastMileDeliverySW\ → src/. Plugin class Kommandhub\LastMileDeliverySW\KmhLastMileDeliverySW (technical name KmhLastMileDeliverySW). Package kommandhub/last-mile-delivery-sw, no runtime dependencies beyond shopware/core ~6.6.0 || ~6.7.0.
Modules
| Path | Responsibility |
|---|---|
KmhLastMileDeliverySW.php | Lifecycle: install / activate / update run ShippingMethodInstaller and OtpMailFlowInstaller; deactivate / uninstall deactivate the shipping method; uninstall without keep-data runs Installer/UserDataRemover. Loads Resources/config/packages/*.yaml (the monolog channel). |
Core/Content/ | DAL entities: price rules, surcharges, pricing snapshot, order OTP; write validators; entity extensions on order and order_delivery. |
DeliveryPricing/ | Pricing pipeline: DeliveryPriceCalculationService, ShipmentContextFactory, DeliveryPriceCalculator, DeliveryRuleMatcher, SalesChannelPriceResolver, Surcharge/*, Subscriber/PricingSnapshotSubscriber. |
Map/ | MapProviderInterface, MapProviderRegistry, Provider/GoogleMapsProvider, Provider/OpenStreetMapProvider, MapDistanceService (distance cache), StoreLocationProvider. |
Shipping/ | InstantDeliveryShippingMethod (identity), InstantDeliveryProcessor (cart processor), DeliveryAddressResolver, Error/InstantDeliveryError, Installer/ShippingMethodInstaller. |
Otp/ | OtpService, PodService, OtpGenerator, OtpHasher, Verification/OtpVerificationPolicy, Flow events, Subscriber/FlowEventSubscriber, Installer/OtpMailFlowInstaller. |
Delivery/ | in_transit state constants, AutoShipmentPolicy, DeliveryVerificationEvaluator, DeliveryStateTransitioner, DeliveryStateChangeSubscriber, ShipOnOtpVerifiedSubscriber, Event/OrderDeliveryInTransitEvent. |
Administration/Controller/ | OtpVerificationController — the three Admin API actions. |
Setting/Service/Config.php | Typed, sales-channel-aware reader for KmhLastMileDeliverySW.config.*. |
Logging/ConfigurableLogger.php | PSR-3 wrapper over the kmh_lastmiledelivery_channel monolog channel, gated on the debug settings. |
Resources/app/administration/ | Two Settings modules, two order-detail cards, the ACL entry, an API service. |
There are no storefront templates, storefront JavaScript, CLI commands, scheduled tasks or message-queue handlers.
Pricing pipeline
text
Cart calculation
└─ InstantDeliveryProcessor::process() cart processor, priority -6000 (core DeliveryProcessor: -5000)
├─ selected shipping method technicalName ≠ kmh_lastmiledelivery_instant → return
├─ CartBehavior has 'skipDeliveryPriceRecalculation' (Administration order edit) → return
├─ no deliveries → return
├─ DeliveryAddressResolver: "street, zip city, country" | null → InvalidAddress
└─ DeliveryPriceCalculationService::calculate()
├─ MapDistanceService::distanceKmForAddress() null → DistanceUnavailable
│ StoreLocationProvider (originLatitude/Longitude) → registry->get(distanceProvider)
│ cache.app: get(key, geocode → distance) key = lmd_dist_<provider>_<lat>_<lng>_<sha256(normalised address)>
├─ ShipmentContextFactory::fromCart() Metric values
├─ DeliveryPriceCalculator::calculate() null → NoMatchingRule
│ active rules, priority DESC; ruleId ∈ context ruleIds; first match → SalesChannelPriceResolver
│ rounded to context currency decimals
└─ SurchargeEngine::apply() PricingBreakdown(base, surcharges[], final)
├─ failure → cart error InstantDeliveryError (blockOrder = true), log info
└─ success → every delivery: setShippingCosts(QuantityPriceCalculator, tax by shipping method taxType)
addExtension('kmhLastMileDeliveryPricing', PricingBreakdownStruct)
CartConvertedEvent
└─ PricingSnapshotSubscriber → converted delivery['lastMileDeliveryPricing'] = {currencyId, basePrice, finalPrice, breakdown}- Rule selection is deterministic because active rules have unique priorities (enforced by
DeliveryPriceRuleWriteValidator). The first matching rule is final even when it has no price for the channel. - Fail closed: a rule or surcharge with an unknown metric is skipped; a condition on a metric the shipment didn't measure doesn't match; a surcharge with an unknown type/calculation type, a fixed amount without a price, or a percentage of
nullis skipped. SalesChannelPriceResolver: row for the sales channel → row withsalesChannelId = null→ none; then the currency's price, else the default currency's price ×Context::getCurrencyFactor(). Net price when the context tax state isn't gross.- Surcharge order:
priority DESC, id ASC; each amount rounded toContext::getRounding()->getDecimals();RunningTotalpercentages use base + previously applied amounts; the final price is rounded once more. - Time:
FulfillmentClock::now()infulfillmentTimezone(invalid/empty → UTC).PeakPeriodEvaluator: weekday bitmask bit 0 = Monday … bit 6 = Sunday,0= every day;[start, end);end <= startcrosses midnight and is attributed to the start day; period dates inclusive. - Map providers: HTTP options
timeout: 3, max_duration: 5. Google:APPROXIMATEresults → not found;REQUEST_DENIED,OVER_DAILY_LIMIT,INVALID_REQUESTlogged as error, other non-OK statuses as warning. Nominatim:place_rank < 26→ not found. Unresolvable addresses cached asnullfor 3600 s;MapProviderExceptions are not cached and degrade tonull.
Delivery verification
text
StateMachineTransitionEvent (order_delivery → in_transit)
└─ DeliveryStateChangeSubscriber
├─ OtpService::issueForOrder() otpEnabled? no record yet? → create (hash, pending, expiresAt) → OrderOtpGeneratedEvent
└─ dispatch OrderDeliveryInTransitEvent
POST verify-otp → OtpService::verify()
├─ OtpVerificationPolicy::decide() verified → expired → attempts ≥ max → password_verify
├─ reserveAttempt(): UPDATE … SET attempts = attempts + 1 WHERE verified_at IS NULL [AND attempts < :max]
├─ markVerified(): UPDATE … SET status = 'verified', verified_at = now WHERE verified_at IS NULL
└─ OrderOtpVerifiedEvent → ShipOnOtpVerifiedSubscriber → DeliveryVerificationEvaluator
POST proof-of-delivery → PodService::attachProof()
enablePod? media exists, is image, uploadedAt ≥ orderDateTime → upsert podMediaId → DeliveryVerificationEvaluatorAuto-shipment
DeliveryVerificationEvaluator loads the order's record, computes otpVerified (verifiedAt set or status verified) and podProvided (podMediaId set), and asks the pure AutoShipmentPolicy: eligible = setStateShippedOnVerification && requirement met, where a disabled POD collapses the requirement to otp_only. If eligible, DeliveryStateTransitioner::shipDelivery() loads all deliveries of the order (live version, sorted by shipping cost) and, for each one whose available transitions include one to shipped, calls StateMachineRegistry::transition() with action ship. Unavailable or rejected transitions are logged and skipped.
OtpService and PodService always write with the live version context, because the Administration's order page works on a draft version; the order → lastMileDeliveryOtp and order_delivery → lastMileDeliveryPricing extensions carry CascadeDelete(false) so they aren't cloned into (and deleted with) draft versions.
Data model
All tables are created by migrations Migration1787323600… – Migration1787323635… and dropped by UserDataRemover. Repositories follow Shopware's naming, e.g. kmh_lastmiledelivery_delivery_price_rule.repository.
| Entity | Key fields | Notes |
|---|---|---|
kmh_lastmiledelivery_delivery_price_rule | name, priority (int), active (DB default 1), ruleId → rule (SET NULL) | Associations conditions, prices (cascade delete). |
kmh_lastmiledelivery_delivery_price_rule_condition | deliveryPriceRuleId, metric (string), minValue, maxValue (double, nullable) | Metrics: distance, weight, volumetric_weight, chargeable_weight, longest_edge, item_count, order_value. |
kmh_lastmiledelivery_delivery_price_rule_price | deliveryPriceRuleId, salesChannelId (nullable = default), price (PriceField) | Unique key (delivery_price_rule_id, sales_channel_id); the write validator also enforces one default row. |
kmh_lastmiledelivery_delivery_surcharge | name, description, type, active, priority, calculationType (fixed/percentage), percentage, percentageBase (base_price/running_total, DB default base_price), ruleId, validFrom, validUntil | Associations conditions, prices, periods, salesChannels (ManyToMany; empty = all). Types: peak_hour, weekend_holiday, remote_area, bulky, fuel. |
kmh_lastmiledelivery_delivery_surcharge_condition / _price / _period / _sales_channel | as the rule tables; period: weekdayMask, startTime, endTime (HH:MM), validFromDate, validUntilDate | |
kmh_lastmiledelivery_delivery_pricing_snapshot | orderDeliveryId + version, currencyId, basePrice, finalPrice, breakdown (JSON: basePrice, surcharges[] with surchargeId, name, type, calculationType, inputValue, baseAmount, amount, finalPrice) | One per order delivery (unique). Extension lastMileDeliveryPricing on order_delivery. |
kmh_lastmiledelivery_order_otp | orderId + version, otpHash (not API-aware, nullable), status (pending/verified), attempts, expiresAt, verifiedAt, podMediaId → media (SET NULL), podSubmittedAt | One per order (unique). Extension lastMileDeliveryOtp on order. FK to order cascades on delete. |
Write validation (PreWriteValidationEvent): DeliveryPriceRuleWriteValidator — unique priority among active rules (duplicate_priority), ranges (invalid_range), known metrics (unknown_metric), one price per rule and sales channel (duplicate_sales_channel_price). DeliverySurchargeWriteValidator — known type / calculation_type / percentage_base / metric (unknown_value), ranges, positive percentage for percentage surcharges (missing_percentage), HH:MM period times (invalid_time_format). Violation codes are the strings in brackets.
The in_transit state and its transitions are inserted by Migration1787323610AddDeliveryStates into order_delivery.state (idempotent).
Events (Flow Builder triggers)
Registered through FlowEventSubscriber on BusinessEventCollectorEvent.
| Event name | Class | Data | Interfaces |
|---|---|---|---|
kmh_lastmiledelivery.order_otp.generated | Otp\Event\OrderOtpGeneratedEvent | order, otp (plain code), otpExpiresAt (ATOM string) | OrderAware, MailAware (customer), ScalarValuesAware |
kmh_lastmiledelivery.order_delivery.in_transit | Delivery\Event\OrderDeliveryInTransitEvent | order | OrderAware, MailAware, SalesChannelAware |
kmh_lastmiledelivery.order_otp.verified | Otp\Event\OrderOtpVerifiedEvent | order | OrderAware, MailAware, SalesChannelAware |
The plain code exists only inside OrderOtpGeneratedEvent; it is never stored or logged. Mail template types: kmh_lastmiledelivery_order_otp, kmh_lastmiledelivery_delivery_confirmed.
Extension points
Add a map provider
Implement Kommandhub\LastMileDeliverySW\Map\MapProviderInterface (getName(), geocode(), distance()) in a service. Implementations are auto-tagged kmh_lastmiledelivery.map_provider through _instanceof in the plugin's services.yml — in your own plugin, add the tag yourself:
yaml
App\Map\MyProvider:
tags: ['kmh_lastmiledelivery.map_provider']Store getName() in KmhLastMileDeliverySW.config.distanceProvider (the Administration select only offers the two built-in values, so set it via bin/console system:config:set or the API). Throw Map\Exception\MapProviderException for transport or provider errors (not cached) and return null from geocode() for an address that doesn't exist (cached for an hour).
Add a surcharge contributor
DeliveryPricing\Surcharge\SurchargeContributor::contribute(PricingContext): iterable<AppliedSurcharge> — for charges the configured surcharges can't express. Tag the service kmh_lastmiledelivery.surcharge_contributor. Contributions are appended after the configured surcharges, added to the running total, persisted in the breakdown and shown on the card. None ship with the plugin.
Other hooks
- Read or write rules and surcharges through their repositories or the Admin API.
- Read the breakdown from
order.deliveries[].extensions.lastMileDeliveryPricingand the verification status fromorder.extensions.lastMileDeliveryOtp(add the associations to your criteria). - Build flows on the three triggers.
- Change the storefront messages via the snippet keys
checkout.kmh-lastmiledelivery-instant-invalid_address|distance_unavailable|no_matching_rule.
Logging
Channel kmh_lastmiledelivery_channel, rotating file var/log/kmh_lastmiledelivery_<env>.log, 7 files. ConfigurableLogger always passes emergency–error; lower levels only when enableDebugging is on and the level is in logLevels (or logLevels is empty). Settings are resolved for the salesChannelId in the log context — the plugin's own calls don't pass one, so the global value applies. The logger is bound only to the plugin's services (bind in services.yml), not the container-wide LoggerInterface.
Administration
Sources in src/Resources/app/administration/src/; built bundles in src/Resources/public/administration/ are generated and committed. Shopware 6.6 loads js/ + css/ (webpack), 6.7 loads .vite/ + assets/ (Vite). Rebuild both, 6.7 first — the Vite build empties public/administration/. Admin code targets APIs present in both versions (e.g. the order-detail store is Pinia in 6.7, Vuex in 6.6).
Routes: kmh.lastmiledelivery.price.rule.list|detail|create, kmh.lastmiledelivery.surcharge.list|detail|create, all with meta.privilege: lastmiledelivery.manage. The cards are mounted by overriding block sw_order_detail_general_line_items_card of sw-order-detail-general.
Development & testing
The repository contains a Docker stack (make up, make prepare, make shell); Administration at http://localhost/admin.
| Command | What it does |
|---|---|
make test / make test FILTER=ConfigTest | PHPUnit |
make test-coverage | Coverage report (held at 100 % for src/ without the bootstrap) |
make analyse | PHPStan |
make cs / make cs-fix | php-cs-fixer |
make build-admin | Rebuild the Administration |
make validate-plugin | shopware-cli Store check |
make zip | Release ZIP into build/ (ships the committed admin bundles) |
tests/Unit mirrors src/ and runs without a kernel (that is what CI runs, with --exclude-group kernel). tests/Integration (#[Group('kernel')]) needs a booted Shopware with the plugin installed — tests/TestBootstrapper.php installs and activates it in the test database.