From fd1ae26cb0df7e60c8f54c225f7c399aed43a180 Mon Sep 17 00:00:00 2001 From: Marvin Besselsen Date: Wed, 14 Oct 2026 13:50:56 +0200 Subject: [PATCH 01/10] Add delivery_bill_id to shipment fulfillment payload --- E2E-TESTS.md | 173 ++++++----- Plugin/Shipment/DeliveryBillId.php | 127 ++++++++ Plugin/Shipment/DeliveryBillIdArgument.php | 63 ++++ Service/Order/Shipping/Fulfillment.php | 33 ++ Service/Webhook/OrderStatus.php | 21 +- Service/Webhook/Shipments.php | 1 + .../support/services/ChannableApi.ts | 108 +++++++ .../tests/order/delivery-bill-id.spec.ts | 281 ++++++++++++++++++ docs/SHIPMENTS.md | 125 ++++++++ docs/SHIPMENTS_NL.md | 125 ++++++++ etc/db_schema.xml | 5 + etc/db_schema_whitelist.json | 5 + etc/di.xml | 8 + etc/extension_attributes.xml | 6 + etc/module.xml | 25 +- 15 files changed, 1006 insertions(+), 100 deletions(-) create mode 100644 Plugin/Shipment/DeliveryBillId.php create mode 100644 Plugin/Shipment/DeliveryBillIdArgument.php create mode 100644 Test/End-2-end/tests/order/delivery-bill-id.spec.ts create mode 100644 docs/SHIPMENTS.md create mode 100644 docs/SHIPMENTS_NL.md diff --git a/E2E-TESTS.md b/E2E-TESTS.md index d03fb4f4..d7b8083f 100644 --- a/E2E-TESTS.md +++ b/E2E-TESTS.md @@ -1,120 +1,113 @@ -# E2E Tests +# E2E Test Cases — Channable Module -Browser-based end-to-end tests for the Channable module, powered by [Playwright](https://playwright.dev/). Tests run against a real Magento instance — orders are submitted via the Channable webhook endpoint and the resulting Magento state is verified through the admin panel and REST API. +Browser- and API-based end-to-end tests for the Channable module, powered by [Playwright](https://playwright.dev/). Tests run against a real Magento instance: orders are submitted through the Channable webhook endpoints and the resulting Magento state is verified through the admin panel, the REST API and the module's own webhooks. -Tests run automatically on release tags, pull requests, and when the `run_e2e_tests` label is added. They can also be triggered manually via GitHub Actions. The CI environment spins up a Dockerized Magento instance with sample data, configures the module, and runs the full suite. +Tests run automatically on release tags, pull requests and when the `run_e2e_tests` label is added, and can be triggered manually through GitHub Actions. The CI environment spins up a Dockerized Magento instance with sample data, configures the module and runs the full suite. -## Cross-Border Tax +## Cross-Border Tax (`tests/order/cross-border-tax.spec.ts`) -The most complex part of order import is tax calculation. Channable sends prices as shown on the marketplace (gross, including destination tax), but Magento may need to recalculate tax based on its own tax rules and origin country. +Channable sends prices as shown on the marketplace (gross, including destination tax), while Magento may recalculate tax based on its own rules and origin country. All scenarios use a product price of €12.10 and verify the grand total stays consistent. Tax rules are provisioned for NL (21%), DE (19%), AT (20%), BE (21%) and FR (20%); shipping origin is always NL. -These tests validate that the module correctly handles the interplay between Magento's tax configuration and cross-border scenarios. All scenarios use a fixed product price of €12.10 and verify the grand total stays consistent — regardless of whether prices include/exclude tax, whether cross-border trade (CBT) is enabled, or which destination country is involved. +1. Domestic NL→NL, price incl tax — €12.10 grand total, 21% tax applied +2. Cross-border NL→DE, price incl tax, CBT disabled — €12.10 grand total, 19% tax, module compensates for the rate difference +3. Cross-border NL→DE, price incl tax, CBT enabled — €12.10 grand total, 19% tax, Magento handles it natively +4. Cross-border NL→DE, price excl tax, CBT disabled — €12.10 grand total, 19% tax, uses Channable's `price_tax` for stripping +5. Domestic NL→NL, price excl tax — €12.10 grand total, 21% tax applied +6. Cross-border NL→AT, price incl tax, CBT disabled — €12.10 grand total, 20% tax, verifies a different destination rate -| Test | Expected | -|------|----------| -| Domestic NL→NL, price incl tax | €12.10 grand total, 21% tax applied | -| Cross-border NL→DE, price incl tax, CBT off | €12.10 grand total, 19% tax — module compensates for rate difference | -| Cross-border NL→DE, price incl tax, CBT on | €12.10 grand total, 19% tax — Magento handles natively | -| Cross-border NL→DE, price excl tax, CBT off | €12.10 grand total, 19% tax — uses Channable's price_tax for stripping | -| Domestic NL→NL, price excl tax | €12.10 grand total, 21% tax applied | -| Cross-border NL→AT, price incl tax, CBT off | €12.10 grand total, 20% tax — verifies with a different destination rate | +## Order Import (`tests/order/order-import.spec.ts`) -Tax rules are provisioned during CI setup with rates for NL (21%), DE (19%), AT (20%), BE (21%), and FR (20%). Shipping origin is always set to NL (Netherlands). +Each test submits an order through the Channable webhook with a specific module configuration enabled, then verifies in the Magento admin that the order was created with the correct properties. -## Order Import +7. Guest checkout (default) — order created, customer group is NOT LOGGED IN +8. Customer creation — customer account created and linked to the order +9. Business order (VAT exempt) — tax is €0.00, VAT ID stored on the order +10. LVB order (auto-shipped) — order auto-shipped, shipment record present +11. Auto-invoice — invoice created automatically after import +12. Custom increment ID with prefix — order increment ID starts with CHAN- +13. Custom increment ID (alphanumeric strip) — special characters removed from the channel order ID +14. Shipping cost in order — shipping amount is €5.00 on the order +15. Discount in order — discount of -€2.00 reflected in the order totals +16. Multiple quantities — grand total correctly multiplied for qty 3 +17. LV order with pycountry region code — region resolved from the Channable region code +18. PL order without region — order created without a region set +19. Item-level discount: original price vs discounted price — item price and discount stored separately +20. Item-level discount: multi-qty grand total — discount multiplied by quantity +21. Item-level discount: price excl tax config — discount applied on the excl tax price +22. Item-level discount: price incl tax config — discount applied on the incl tax price +23. Item-level discount: multi-product — discounts per item across multiple products +24. Item-level discount: no order-level discount applied — item discounts do not duplicate into the order discount +25. Multi-currency order (PLN) — order created in PLN with the correct currency conversion +26. Customer created in correct store view — customer assigned to the store view the order was placed in +27. Item updates regression — order import leaves the `channable_items` table consistent -Each test submits an order through the Channable webhook with a specific module configuration enabled, then navigates to the Magento admin to verify the order was created with the correct properties. This covers the full range of config-driven behavior: customer handling, invoicing, shipping, custom order IDs, discounts, and multi-currency support. +## Webhooks (`tests/order/webhooks.spec.ts`) -| Test | Expected | -|------|----------| -| Guest checkout (default) | Order created, customer group = NOT LOGGED IN | -| Customer creation enabled | Customer account created and linked to order | -| Business order (VAT exempt) | Tax = €0.00, VAT ID stored on order | -| LVB order (auto-shipped) | Order auto-shipped, shipment record present | -| Auto-invoice enabled | Invoice automatically created after import | -| Custom increment ID with prefix | Order increment ID starts with CHAN- | -| Custom increment ID (alphanumeric strip) | Special characters removed from channel order ID | -| Shipping cost | Shipping amount = €5.00 on order | -| Discount applied | Discount = -€2.00 reflected in order totals | -| Multiple quantities | Grand total correctly multiplied for qty 3 | -| Multi-currency (PLN) | Order created in PLN with correct currency conversion | +Channable periodically polls Magento for order status updates and shipment information. These tests verify the read-only GET endpoints return the correct structure and content. -## Webhooks +28. Order status — processing — returns the order id and a valid status string +29. Order status — invalid ID — returns `validated=false` with error details +30. Shipments — recent order appears — returns a shipment array for known orders +31. Shipments — LVB order has fulfillment — shipped order includes its tracking information -Channable periodically polls Magento for order status updates and shipment information. These tests verify the read-only GET endpoints return the correct data structure and content, ensuring the bi-directional sync between Magento and Channable works as expected. +## Delivery Bill ID (`tests/order/delivery-bill-id.spec.ts`) -| Test | Expected | -|------|----------| -| Order status — pending/processing | Returns order id and valid status string | -| Order status — invalid ID | Returns validated=false with error details | -| Shipments — recent order | Returns shipment array for known orders | -| Shipments — LVB with tracking | Shipped order includes tracking information | +Every shipment update carries a `delivery_bill_id`: the number of the delivery note physically included in the parcel, used by marketplaces such as Conrad to match their invoice to the delivery. It defaults to the Magento shipment increment ID, which is printed on Magento's standard packing slip. ERP systems that generate their own delivery note number can supply it through the `channable_delivery_bill_id` extension attribute, either when creating the shipment (`POST /V1/order/:id/ship`) or afterwards (`POST /V1/shipment`). -## Bundle Pricing +32. Shipments webhook — `delivery_bill_id` defaults to the shipment increment ID +33. Shipments webhook — entry exposes `shipment_id` next to the order `id` +34. Delivery bill ID does not replace the tracking data — `tracking_code` and `carrier_code` unchanged +35. Shipment without tracking still reports a delivery bill ID +36. ERP value supplied on shipment creation is used instead of the increment ID +37. ERP value is exposed as extension attribute on the shipment through the REST API +38. ERP value can be set on an existing shipment and is reflected in the webhook +39. Empty ERP value falls back to the shipment increment ID +40. Order status returns a delivery bill ID per shipment, plus a `fulfillments` array +41. Order without shipments has no `fulfillment` and no `fulfillments` key +42. Multiple shipments — each shipment keeps its own delivery bill ID and tracking data +43. Multiple shipments — `fulfillments` holds one entry per shipment, `fulfillment` the most recent -Dynamic bundle products derive their prices from their children rather than having a fixed price. This test suite validates that the feed correctly reports bundle prices across different tax configurations. The fix uses indexed prices (`$product->getData('min_price')`) instead of `getBaseAmount()`, matching how configurables are handled and ensuring correct behavior with Magento's `getTaxPrice()`. +## Bundle Pricing (`tests/feed/bundle-pricing.spec.ts`) -Test products are created via REST API: two simple children (€20 + €30, tax class 2), two bundles (one dynamic with two required options, one fixed at €100), and a single-option dynamic bundle for OOS testing. NL 21% tax rules are used. +Dynamic bundle products derive their prices from their children rather than having a fixed price. These tests validate that the feed reports bundle prices correctly across tax configurations, using indexed prices (`min_price`) instead of `getBaseAmount()`. Test products are created through the REST API: two simple children (€20 + €30), a dynamic bundle with two required options, a fixed bundle at €100, and a single-option dynamic bundle for the out-of-stock scenario. -**Group A: Catalog prices including tax** (stored €50 = incl tax) +**Group A — catalog prices including tax** (stored €50 = incl tax) -| Test | Channable Config | Assert | -|------|------------------|--------| -| Dynamic, add tax on | `tax=1` | price = 50.00, min_price = 50.00 | -| Dynamic, add tax off | `tax=0` | price = 50.00, min_price = 50.00 | -| Dynamic, include both | `tax=1, tax_include_both=1` | price_incl = 50.00, price_excl ≈ 41.32 | -| Fixed, add tax on | `tax=1` | price = 100.00 | +44. Dynamic bundle, add tax on — price = 50.00, min_price = 50.00 +45. Dynamic bundle, add tax off — price = 50.00, min_price = 50.00 +46. Dynamic bundle, include both — price_incl = 50.00, price_excl ≈ 41.32 +47. Fixed bundle, add tax on — price = 100.00 -**Group B: Catalog prices excluding tax** (stored €50 = excl tax, incl = €60.50) +**Group B — catalog prices excluding tax** (stored €50 = excl tax, incl = €60.50) -| Test | Channable Config | Assert | -|------|------------------|--------| -| Dynamic, add tax on | `tax=1` | price ≈ 60.50, min_price ≈ 60.50 | -| Dynamic, add tax off | `tax=0` | price = 50.00, min_price = 50.00 | -| Dynamic, include both | `tax=1, tax_include_both=1` | price_incl ≈ 60.50, price_excl = 50.00 | -| Fixed, add tax on | `tax=1` | price ≈ 121.00 | +48. Dynamic bundle, add tax on — price ≈ 60.50, min_price ≈ 60.50 +49. Dynamic bundle, add tax off — price = 50.00, min_price = 50.00 +50. Dynamic bundle, include both — price_incl ≈ 60.50, price_excl = 50.00 +51. Fixed bundle, add tax on — price ≈ 121.00 -**Group C: Stock scenarios** (prices incl tax) +**Group C — stock scenarios** (prices incl tax) -| Test | Setup | Assert | -|------|-------|--------| -| Dynamic (single option), OOS child | child-b set OOS + reindex | min_price = 20.00 (only in-stock child) | +52. Dynamic bundle with an out-of-stock child — min_price = 20.00, only in-stock children counted -## Credit Memo +## Credit Memo (`tests/order/credit-memo.spec.ts`) -Validates that orders placed through the Channable payment method can be refunded — both via the Magento admin UI and the REST API. +Validates that orders placed through the Channable payment method can be refunded. Both tests create an auto-invoiced order and then create a credit memo. -Both tests create an auto-invoiced order (`invoice_order: 1`), then attempt to create a credit memo. +53. Credit memo via admin UI — created through the invoice → Credit Memo → Refund Offline flow +54. Credit memo via REST API (`V1/invoice/:id/refund`) — returns 200 with a credit memo ID and the record exists -| Test | Expected | -|------|----------| -| Credit memo via admin UI | Credit memo created successfully through invoice → Credit Memo → Refund Offline flow | -| Credit memo via REST API (`V1/invoice/:id/refund`) | API returns 200 with credit memo ID, credit memo record exists in DB | +## Feed Generation (`tests/feed/feed-generation.spec.ts`) -## Upcoming: Feed Generation +Validates that the product feed endpoint returns valid data and handles configuration and token errors gracefully. -The next suite of E2E tests will cover feed generation — validating that product data is correctly exported to Channable based on attribute mapping, category filters, and feed configuration. This will include tests for price rendering, image URLs, stock status, and custom attribute handling. +55. Feed endpoint returns valid JSON without errors +56. Feed endpoint returns a products array +57. Feed products contain the required id and title fields +58. Feed does not crash with the visibility filter enabled +59. Feed returns empty for an invalid token +60. Feed returns empty when the module is disabled +61. Single product feed through the `pid` parameter -## Running Locally +## Item Updates (`tests/order/item-save.spec.ts`) -```bash -cd Test/End-2-end -npm install && npx playwright install chromium - -BASE_URL="https://magento-248.test/" \ -MAGENTO_CONTAINER="magento-248-phpfpm-1" \ -MAGENTO_ADMIN_USER="e2e-admin" \ -MAGENTO_ADMIN_PASS="E2eTest1234!" \ -CHANNABLE_TOKEN="" \ -PRODUCT_ID="3" \ -npx playwright test -``` - -Run a specific suite: -```bash -npx playwright test tests/order/cross-border-tax.spec.ts -npx playwright test tests/order/order-import.spec.ts -npx playwright test tests/order/webhooks.spec.ts -npx playwright test tests/feed/bundle-pricing.spec.ts -npx playwright test tests/order/credit-memo.spec.ts -``` +62. Saving a product in the admin does not cause a constraint violation on `channable_items` diff --git a/Plugin/Shipment/DeliveryBillId.php b/Plugin/Shipment/DeliveryBillId.php new file mode 100644 index 00000000..e986931d --- /dev/null +++ b/Plugin/Shipment/DeliveryBillId.php @@ -0,0 +1,127 @@ +extensionFactory = $extensionFactory; + } + + /** + * Copy the extension attribute to the entity data prior to saving. + * + * @param ShipmentRepositoryInterface $subject + * @param ShipmentInterface $shipment + * + * @return array + */ + public function beforeSave( + ShipmentRepositoryInterface $subject, + ShipmentInterface $shipment + ): array { + $extensionAttributes = $shipment->getExtensionAttributes(); + if ($extensionAttributes === null) { + return [$shipment]; + } + + $deliveryBillId = $extensionAttributes->getChannableDeliveryBillId(); + if ($deliveryBillId !== null) { + $shipment->setData(self::ATTRIBUTE_CODE, $deliveryBillId); + } + + return [$shipment]; + } + + /** + * @param ShipmentRepositoryInterface $subject + * @param ShipmentInterface $shipment + * + * @return ShipmentInterface + */ + public function afterGet( + ShipmentRepositoryInterface $subject, + ShipmentInterface $shipment + ): ShipmentInterface { + return $this->addExtensionAttribute($shipment); + } + + /** + * @param ShipmentRepositoryInterface $subject + * @param ShipmentInterface $shipment + * + * @return ShipmentInterface + */ + public function afterSave( + ShipmentRepositoryInterface $subject, + ShipmentInterface $shipment + ): ShipmentInterface { + return $this->addExtensionAttribute($shipment); + } + + /** + * @param ShipmentRepositoryInterface $subject + * @param ShipmentSearchResultInterface $searchResult + * + * @return ShipmentSearchResultInterface + */ + public function afterGetList( + ShipmentRepositoryInterface $subject, + ShipmentSearchResultInterface $searchResult + ): ShipmentSearchResultInterface { + foreach ($searchResult->getItems() as $shipment) { + $this->addExtensionAttribute($shipment); + } + + return $searchResult; + } + + /** + * Expose the stored delivery bill ID as extension attribute. + * + * @param ShipmentInterface $shipment + * + * @return ShipmentInterface + */ + private function addExtensionAttribute(ShipmentInterface $shipment): ShipmentInterface + { + $extensionAttributes = $shipment->getExtensionAttributes() ?: $this->extensionFactory->create(); + $extensionAttributes->setChannableDeliveryBillId( + $shipment->getData(self::ATTRIBUTE_CODE) !== null + ? (string)$shipment->getData(self::ATTRIBUTE_CODE) + : null + ); + $shipment->setExtensionAttributes($extensionAttributes); + + return $shipment; + } +} diff --git a/Plugin/Shipment/DeliveryBillIdArgument.php b/Plugin/Shipment/DeliveryBillIdArgument.php new file mode 100644 index 00000000..9f601cf3 --- /dev/null +++ b/Plugin/Shipment/DeliveryBillIdArgument.php @@ -0,0 +1,63 @@ +getExtensionAttributes() : null; + if ($extensionAttributes === null) { + return $result; + } + + $deliveryBillId = $extensionAttributes->getChannableDeliveryBillId(); + if ($deliveryBillId !== null) { + $result->setData(DeliveryBillId::ATTRIBUTE_CODE, $deliveryBillId); + } + + return $result; + } +} diff --git a/Service/Order/Shipping/Fulfillment.php b/Service/Order/Shipping/Fulfillment.php index e94cedaf..c58290e4 100644 --- a/Service/Order/Shipping/Fulfillment.php +++ b/Service/Order/Shipping/Fulfillment.php @@ -10,6 +10,7 @@ use Magento\Sales\Api\Data\ShipmentTrackInterface; use Magento\Sales\Model\Order\Shipment; use Magmodules\Channable\Api\Config\RepositoryInterface as ConfigProvider; +use Magmodules\Channable\Plugin\Shipment\DeliveryBillId as DeliveryBillIdPlugin; /** * Service class to return Fulfillment data @@ -35,6 +36,11 @@ public function __construct( * Returns fulfillment data for shipment. * See; https://docs.channable.com/api/v1/#shipment-update * + * The delivery_bill_id defaults to the Magento shipment increment ID. This ID is printed on + * the default Magento packing slip and is therefore available on the physical delivery note. + * ERP systems can supply their own delivery note number through the channable_delivery_bill_id + * extension attribute on the shipment. + * * @param Shipment $shipment * * @return array @@ -55,9 +61,36 @@ public function execute(Shipment $shipment): array } } + $fulfillment['delivery_bill_id'] = $this->getDeliveryBillId($shipment); + return $fulfillment; } + /** + * Returns the delivery bill ID for the shipment. + * + * Uses the value supplied by an ERP through the channable_delivery_bill_id extension + * attribute, and falls back to the Magento shipment increment ID when not set. + * + * @param Shipment $shipment + * + * @return string + */ + private function getDeliveryBillId(Shipment $shipment): string + { + // Shipments loaded through a collection carry the value as data, not as extension attribute + $deliveryBillId = $shipment->getData(DeliveryBillIdPlugin::ATTRIBUTE_CODE); + + if (!$deliveryBillId) { + $extensionAttributes = $shipment->getExtensionAttributes(); + $deliveryBillId = $extensionAttributes !== null + ? $extensionAttributes->getChannableDeliveryBillId() + : null; + } + + return $deliveryBillId ? (string)$deliveryBillId : (string)$shipment->getIncrementId(); + } + /** * Check if tracking entry should be used as a return tracking label * diff --git a/Service/Webhook/OrderStatus.php b/Service/Webhook/OrderStatus.php index fb55a80e..5b1424ec 100644 --- a/Service/Webhook/OrderStatus.php +++ b/Service/Webhook/OrderStatus.php @@ -80,28 +80,31 @@ public function execute(string $incrementId): array 'status' => $order->getStatus() ]; - if ($fulfillment = $this->getFulfillment($order)) { - $response['fulfillment'] = $fulfillment; + if ($fulfillments = $this->getFulfillments($order)) { + $response['fulfillment'] = end($fulfillments); + $response['fulfillments'] = $fulfillments; } return $response; } /** + * Returns fulfillment data, one entry per shipment. + * * @param OrderInterface $order * - * @return array|bool + * @return array */ - private function getFulfillment(OrderInterface $order) + private function getFulfillments(OrderInterface $order): array { - $fulfillment = []; + $fulfillments = []; $shipmentCollection = $order->getShipmentsCollection(); foreach ($shipmentCollection as $shipment) { - $fulfillment += $this->fulfillment->execute($shipment); + $fulfillment = $this->fulfillment->execute($shipment); + $fulfillment['shipment_id'] = $shipment->getIncrementId(); + $fulfillments[] = $fulfillment; } - return (!empty($fulfillment)) - ? ($fulfillment) - : (false); + return $fulfillments; } } diff --git a/Service/Webhook/Shipments.php b/Service/Webhook/Shipments.php index a1b70bb2..5e949a14 100644 --- a/Service/Webhook/Shipments.php +++ b/Service/Webhook/Shipments.php @@ -95,6 +95,7 @@ public function execute(int $timespan): array foreach ($collection as $shipment) { $response[] = [ 'id' => $shipment->getOrderIncrementId(), + 'shipment_id' => $shipment->getIncrementId(), 'type'=> 'shipment', 'status' => $shipment->getStatus(), 'date' => $this->localeDate->date($shipment->getCreatedAt())->format('Y-m-d H:i:s'), diff --git a/Test/End-2-end/support/services/ChannableApi.ts b/Test/End-2-end/support/services/ChannableApi.ts index a56309bb..17127f81 100644 --- a/Test/End-2-end/support/services/ChannableApi.ts +++ b/Test/End-2-end/support/services/ChannableApi.ts @@ -562,4 +562,112 @@ export default class ChannableApi extends BaseApi { const data = await response.json() as any; return data.id; } + + /** + * Get the Magento order entity ID by increment ID (via REST API). + */ + async getOrderEntityId(baseURL: string, orderIncrementId: string): Promise { + const token = process.env.admin_token; + const url = `${baseURL}rest/all/V1/orders?searchCriteria[filterGroups][0][filters][0][field]=increment_id` + + `&searchCriteria[filterGroups][0][filters][0][value]=${orderIncrementId}&searchCriteria[pageSize]=1`; + + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }, + }); + + const data = await response.json() as any; + const orderId = data.items?.[0]?.entity_id; + if (!orderId) throw new Error(`Order not found: ${orderIncrementId}`); + + return String(orderId); + } + + /** + * Ship an order through the Magento REST API (POST /V1/order/:id/ship). + * + * An ERP supplies its own delivery note number through the channable_delivery_bill_id + * extension attribute on the shipment creation arguments. + */ + async shipOrder(baseURL: string, orderIncrementId: string, options: { + deliveryBillId?: string; + trackNumber?: string; + carrierCode?: string; + title?: string; + items?: Array<{ orderItemId: string | number; qty: number }>; + } = {}): Promise<{ status: number; shipmentId: string; body: any }> { + const token = process.env.admin_token; + const orderId = await this.getOrderEntityId(baseURL, orderIncrementId); + + const payload: any = { notify: false }; + + if (options.items) { + payload.items = options.items.map((item) => ({ + order_item_id: parseInt(String(item.orderItemId), 10), + qty: item.qty, + })); + } + + if (options.trackNumber) { + payload.tracks = [{ + track_number: options.trackNumber, + carrier_code: options.carrierCode ?? 'custom', + title: options.title ?? 'E2E Carrier', + }]; + } + + if (options.deliveryBillId !== undefined) { + payload.arguments = { + extension_attributes: { channable_delivery_bill_id: options.deliveryBillId }, + }; + } + + const response = await fetch(`${baseURL}rest/all/V1/order/${orderId}/ship`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + const body = await response.json(); + + return { status: response.status, shipmentId: String(body), body }; + } + + /** + * Get a shipment by entity ID (via REST API). + */ + async getShipment(baseURL: string, shipmentId: string): Promise { + const token = process.env.admin_token; + + const response = await fetch(`${baseURL}rest/all/V1/shipment/${shipmentId}`, { + headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Shipment ${shipmentId} not found: ${response.status}`); + } + + return response.json(); + } + + /** + * Update an existing shipment through the Magento REST API (POST /V1/shipment). + * Used to verify ERP systems can set the delivery bill ID after shipment creation. + */ + async saveShipment(baseURL: string, shipment: any): Promise<{ status: number; body: any }> { + const token = process.env.admin_token; + + const response = await fetch(`${baseURL}rest/all/V1/shipment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ entity: shipment }), + }); + + return { status: response.status, body: await response.json() }; + } } diff --git a/Test/End-2-end/tests/order/delivery-bill-id.spec.ts b/Test/End-2-end/tests/order/delivery-bill-id.spec.ts new file mode 100644 index 00000000..d52d4c33 --- /dev/null +++ b/Test/End-2-end/tests/order/delivery-bill-id.spec.ts @@ -0,0 +1,281 @@ +/* + * Copyright Magmodules.eu. All rights reserved. + * See COPYING.txt for license details. + */ + +import {expect, test} from '@playwright/test'; +import ChannableApi from 'Services/ChannableApi'; + +const channableApi = new ChannableApi(); + +const PRODUCT_ID = parseInt(process.env.PRODUCT_ID || '1', 10); + +/** + * Helper: extract order increment ID from the Channable webhook response. + */ +function getOrderIncrementId(response: any): string { + if (response.order_id) { + return String(response.order_id); + } + throw new Error(`Order creation failed: ${JSON.stringify(response)}`); +} + +/** + * Helper: create a Channable order and return its increment ID. + */ +async function createOrder(baseURL: string, quantity: number = 1): Promise { + const orderData = channableApi.buildOrderData({ productId: PRODUCT_ID, quantity }); + const response = await channableApi.postOrder(baseURL, orderData); + + return getOrderIncrementId(response); +} + +/** + * Helper: return all shipment entries for an order from the shipments webhook. + */ +async function getShipmentEntries(baseURL: string, orderIncrementId: string): Promise { + const shipments = await channableApi.getShipments(baseURL, 1); + expect(Array.isArray(shipments)).toBe(true); + + return shipments.filter( + (entry: any) => entry.type === 'shipment' && String(entry.id) === orderIncrementId + ); +} + +/** + * Helper: unique delivery bill ID in the format an ERP would generate. + */ +function erpDeliveryBillId(): string { + return `DSN-${Math.floor(Math.random() * 900000) + 100000}`; +} + +test.describe('Delivery Bill ID: default (Magento shipment increment ID)', () => { + test('Shipments webhook — delivery_bill_id defaults to the shipment increment ID', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + const { status, shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { + trackNumber: '3SDEFAULT001', + carrierCode: 'custom', + title: 'DHL', + }); + expect(status).toBe(200); + + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + const entries = await getShipmentEntries(baseURL!, incrementId); + + expect(entries).toHaveLength(1); + expect(entries[0].fulfillment.delivery_bill_id).toBe(shipment.increment_id); + }); + + test('Shipments webhook — entry exposes shipment_id next to the order id', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { + trackNumber: '3SDEFAULT002', + }); + + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + const entries = await getShipmentEntries(baseURL!, incrementId); + + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe(incrementId); + expect(entries[0].shipment_id).toBe(shipment.increment_id); + // The delivery bill ID and the shipment increment ID are the same value by default + expect(entries[0].fulfillment.delivery_bill_id).toBe(entries[0].shipment_id); + }); + + test('Delivery bill ID does not replace the tracking data', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + await channableApi.shipOrder(baseURL!, incrementId, { + trackNumber: '3SDEFAULT003', + carrierCode: 'custom', + title: 'DHL', + }); + + const entries = await getShipmentEntries(baseURL!, incrementId); + const fulfillment = entries[0].fulfillment; + + expect(fulfillment.tracking_code).toContain('3SDEFAULT003'); + expect(Array.isArray(fulfillment.carrier_code)).toBe(true); + expect(typeof fulfillment.delivery_bill_id).toBe('string'); + }); + + test('Shipment without tracking still reports a delivery bill ID', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId); + + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + const entries = await getShipmentEntries(baseURL!, incrementId); + + expect(entries).toHaveLength(1); + expect(entries[0].fulfillment.delivery_bill_id).toBe(shipment.increment_id); + }); +}); + +test.describe('Delivery Bill ID: ERP supplied value', () => { + test('ERP value supplied on shipment creation is used', async ({ baseURL }) => { + const deliveryBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!); + + const { status } = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId, + trackNumber: '3SERP001', + }); + expect(status).toBe(200); + + const entries = await getShipmentEntries(baseURL!, incrementId); + + expect(entries).toHaveLength(1); + expect(entries[0].fulfillment.delivery_bill_id).toBe(deliveryBillId); + // The ERP value overrides the increment ID, the shipment_id is unaffected + expect(entries[0].shipment_id).not.toBe(deliveryBillId); + }); + + test('ERP value is exposed as extension attribute on the shipment', async ({ baseURL }) => { + const deliveryBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!); + + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { deliveryBillId }); + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + + expect(shipment.extension_attributes?.channable_delivery_bill_id).toBe(deliveryBillId); + }); + + test('ERP value can be set on an existing shipment', async ({ baseURL }) => { + const deliveryBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!); + + // Ship without a delivery bill ID first, as most shipping extensions do + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { + trackNumber: '3SERP002', + }); + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + expect(shipment.extension_attributes?.channable_delivery_bill_id).toBeFalsy(); + + // The ERP pushes its delivery note number afterwards + shipment.extension_attributes = { + ...(shipment.extension_attributes || {}), + channable_delivery_bill_id: deliveryBillId, + }; + const { status } = await channableApi.saveShipment(baseURL!, shipment); + expect(status).toBe(200); + + const updated = await channableApi.getShipment(baseURL!, shipmentId); + expect(updated.extension_attributes?.channable_delivery_bill_id).toBe(deliveryBillId); + + const entries = await getShipmentEntries(baseURL!, incrementId); + expect(entries[0].fulfillment.delivery_bill_id).toBe(deliveryBillId); + }); + + test('Empty ERP value falls back to the shipment increment ID', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId: '', + trackNumber: '3SERP003', + }); + + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + const entries = await getShipmentEntries(baseURL!, incrementId); + + expect(entries[0].fulfillment.delivery_bill_id).toBe(shipment.increment_id); + }); +}); + +test.describe('Delivery Bill ID: order status webhook', () => { + test('Order status returns a delivery bill ID per shipment', async ({ baseURL }) => { + const deliveryBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!); + + const { shipmentId } = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId, + trackNumber: '3SSTATUS001', + }); + const shipment = await channableApi.getShipment(baseURL!, shipmentId); + + const statusResponse = await channableApi.getOrderStatus(baseURL!, incrementId); + + expect(statusResponse.id).toBe(incrementId); + expect(statusResponse.fulfillment.delivery_bill_id).toBe(deliveryBillId); + expect(statusResponse.fulfillments).toHaveLength(1); + expect(statusResponse.fulfillments[0].shipment_id).toBe(shipment.increment_id); + expect(statusResponse.fulfillments[0].delivery_bill_id).toBe(deliveryBillId); + }); + + test('Order without shipments has no fulfillment data', async ({ baseURL }) => { + const incrementId = await createOrder(baseURL!); + + const statusResponse = await channableApi.getOrderStatus(baseURL!, incrementId); + + expect(statusResponse).not.toHaveProperty('fulfillment'); + expect(statusResponse).not.toHaveProperty('fulfillments'); + }); +}); + +test.describe('Delivery Bill ID: multiple shipments', () => { + test('Each shipment keeps its own delivery bill ID and tracking data', async ({ baseURL }) => { + const firstBillId = erpDeliveryBillId(); + const secondBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!, 2); + + const { orderItemId } = await channableApi.getOrderItemInfo(baseURL!, incrementId); + + // Ship the order in two parts, each with its own delivery note + const first = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId: firstBillId, + trackNumber: '3SMULTI001', + items: [{ orderItemId, qty: 1 }], + }); + expect(first.status).toBe(200); + + const second = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId: secondBillId, + trackNumber: '3SMULTI002', + items: [{ orderItemId, qty: 1 }], + }); + expect(second.status).toBe(200); + + const entries = await getShipmentEntries(baseURL!, incrementId); + expect(entries).toHaveLength(2); + + const billIds = entries.map((entry: any) => entry.fulfillment.delivery_bill_id); + expect(billIds).toContain(firstBillId); + expect(billIds).toContain(secondBillId); + + // Tracking data is not merged across shipments + const firstEntry = entries.find((entry: any) => entry.fulfillment.delivery_bill_id === firstBillId); + const secondEntry = entries.find((entry: any) => entry.fulfillment.delivery_bill_id === secondBillId); + expect(firstEntry.fulfillment.tracking_code).toEqual(['3SMULTI001']); + expect(secondEntry.fulfillment.tracking_code).toEqual(['3SMULTI002']); + expect(firstEntry.shipment_id).not.toBe(secondEntry.shipment_id); + }); + + test('Order status returns one fulfillment entry per shipment', async ({ baseURL }) => { + const firstBillId = erpDeliveryBillId(); + const secondBillId = erpDeliveryBillId(); + const incrementId = await createOrder(baseURL!, 2); + + const { orderItemId } = await channableApi.getOrderItemInfo(baseURL!, incrementId); + + await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId: firstBillId, + trackNumber: '3SMULTI003', + items: [{ orderItemId, qty: 1 }], + }); + const second = await channableApi.shipOrder(baseURL!, incrementId, { + deliveryBillId: secondBillId, + trackNumber: '3SMULTI004', + items: [{ orderItemId, qty: 1 }], + }); + + const secondShipment = await channableApi.getShipment(baseURL!, second.shipmentId); + const statusResponse = await channableApi.getOrderStatus(baseURL!, incrementId); + + expect(statusResponse.fulfillments).toHaveLength(2); + expect(statusResponse.fulfillments.map((f: any) => f.delivery_bill_id)) + .toEqual([firstBillId, secondBillId]); + + // The legacy fulfillment key holds the most recent shipment + expect(statusResponse.fulfillment.delivery_bill_id).toBe(secondBillId); + expect(statusResponse.fulfillment.shipment_id).toBe(secondShipment.increment_id); + expect(statusResponse.fulfillment.tracking_code).toEqual(['3SMULTI004']); + }); +}); diff --git a/docs/SHIPMENTS.md b/docs/SHIPMENTS.md new file mode 100644 index 00000000..80180b4d --- /dev/null +++ b/docs/SHIPMENTS.md @@ -0,0 +1,125 @@ +# Shipments + +This guide explains how shipment updates work in the [Channable module](https://www.magmodules.eu/magento2-channable.html). It covers what Magento sends back to Channable when you ship an order, how tracking and return labels are recognised, and how the delivery bill ID works — including how to supply your own number from an ERP system. + +## How Shipment Updates Work + +Channable periodically asks your Magento store which orders have been shipped. The module answers with a fulfillment payload per shipment, containing the tracking information and the delivery bill ID. Channable passes this on to the marketplace, which notifies the customer. + +Everything is based on standard Magento shipments. As soon as a shipment exists — created manually in the admin, by a shipping extension such as SendCloud, MyParcel or Paazl, or through the REST API — it is picked up automatically. No extra configuration is required. + +A shipment update contains: + +| Field | Source | +|---|---| +| `tracking_code` | The tracking numbers of the shipment | +| `carrier_code` | The carrier of each tracking entry | +| `title` | The title of each tracking entry | +| `return_tracking_code` | Tracking numbers recognised as a return label | +| `return_transporter` | The carrier of those return labels | +| `delivery_bill_id` | The number of the delivery note in the parcel | +| `shipment_id` | The Magento shipment increment ID | + +Orders with multiple shipments produce one update per shipment. Each shipment keeps its own tracking data and its own delivery bill ID. + +## Delivery Bill ID + +Some marketplaces — Conrad among them — invoice the customer themselves while you send the parcel. To let the customer link that invoice to the parcel they received, the number of the delivery note inside the parcel is sent along with the shipment. That number is the delivery bill ID. + +### Default: the Magento shipment increment ID + +By default the module sends the Magento shipment increment ID (for example `300000004`). This works without any configuration and requires no manual input: + +- It is shown in the admin as soon as a shipment is created +- It is included in the shipment confirmation email +- It is printed on Magento's standard packing slip, so the number on the paper in the parcel matches the number the marketplace receives + +For merchants who ship from Magento, this is all you need. + +### Using your own number from an ERP system + +If you print your delivery notes from an ERP or fulfillment system, that system generates its own delivery note number (for example `DSN-123`). In that case the marketplace must receive your number, not Magento's, otherwise the number on the invoice will not match the paper in the box. + +Your ERP can supply the number through the `channable_delivery_bill_id` extension attribute on the shipment. There are two ways to do this. + +**When creating the shipment** — add the attribute to the shipment arguments: + +``` +POST /rest/V1/order/{orderId}/ship + +{ + "tracks": [ + { + "carrier_code": "dhl", + "title": "DHL", + "track_number": "3S123456789" + } + ], + "arguments": { + "extension_attributes": { + "channable_delivery_bill_id": "DSN-123" + } + } +} +``` + +**On an existing shipment** — useful when a shipping extension already created the shipment: + +``` +POST /rest/V1/shipment + +{ + "entity": { + "entity_id": 12, + "order_id": 34, + "extension_attributes": { + "channable_delivery_bill_id": "DSN-123" + } + } +} +``` + +The value is stored on the shipment and returned again by `GET /rest/V1/shipment/{id}`, so you can always verify what was sent. + +When the field is empty, the module falls back to the Magento shipment increment ID. Shipments created by shipping extensions that know nothing about this field therefore keep working exactly as before. + +**Important:** the number you supply must be the same number printed on the delivery note in the parcel. That is the whole purpose of the field. + +## Return Labels + +Shipping extensions often add a second tracking entry for the return label. The module can recognise those entries and report them separately as `return_tracking_code` instead of as a regular tracking code. + +**Location:** Stores → Configuration → Channable → Marketplace → Orders + +### Return Label Recognition + +Choose how return labels are identified. When set to pattern matching, you define per carrier which tracking titles indicate a return label. + +### Return Label Patterns + +A table with two columns: + +- **Carrier** — the carrier the rule applies to, or "all" for every carrier +- **Title pattern** — the text that must appear in the tracking title, for example `Return` or `Retour` + +A tracking entry matching one of these rules is sent as a return label. All other entries are sent as regular tracking codes. + +## Multiple Shipments Per Order + +When an order is shipped in parts, each shipment is reported separately, with its own tracking data, its own `shipment_id` and its own delivery bill ID. This lets the marketplace tell the customer exactly which items were in which parcel. + +If you use your own delivery note numbers, make sure each partial shipment gets its own number — the same number on two parcels makes the invoice impossible to match. + +## Checking What Is Sent + +The shipment data Channable receives is available through the module's webhook URLs, which you can find under Stores → Configuration → Channable → Marketplace. Opening the shipments URL in your browser shows exactly what Channable sees, including the delivery bill ID per shipment. This is the quickest way to verify your ERP integration is filling the field correctly. + +--- + +## Need More Help? + +**Documentation:** +- [All Help Articles](https://www.magmodules.eu/help/channable/) - Complete documentation overview + +**Support:** +- [Contact Support](https://www.magmodules.eu/support/) - Get help from our team diff --git a/docs/SHIPMENTS_NL.md b/docs/SHIPMENTS_NL.md new file mode 100644 index 00000000..f728e165 --- /dev/null +++ b/docs/SHIPMENTS_NL.md @@ -0,0 +1,125 @@ +# Verzendingen + +Deze gids legt uit hoe verzendupdates werken in de [Channable module](https://www.magmodules.eu/nl/magento2-channable.html). Het behandelt wat Magento terugstuurt naar Channable wanneer u een bestelling verzendt, hoe track & trace en retourlabels worden herkend, en hoe het pakbonnummer werkt — inclusief het meegeven van uw eigen nummer vanuit een ERP-systeem. + +## Hoe Verzendupdates Werken + +Channable vraagt periodiek aan uw Magento-webshop welke bestellingen verzonden zijn. De module antwoordt met een fulfillment-payload per verzending, met daarin de track & trace-gegevens en het pakbonnummer. Channable geeft dit door aan de marktplaats, die de klant informeert. + +Alles is gebaseerd op standaard Magento-verzendingen. Zodra er een verzending bestaat — handmatig aangemaakt in de admin, door een verzendextensie zoals SendCloud, MyParcel of Paazl, of via de REST API — wordt deze automatisch opgepikt. Extra configuratie is niet nodig. + +Een verzendupdate bevat: + +| Veld | Bron | +|---|---| +| `tracking_code` | De trackingnummers van de verzending | +| `carrier_code` | De vervoerder van elke trackingregel | +| `title` | De titel van elke trackingregel | +| `return_tracking_code` | Trackingnummers die herkend zijn als retourlabel | +| `return_transporter` | De vervoerder van die retourlabels | +| `delivery_bill_id` | Het nummer van de pakbon in het pakket | +| `shipment_id` | Het Magento verzendingsnummer | + +Bestellingen met meerdere verzendingen leveren één update per verzending op. Elke verzending behoudt zijn eigen trackinggegevens en zijn eigen pakbonnummer. + +## Pakbonnummer (Delivery Bill ID) + +Sommige marktplaatsen — waaronder Conrad — factureren zelf aan de klant terwijl u het pakket verstuurt. Om de klant die factuur te laten koppelen aan het ontvangen pakket, wordt het nummer van de pakbon in het pakket meegestuurd met de verzending. Dat nummer is het pakbonnummer. + +### Standaard: het Magento verzendingsnummer + +Standaard stuurt de module het Magento verzendingsnummer mee (bijvoorbeeld `300000004`). Dit werkt zonder configuratie en vraagt geen handmatige invoer: + +- Het is direct zichtbaar in de admin zodra een verzending is aangemaakt +- Het staat in de verzendbevestigingsmail +- Het wordt afgedrukt op de standaard pakbon van Magento, waardoor het nummer op het papier in het pakket overeenkomt met het nummer dat de marktplaats ontvangt + +Voor webshops die vanuit Magento verzenden is dit voldoende. + +### Uw eigen nummer uit een ERP-systeem gebruiken + +Drukt u uw pakbonnen af vanuit een ERP- of fulfillmentsysteem, dan genereert dat systeem een eigen pakbonnummer (bijvoorbeeld `DSN-123`). In dat geval moet de marktplaats uw nummer ontvangen en niet dat van Magento, anders komt het nummer op de factuur niet overeen met het papier in de doos. + +Uw ERP kan het nummer meegeven via de extension attribute `channable_delivery_bill_id` op de verzending. Dat kan op twee manieren. + +**Bij het aanmaken van de verzending** — voeg het attribuut toe aan de shipment arguments: + +``` +POST /rest/V1/order/{orderId}/ship + +{ + "tracks": [ + { + "carrier_code": "dhl", + "title": "DHL", + "track_number": "3S123456789" + } + ], + "arguments": { + "extension_attributes": { + "channable_delivery_bill_id": "DSN-123" + } + } +} +``` + +**Op een bestaande verzending** — handig wanneer een verzendextensie de verzending al heeft aangemaakt: + +``` +POST /rest/V1/shipment + +{ + "entity": { + "entity_id": 12, + "order_id": 34, + "extension_attributes": { + "channable_delivery_bill_id": "DSN-123" + } + } +} +``` + +De waarde wordt opgeslagen op de verzending en weer teruggegeven door `GET /rest/V1/shipment/{id}`, zodat u altijd kunt controleren wat er is meegestuurd. + +Is het veld leeg, dan valt de module terug op het Magento verzendingsnummer. Verzendingen die zijn aangemaakt door verzendextensies die dit veld niet kennen, blijven dus gewoon werken zoals voorheen. + +**Belangrijk:** het nummer dat u meegeeft moet hetzelfde nummer zijn als dat op de pakbon in het pakket. Daar is het veld voor bedoeld. + +## Retourlabels + +Verzendextensies voegen vaak een tweede trackingregel toe voor het retourlabel. De module kan die regels herkennen en apart rapporteren als `return_tracking_code` in plaats van als gewone trackingcode. + +**Locatie:** Winkels → Configuratie → Channable → Marketplace → Bestellingen + +### Herkenning van retourlabels + +Kies hoe retourlabels worden herkend. Bij patroonherkenning bepaalt u per vervoerder welke trackingtitels op een retourlabel wijzen. + +### Retourlabel-patronen + +Een tabel met twee kolommen: + +- **Vervoerder** — de vervoerder waarvoor de regel geldt, of "all" voor elke vervoerder +- **Titelpatroon** — de tekst die in de trackingtitel moet voorkomen, bijvoorbeeld `Return` of `Retour` + +Een trackingregel die aan een van deze regels voldoet, wordt als retourlabel verstuurd. Alle andere regels worden als gewone trackingcode verstuurd. + +## Meerdere Verzendingen Per Bestelling + +Wordt een bestelling in delen verzonden, dan wordt elke verzending apart gerapporteerd, met eigen trackinggegevens, een eigen `shipment_id` en een eigen pakbonnummer. Zo kan de marktplaats de klant precies vertellen welke artikelen in welk pakket zaten. + +Gebruikt u eigen pakbonnummers, zorg er dan voor dat elke deelzending een eigen nummer krijgt — hetzelfde nummer op twee pakketten maakt de factuur onmogelijk te koppelen. + +## Controleren Wat Er Wordt Verstuurd + +De verzendgegevens die Channable ontvangt zijn op te vragen via de webhook-URL's van de module, te vinden onder Winkels → Configuratie → Channable → Marketplace. Door de shipments-URL in uw browser te openen ziet u precies wat Channable ziet, inclusief het pakbonnummer per verzending. Dit is de snelste manier om te controleren of uw ERP-koppeling het veld correct vult. + +--- + +## Meer Hulp Nodig? + +**Documentatie:** +- [Alle Help Artikelen](https://www.magmodules.eu/nl/help/channable/) - Compleet documentatie overzicht + +**Support:** +- [Contact Opnemen](https://www.magmodules.eu/nl/support/) - Hulp van ons team diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 6b7b93ce..997c1b17 100755 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -140,6 +140,11 @@ + + + +
+ diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json index f3091c33..cbe2ea1b 100644 --- a/etc/db_schema_whitelist.json +++ b/etc/db_schema_whitelist.json @@ -124,5 +124,10 @@ "column": { "transaction_fee": true } + }, + "sales_shipment": { + "column": { + "channable_delivery_bill_id": true + } } } diff --git a/etc/di.xml b/etc/di.xml index 34500bd8..3e49667e 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -89,6 +89,14 @@ + + + + + + + + diff --git a/etc/extension_attributes.xml b/etc/extension_attributes.xml index eaa5f3a3..56de44f8 100644 --- a/etc/extension_attributes.xml +++ b/etc/extension_attributes.xml @@ -9,4 +9,10 @@ + + + + + + diff --git a/etc/module.xml b/etc/module.xml index 9e4ae856..95526397 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -7,5 +7,28 @@ --> - + + + + + + + + + + + + + + + + + + + + + + + + From 9334f8d1f1484669d516d740a456cc6d8a47d484 Mon Sep 17 00:00:00 2001 From: Marvin Besselsen Date: Wed, 14 Oct 2026 13:50:56 +0200 Subject: [PATCH 02/10] Keep / # ( ) : in imported street addresses --- .gitattributes | 1 + .github/workflows/phpunit.yml | 55 + .gitignore | 5 +- Service/Order/Quote/AddressHandler.php | 2 +- Test/.gitignore | 1 + .../support/pages/backend/OrderViewPage.ts | 14 + .../support/services/ChannableApi.ts | 18 + .../tests/order/order-import.spec.ts | 22 + .../Order/Quote/AddressHandlerTest.php | 174 ++ Test/bootstrap.php | 98 + Test/composer.json | 9 + Test/composer.lock | 1783 +++++++++++++++++ phpunit.xml.dist | 23 + 13 files changed, 2203 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/phpunit.yml create mode 100644 Test/.gitignore create mode 100644 Test/Unit/Service/Order/Quote/AddressHandlerTest.php create mode 100644 Test/bootstrap.php create mode 100644 Test/composer.json create mode 100644 Test/composer.lock create mode 100644 phpunit.xml.dist diff --git a/.gitattributes b/.gitattributes index 4986ca83..f387928f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ /docs export-ignore /Test export-ignore phpstan.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000..25535068 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,55 @@ +name: Unit Tests + +on: + pull_request: + paths: + - '**/*.php' + - 'phpunit.xml.dist' + - 'Test/**' + - 'composer.json' + workflow_dispatch: + +jobs: + phpunit: + strategy: + fail-fast: false + matrix: + include: + - PHP_VERSION: php83-fpm + MAGENTO_VERSION: 2.4.7 + - PHP_VERSION: php84-fpm + MAGENTO_VERSION: 2.4.8-p4 + - PHP_VERSION: php85-fpm + MAGENTO_VERSION: 2.4.9 + + name: PHPUnit ${{ matrix.PHP_VERSION }} Magento ${{ matrix.MAGENTO_VERSION }} + + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Start Docker + run: docker run --detach --name magento-project-community-edition michielgerritsen/magento-project-community-edition:${{ matrix.PHP_VERSION }}-magento${{ matrix.MAGENTO_VERSION }} + + - name: Remove version from composer.json + run: sed -i '/version/d' ./composer.json + + - name: Upload our code into the docker container + run: docker cp $(pwd) magento-project-community-edition:/data/extensions/ + + - name: Install the extension in Magento + run: docker exec magento-project-community-edition ./install-composer-package magmodules/magento2-channable:@dev + + # PHPUnit lives in Test/vendor rather than the module's own composer.json: Magento's root + # autoloader excludes **/Test/** from its class map, which breaks the PHPUnit copy that ships + # with Magento itself. + # docker cp copies the checkout directory, which is named after the repository - and this + # module lives in two of them (magento2-channable and magento2-channable-dev), so the path + # cannot be hardcoded. + - name: Install the test dependencies + run: docker exec magento-project-community-edition /bin/bash -c "cd /data/extensions/${{ github.event.repository.name }}/Test && composer install --no-interaction --prefer-dist --no-progress" + + - name: Run PHPUnit + run: docker exec magento-project-community-edition /bin/bash -c "cd /data/extensions/${{ github.event.repository.name }} && Test/vendor/bin/phpunit -c phpunit.xml.dist" diff --git a/.gitignore b/.gitignore index 003c6347..e115fd65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea *.zip -.DS_Store \ No newline at end of file +.DS_Store +/vendor/ +/composer.lock +/.phpunit.cache/ diff --git a/Service/Order/Quote/AddressHandler.php b/Service/Order/Quote/AddressHandler.php index 32bcc193..1083a23e 100644 --- a/Service/Order/Quote/AddressHandler.php +++ b/Service/Order/Quote/AddressHandler.php @@ -22,7 +22,7 @@ class AddressHandler // Strip any characters not explicitly allowed for each field private const PATTERN_NAME = '/[^\p{L}\p{M},\-_\.\'’`&\s\d]/u'; private const PATTERN_CITY = '/[^\p{L}\p{M}\d\s\-_\'’\.,&\(\)]/u'; - private const PATTERN_STREET = '/[^\p{L}\p{M}"\[\],\-\.\'’`&\s\d]/u'; + private const PATTERN_STREET = '/[^\p{L}\p{M}"\[\]\(\),:#\-\.\/\'’`&\s\d]/u'; private const PATTERN_TELEPHONE = '/[^0-9\+\-\(\)\s]/u'; /** diff --git a/Test/.gitignore b/Test/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/Test/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/Test/End-2-end/support/pages/backend/OrderViewPage.ts b/Test/End-2-end/support/pages/backend/OrderViewPage.ts index 8ba92714..06cc4037 100644 --- a/Test/End-2-end/support/pages/backend/OrderViewPage.ts +++ b/Test/End-2-end/support/pages/backend/OrderViewPage.ts @@ -153,6 +153,20 @@ export default class OrderViewPage { return groupText === 'NOT LOGGED IN'; } + /** + * Read the shipping address block as a single string with the line breaks kept, so a street + * that was split or mangled during import is visible in the assertion diff. + */ + async getShippingAddress(page: Page): Promise { + const block = page.locator('.order-shipping-address address').first(); + return (await block.innerText()).trim(); + } + + async getBillingAddress(page: Page): Promise { + const block = page.locator('.order-billing-address address').first(); + return (await block.innerText()).trim(); + } + async getRowTotal(page: Page): Promise { return this.getItemColumnValue(page, 'Row Total'); } diff --git a/Test/End-2-end/support/services/ChannableApi.ts b/Test/End-2-end/support/services/ChannableApi.ts index 17127f81..d3c72689 100644 --- a/Test/End-2-end/support/services/ChannableApi.ts +++ b/Test/End-2-end/support/services/ChannableApi.ts @@ -113,6 +113,9 @@ export default class ChannableApi extends BaseApi { shipmentMethod?: string; email?: string; extraProducts?: Array<{ id: number; price: number; quantity?: number; discount?: number }>; + street?: string; + houseNumber?: string; + houseNumberExt?: string; } = {}): any { const channableId = overrides.channableId || String(Math.floor(Math.random() * 900000) + 100000); const country = overrides.country || 'NL'; @@ -187,6 +190,21 @@ export default class ChannableApi extends BaseApi { data.shipping.email = overrides.email; } + if (overrides.street !== undefined) { + data.billing.street = overrides.street; + data.shipping.street = overrides.street; + } + + if (overrides.houseNumber !== undefined) { + data.billing.house_number = overrides.houseNumber; + data.shipping.house_number = overrides.houseNumber; + } + + if (overrides.houseNumberExt !== undefined) { + data.billing.house_number_ext = overrides.houseNumberExt; + data.shipping.house_number_ext = overrides.houseNumberExt; + } + if (overrides.channelName) { data.channel_name = overrides.channelName; } diff --git a/Test/End-2-end/tests/order/order-import.spec.ts b/Test/End-2-end/tests/order/order-import.spec.ts index 30935059..731b94e9 100644 --- a/Test/End-2-end/tests/order/order-import.spec.ts +++ b/Test/End-2-end/tests/order/order-import.spec.ts @@ -305,6 +305,28 @@ const testCases = [ expect(baseCurrencyTotal).toBeGreaterThan(0); }, }, + { + // The unit suite pins the sanitizing itself; this case proves the other half of the claim - + // that addressRepository->save() accepts these characters, so the order really is created and + // the house number survives all the way into the stored address. + title: 'House number with a separator survives the import', + config: {}, + orderOverrides: { street: 'Examplestreet', houseNumber: '55/3' }, + assert: async (page, incrementId) => { + const shipping = await orderViewPage.getShippingAddress(page); + const billing = await orderViewPage.getBillingAddress(page); + + // customer/address/street_lines decides whether the house number lands on its own line, so + // compare on collapsed whitespace - what matters here is the separator, not the line break. + const collapse = (address: string) => address.replace(/\s+/g, ' '); + + expect(collapse(shipping)).toContain('Examplestreet 55/3'); + expect(collapse(billing)).toContain('Examplestreet 55/3'); + // "553" is the failure this guards against: a different, undeliverable house number. + expect(shipping).not.toContain('553'); + expect(billing).not.toContain('553'); + }, + }, { title: 'Customer created in correct store view', config: { diff --git a/Test/Unit/Service/Order/Quote/AddressHandlerTest.php b/Test/Unit/Service/Order/Quote/AddressHandlerTest.php new file mode 100644 index 00000000..f248cce4 --- /dev/null +++ b/Test/Unit/Service/Order/Quote/AddressHandlerTest.php @@ -0,0 +1,174 @@ +createHandler(separateHousenumber: true, streetLines: 1); + + $street = $handler->getStreet( + $this->payload(street: 'Examplestreet', houseNumber: $houseNumber), + self::STORE_ID + ); + + self::assertSame($expectedStreet, $street); + } + + public static function preservedPunctuationProvider(): array + { + return [ + 'slash separator (AT, PL, CZ, IT)' => ['55/3', 'Examplestreet 55/3'], + 'hash unit number' => ['#12', 'Examplestreet #12'], + 'parenthetical addition' => ['12 (achter)', 'Examplestreet 12 (achter)'], + 'colon box number' => ['12 bus: 4', 'Examplestreet 12 bus: 4'], + 'plain number stays plain' => ['31', 'Examplestreet 31'], + ]; + } + + /** + * Widening the allowlist must not turn it into an open door: everything outside it is still + * stripped, which is what keeps the stored address free of markup and control punctuation. + */ + #[DataProvider('strippedCharactersProvider')] + public function testStillStripsCharactersOutsideTheAllowlist(string $street, string $expected): void + { + $handler = $this->createHandler(separateHousenumber: true, streetLines: 1); + + $result = $handler->getStreet($this->payload(street: $street, houseNumber: '31'), self::STORE_ID); + + self::assertSame($expected, $result); + } + + public static function strippedCharactersProvider(): array + { + return [ + 'angle brackets' => ['Example