Przeglądaj źródła

feat(core): Implement a state machine for Fulfillments

* feat(core): Implement a state machine in Fulfillment

* refactor: Update fulfillment state

* refactor(core): Add hisory entry type from order fulfillment transition

* refactor(core): Order fulfillment card using fulfillment state label

* chore: Add order fulfillment proccess in vendure config

* chore: Add HistoryEntryType ORDER_FULFILLMENT_TRANSITION

* refactor: Remove order fulfillment angular components

* chore: Add fulfillment state transition event

* chore: Add fulfillment state transition event

* chore: Add functions related to fulfillment state in order service

* chore: Add fulfillment state resolvers

* refactor: Move fulfillment options to shipping options

* refactor: Extract all fulfillment fn in Order services to a new class

* refactor: Make find order by fulfillment check by channel

* chore: Change OrderState from PartiallyFulfilled to PartiallyDelivered

* chore: Change OrderState from Fulfilled to Delivered

* feat: Add Shipped as a new state in OrderState

* chore: Remove verification to transit state when create a fulfillment

* chore: Make order service injectable

* refactor: Fulfillment service method findOrderByFulfillment

* refactor: Change order state automatically in FulfillmentStateMachine

* chore: Update test state transitions

* refactor: Remove FulfillmentService from OrderService

* chore: Update generated types and schema

* refactor: Change transittion fulfillment to a new Fulfillment resolver

* chore: Update generated types

* refactor: Change create fulfillment from Order to Fulfillment service

* test: Update Order failed tests

* refactor: Move fulfillment transition to order service

* feat: Add PartiallyShipped as a new state in OrderState

* chore: Update generated schema

* refactor: Add logic to verify transit  PartiallyShipped order state

* chore: Add i18n message for new transition check

* chore: Change old state-fulfilled to state-delivered

* chore: Change old state-partially-fulfilled to state-partially-delivered

* fix: Misspelled fullfillment to fulfillment

* chore: Update generated types

* refactor: Remove refunded from fulfillment state

* refactor: Remove old imports from fulfillment state machine

* refactor: Make fulfillment services receive a list of order

* test: Update Order E2E tests

* chore: Add new fulfillment helpers services in export

* refactor: Add admin fulfillment entity in providers list

* chore: Update generared types

* test: Update order and fulfillment e2e tests

* chore: Lint fulfimment service

* chore: Move e2e fulfillment gql to shared definitions
Jonathan Célio 5 lat temu
rodzic
commit
70a7665510
100 zmienionych plików z 1413 dodań i 593 usunięć
  1. 1 1
      docs/content/docs/plugins/plugin-examples.md
  2. 11 11
      docs/diagrams/order-state-diagram.puml
  3. 6 6
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts
  4. 26 26
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  5. 15 15
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  6. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts
  7. 11 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  8. 2 2
      packages/admin-ui/src/lib/core/src/data/data.module.ts
  9. 1 1
      packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts
  10. 1 1
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  11. 2 2
      packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts
  12. 3 3
      packages/admin-ui/src/lib/core/src/data/server-config.ts
  13. 4 4
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  14. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.ts
  15. 5 5
      packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.component.ts
  16. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html
  17. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts
  18. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/product-selector/product-selector.component.ts
  19. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component.ts
  20. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  21. 2 2
      packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts
  22. 18 18
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts
  23. 1 1
      packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.html
  24. 7 11
      packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts
  25. 2 5
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  26. 30 30
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  27. 5 5
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html
  28. 9 9
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.ts
  29. 3 3
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  30. 1 1
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts
  31. 3 3
      packages/admin-ui/src/lib/settings/src/components/test-order-builder/test-order-builder.component.ts
  32. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  33. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  34. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  35. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  36. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  37. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  38. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  39. 10 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  40. 3 1
      packages/common/src/generated-shop-types.ts
  41. 11 1
      packages/common/src/generated-types.ts
  42. 31 31
      packages/core/e2e/asset.e2e-spec.ts
  43. 1 1
      packages/core/e2e/customer-group.e2e-spec.ts
  44. 242 0
      packages/core/e2e/fulfillment-process.e2e-spec.ts
  45. 60 10
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  46. 3 1
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  47. 36 0
      packages/core/e2e/graphql/shared-definitions.ts
  48. 6 6
      packages/core/e2e/order-process.e2e-spec.ts
  49. 8 8
      packages/core/e2e/order-promotion.e2e-spec.ts
  50. 152 111
      packages/core/e2e/order.e2e-spec.ts
  51. 24 23
      packages/core/e2e/product.e2e-spec.ts
  52. 9 9
      packages/core/e2e/shop-order.e2e-spec.ts
  53. 5 1
      packages/core/src/api/api-internal-modules.ts
  54. 4 4
      packages/core/src/api/common/graphql-value-transformer.ts
  55. 7 7
      packages/core/src/api/config/graphql-custom-fields.ts
  56. 1 1
      packages/core/src/api/middleware/asset-interceptor-plugin.ts
  57. 5 5
      packages/core/src/api/resolvers/admin/global-settings.resolver.ts
  58. 11 0
      packages/core/src/api/resolvers/admin/order.resolver.ts
  59. 12 3
      packages/core/src/api/resolvers/entity/fulfillment-entity.resolver.ts
  60. 2 2
      packages/core/src/api/resolvers/shop/shop-customer.resolver.ts
  61. 1 1
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  62. 3 0
      packages/core/src/api/schema/admin-api/fulfillment.api.graphql
  63. 1 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  64. 2 1
      packages/core/src/api/schema/type/history-entry.type.graphql
  65. 1 0
      packages/core/src/api/schema/type/order.type.graphql
  66. 8 8
      packages/core/src/bootstrap.ts
  67. 3 3
      packages/core/src/common/configurable-operation.ts
  68. 10 4
      packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts
  69. 1 3
      packages/core/src/config/default-config.ts
  70. 27 0
      packages/core/src/config/fulfillment/custom-fulfillment-process.ts
  71. 1 0
      packages/core/src/config/index.ts
  72. 1 1
      packages/core/src/config/promotion/actions/product-discount-action.ts
  73. 1 1
      packages/core/src/config/promotion/conditions/contains-products-condition.ts
  74. 2 2
      packages/core/src/config/promotion/conditions/customer-group-condition.ts
  75. 8 0
      packages/core/src/config/vendure-config.ts
  76. 10 10
      packages/core/src/data-import/providers/importer/importer.ts
  77. 5 5
      packages/core/src/data-import/providers/populator/populator.ts
  78. 4 1
      packages/core/src/entity/fulfillment/fulfillment.entity.ts
  79. 13 16
      packages/core/src/entity/order/order.entity.ts
  80. 1 1
      packages/core/src/entity/promotion/promotion.entity.ts
  81. 22 0
      packages/core/src/event-bus/events/fulfillment-state-transition-event.ts
  82. 5 2
      packages/core/src/i18n/messages/en.json
  83. 5 3
      packages/core/src/i18n/messages/pt_BR.json
  84. 3 3
      packages/core/src/job-queue/job-queue.service.ts
  85. 12 12
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  86. 5 5
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  87. 4 4
      packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts
  88. 138 0
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts
  89. 39 0
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts
  90. 30 14
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  91. 14 6
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  92. 35 12
      packages/core/src/service/helpers/utils/order-utils.ts
  93. 2 0
      packages/core/src/service/index.ts
  94. 4 0
      packages/core/src/service/service.module.ts
  95. 6 6
      packages/core/src/service/services/asset.service.ts
  96. 6 6
      packages/core/src/service/services/customer.service.ts
  97. 72 0
      packages/core/src/service/services/fulfillment.service.ts
  98. 7 1
      packages/core/src/service/services/history.service.ts
  99. 1 1
      packages/core/src/service/services/order-testing.service.ts
  100. 56 37
      packages/core/src/service/services/order.service.ts

+ 1 - 1
docs/content/docs/plugins/plugin-examples.md

@@ -234,7 +234,7 @@ export class OrderAnalyticsPlugin implements OnVendureBootstrap {
    */
   onVendureBootstrap() {
     this.eventBus.ofType(OrderStateTransitionEvent).subscribe(event => {
-      if (event.toState === 'Fulfilled') {
+      if (event.toState === 'Delivered') {
         this.workerService.send(new ProcessOrderMessage({ orderId: event.order.id })).subscribe();
       }
     });

+ 11 - 11
docs/diagrams/order-state-diagram.puml

@@ -27,12 +27,12 @@ state AdminAPI {
         PaymentSettled: provider, i.e. the transaction is complete.
     }
 
-    state PartiallyFulfilled {
-        PartiallyFulfilled: One or more OrderItems have been dispatched to the Customer
+    state PartiallyDelivered {
+        PartiallyDelivered: One or more OrderItems have been dispatched to the Customer
     }
 
-    state Fulfilled #9d9 {
-        Fulfilled: All OrderItems have been dispatched to the Customer
+    state Delivered #9d9 {
+        Delivered: All OrderItems have been dispatched to the Customer
     }
 
 
@@ -41,7 +41,7 @@ state AdminAPI {
     }
     Cancelled --> [*]
 
-    Fulfilled --> [*]
+    Delivered --> [*]
 }
 
 
@@ -50,15 +50,15 @@ ArrangingPayment --> AddingItems: transitionOrderToState
 ArrangingPayment --> PaymentAuthorized: addPaymentToOrder
 ArrangingPayment -----> PaymentSettled: addPaymentToOrder
 PaymentAuthorized --> PaymentSettled: settlePayment
-PaymentSettled --> Fulfilled: fulfillOrder
-PaymentSettled --> PartiallyFulfilled: fulfillOrder
-PartiallyFulfilled --> PartiallyFulfilled: fulfillOrder
-PartiallyFulfilled --> Fulfilled: fulfillOrder
+PaymentSettled --> Delivered: fulfillOrder
+PaymentSettled --> PartiallyDelivered: fulfillOrder
+PartiallyDelivered --> PartiallyDelivered: fulfillOrder
+PartiallyDelivered --> Delivered: fulfillOrder
 
 PaymentAuthorized --> Cancelled: cancelOrder
 PaymentSettled --> Cancelled: cancelOrder
-PartiallyFulfilled --> Cancelled: cancelOrder
-Fulfilled --> Cancelled: cancelOrder
+PartiallyDelivered --> Cancelled: cancelOrder
+Delivered --> Cancelled: cancelOrder
 
 
 

+ 6 - 6
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts

@@ -27,7 +27,7 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
         super(router, route);
         super.setQueryFn(
             (...args: any[]) => this.dataService.facet.getFacets(...args).refetchOnChannelChange(),
-            data => data.facets,
+            (data) => data.facets,
         );
     }
 
@@ -42,12 +42,12 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
     deleteFacet(facetValueId: string) {
         this.showModalAndDelete(facetValueId)
             .pipe(
-                switchMap(response => {
+                switchMap((response) => {
                     if (response.result === DeletionResult.DELETED) {
                         return [true];
                     } else {
                         return this.showModalAndDelete(facetValueId, response.message || '').pipe(
-                            map(r => r.result === DeletionResult.DELETED),
+                            map((r) => r.result === DeletionResult.DELETED),
                         );
                     }
                 }),
@@ -61,7 +61,7 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
                     });
                     this.refresh();
                 },
-                err => {
+                (err) => {
                     this.notificationService.error(_('common.notify-delete-error'), {
                         entity: 'FacetValue',
                     });
@@ -80,8 +80,8 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
                 ],
             })
             .pipe(
-                switchMap(res => (res ? this.dataService.facet.deleteFacet(facetId, !!message) : EMPTY)),
-                map(res => res.deleteFacet),
+                switchMap((res) => (res ? this.dataService.facet.deleteFacet(facetId, !!message) : EMPTY)),
+                map((res) => res.deleteFacet),
             );
     }
 }

+ 26 - 26
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -131,7 +131,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     ngOnInit() {
         this.init();
         this.product$ = this.entity$;
-        const variants$ = this.product$.pipe(map(product => product.variants));
+        const variants$ = this.product$.pipe(map((product) => product.variants));
         const filterTerm$ = this.filterInput.valueChanges.pipe(
             startWith(''),
             debounceTime(50),
@@ -140,7 +140,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         this.variants$ = combineLatest(variants$, filterTerm$).pipe(
             map(([variants, term]) => {
                 return term
-                    ? variants.filter(v => {
+                    ? variants.filter((v) => {
                           const lcTerm = term.toLocaleLowerCase();
                           return (
                               v.name.toLocaleLowerCase().includes(term) ||
@@ -151,19 +151,19 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             }),
         );
         this.taxCategories$ = this.productDetailService.getTaxCategories().pipe(takeUntil(this.destroy$));
-        this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
+        this.activeTab$ = this.route.paramMap.pipe(map((qpm) => qpm.get('tab') as any));
 
         // FacetValues are provided initially by the nested array of the
         // Product entity, but once a fetch to get all Facets is made (as when
         // opening the FacetValue selector modal), then these additional values
         // are concatenated onto the initial array.
         this.facets$ = this.productDetailService.getFacets();
-        const productFacetValues$ = this.product$.pipe(map(product => product.facetValues));
+        const productFacetValues$ = this.product$.pipe(map((product) => product.facetValues));
         const allFacetValues$ = this.facets$.pipe(map(flattenFacetValues));
         const productGroup = this.getProductFormGroup();
 
         const formFacetValueIdChanges$ = productGroup.valueChanges.pipe(
-            map(val => val.facetValueIds as string[]),
+            map((val) => val.facetValueIds as string[]),
             distinctUntilChanged(),
         );
         const formChangeFacetValues$ = combineLatest(
@@ -173,12 +173,12 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         ).pipe(
             map(([ids, productFacetValues, allFacetValues]) => {
                 const combined = [...productFacetValues, ...allFacetValues];
-                return ids.map(id => combined.find(fv => fv.id === id)).filter(notNullOrUndefined);
+                return ids.map((id) => combined.find((fv) => fv.id === id)).filter(notNullOrUndefined);
             }),
         );
 
         this.facetValues$ = merge(productFacetValues$, formChangeFacetValues$);
-        this.productChannels$ = this.product$.pipe(map(p => p.channels));
+        this.productChannels$ = this.product$.pipe(map((p) => p.channels));
     }
 
     ngOnDestroy() {
@@ -220,7 +220,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 ],
             })
             .pipe(
-                switchMap(response =>
+                switchMap((response) =>
                     response
                         ? this.dataService.product.removeProductsFromChannel({
                               channelId,
@@ -233,7 +233,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 () => {
                     this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
                 },
-                err => {
+                (err) => {
                     this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
                 },
             );
@@ -259,7 +259,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
      * If creating a new product, automatically generate the slug based on the product name.
      */
     updateSlug(nameValue: string) {
-        this.isNew$.pipe(take(1)).subscribe(isNew => {
+        this.isNew$.pipe(take(1)).subscribe((isNew) => {
             if (isNew) {
                 const slugControl = this.detailForm.get(['product', 'slug']);
                 if (slugControl && slugControl.pristine) {
@@ -270,7 +270,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     }
 
     selectProductFacetValue() {
-        this.displayFacetValueModal().subscribe(facetValueIds => {
+        this.displayFacetValueModal().subscribe((facetValueIds) => {
             if (facetValueIds) {
                 const productGroup = this.getProductFormGroup();
                 const currentFacetValueIds = productGroup.value.facetValueIds;
@@ -289,7 +289,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     entity: 'ProductOption',
                 });
             },
-            err => {
+            (err) => {
                 this.notificationService.error(_('common.notify-update-error'), {
                     entity: 'ProductOption',
                 });
@@ -301,7 +301,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         const productGroup = this.getProductFormGroup();
         const currentFacetValueIds = productGroup.value.facetValueIds;
         productGroup.patchValue({
-            facetValueIds: currentFacetValueIds.filter(id => id !== facetValueId),
+            facetValueIds: currentFacetValueIds.filter((id) => id !== facetValueId),
         });
         productGroup.markAsDirty();
     }
@@ -315,9 +315,9 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             .subscribe(([facetValueIds, variants]) => {
                 if (facetValueIds) {
                     for (const variantId of selectedVariantIds) {
-                        const index = variants.findIndex(v => v.id === variantId);
+                        const index = variants.findIndex((v) => v.id === variantId);
                         const variant = variants[index];
-                        const existingFacetValueIds = variant ? variant.facetValues.map(fv => fv.id) : [];
+                        const existingFacetValueIds = variant ? variant.facetValues.map((fv) => fv.id) : [];
                         const variantFormGroup = this.detailForm.get(['variants', index]);
                         if (variantFormGroup) {
                             variantFormGroup.patchValue({
@@ -334,7 +334,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     variantsToCreateAreValid(): boolean {
         return (
             0 < this.createVariantsConfig.variants.length &&
-            this.createVariantsConfig.variants.every(v => {
+            this.createVariantsConfig.variants.every((v) => {
                 return v.sku !== '';
             })
         );
@@ -342,14 +342,14 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
 
     private displayFacetValueModal(): Observable<string[] | undefined> {
         return this.productDetailService.getFacets().pipe(
-            mergeMap(facets =>
+            mergeMap((facets) =>
                 this.modalService.fromComponent(ApplyFacetDialogComponent, {
                     size: 'md',
                     closable: true,
                     locals: { facets },
                 }),
             ),
-            map(facetValues => facetValues && facetValues.map(v => v.id)),
+            map((facetValues) => facetValues && facetValues.map((v) => v.id)),
         );
     }
 
@@ -384,7 +384,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     this.detailForm.markAsPristine();
                     this.router.navigate(['../', productId], { relativeTo: this.route });
                 },
-                err => {
+                (err) => {
                     // tslint:disable-next-line:no-console
                     console.error(err);
                     this.notificationService.error(_('common.notify-create-error'), {
@@ -423,7 +423,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                 }),
             )
             .subscribe(
-                result => {
+                (result) => {
                     this.updateSlugAfterSave(result);
                     this.detailForm.markAsPristine();
                     this.assetChanges = {};
@@ -433,7 +433,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     });
                     this.changeDetector.markForCheck();
                 },
-                err => {
+                (err) => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Product',
                     });
@@ -449,14 +449,14 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
      * Sets the values of the form on changes to the product or current language.
      */
     protected setFormValues(product: ProductWithVariants.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
+        const currentTranslation = product.translations.find((t) => t.languageCode === languageCode);
         this.detailForm.patchValue({
             product: {
                 enabled: product.enabled,
                 name: currentTranslation ? currentTranslation.name : '',
                 slug: currentTranslation ? currentTranslation.slug : '',
                 description: currentTranslation ? currentTranslation.description : '',
-                facetValueIds: product.facetValues.map(fv => fv.id),
+                facetValueIds: product.facetValues.map((fv) => fv.id),
             },
         });
 
@@ -478,8 +478,8 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
 
         const variantsFormArray = this.detailForm.get('variants') as FormArray;
         product.variants.forEach((variant, i) => {
-            const variantTranslation = variant.translations.find(t => t.languageCode === languageCode);
-            const facetValueIds = variant.facetValues.map(fv => fv.id);
+            const variantTranslation = variant.translations.find((t) => t.languageCode === languageCode);
+            const facetValueIds = variant.facetValues.map((fv) => fv.id);
             const group: VariantFormValue = {
                 id: variant.id,
                 enabled: variant.enabled,
@@ -570,7 +570,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
             const formRow = variantsFormArray.get(i.toString());
             return formRow && formRow.dirty;
         });
-        const dirtyVariantValues = variantsFormArray.controls.filter(c => c.dirty).map(c => c.value);
+        const dirtyVariantValues = variantsFormArray.controls.filter((c) => c.dirty).map((c) => c.value);
 
         if (dirtyVariants.length !== dirtyVariantValues.length) {
             throw new Error(_(`error.product-variant-form-values-do-not-match`));

+ 15 - 15
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts

@@ -73,7 +73,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         this.subscription.add(
             this.formArray.valueChanges
                 .pipe(
-                    map(value => value.length),
+                    map((value) => value.length),
                     debounceTime(1),
                     distinctUntilChanged(),
                 )
@@ -109,7 +109,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     getTaxCategoryName(group: FormGroup): string {
         const control = group.get(['taxCategoryId']);
         if (control && this.taxCategories) {
-            const match = this.taxCategories.find(t => t.id === control.value);
+            const match = this.taxCategories.find((t) => t.id === control.value);
             return match ? match.name : '';
         }
         return '';
@@ -124,7 +124,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             variantId,
             ...event,
         });
-        const index = this.variants.findIndex(v => v.id === variantId);
+        const index = this.variants.findIndex((v) => v.id === variantId);
         this.formArray.at(index).markAsDirty();
     }
 
@@ -132,7 +132,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         if (this.areAllSelected()) {
             this.selectedVariantIds = [];
         } else {
-            this.selectedVariantIds = this.variants.map(v => v.id);
+            this.selectedVariantIds = this.variants.map((v) => v.id);
         }
         this.selectionChange.emit(this.selectedVariantIds);
     }
@@ -148,10 +148,10 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     }
 
     optionGroupName(optionGroupId: string): string | undefined {
-        const group = this.optionGroups.find(g => g.id === optionGroupId);
+        const group = this.optionGroups.find((g) => g.id === optionGroupId);
         if (group) {
             const translation =
-                group?.translations.find(t => t.languageCode === this.activeLanguage) ??
+                group?.translations.find((t) => t.languageCode === this.activeLanguage) ??
                 group.translations[0];
             return translation.name;
         }
@@ -159,17 +159,17 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
 
     optionName(option: ProductOptionFragment) {
         const translation =
-            option.translations.find(t => t.languageCode === this.activeLanguage) ?? option.translations[0];
+            option.translations.find((t) => t.languageCode === this.activeLanguage) ?? option.translations[0];
         return translation.name;
     }
 
     pendingFacetValues(variant: ProductWithVariants.Variants) {
         if (this.facets) {
             const formFacetValueIds = this.getFacetValueIds(variant.id);
-            const variantFacetValueIds = variant.facetValues.map(fv => fv.id);
+            const variantFacetValueIds = variant.facetValues.map((fv) => fv.id);
             return formFacetValueIds
-                .filter(x => !variantFacetValueIds.includes(x))
-                .map(id => this.facetValues.find(fv => fv.id === id))
+                .filter((x) => !variantFacetValueIds.includes(x))
+                .map((id) => this.facetValues.find((fv) => fv.id === id))
                 .filter(notNullOrUndefined);
         } else {
             return [];
@@ -178,11 +178,11 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
 
     existingFacetValues(variant: ProductWithVariants.Variants) {
         const formFacetValueIds = this.getFacetValueIds(variant.id);
-        const intersection = [...formFacetValueIds].filter(x =>
-            variant.facetValues.map(fv => fv.id).includes(x),
+        const intersection = [...formFacetValueIds].filter((x) =>
+            variant.facetValues.map((fv) => fv.id).includes(x),
         );
         return intersection
-            .map(id => variant.facetValues.find(fv => fv.id === id))
+            .map((id) => variant.facetValues.find((fv) => fv.id === id))
             .filter(notNullOrUndefined);
     }
 
@@ -190,7 +190,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         const formGroup = this.formGroupMap.get(variant.id);
         if (formGroup) {
             const newValue = (formGroup.value as VariantFormValue).facetValueIds.filter(
-                id => id !== facetValueId,
+                (id) => id !== facetValueId,
             );
             formGroup.patchValue({
                 facetValueIds: newValue,
@@ -213,7 +213,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
                     customFields: this.customOptionFields,
                 },
             })
-            .subscribe(result => {
+            .subscribe((result) => {
                 if (result) {
                     this.updateProductOption.emit(result);
                 }

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts

@@ -29,7 +29,7 @@ export class ProductVariantsTableComponent implements OnInit, OnDestroy {
     ngOnInit() {
         this.subscription = this.formArray.valueChanges
             .pipe(
-                map(value => value.length),
+                map((value) => value.length),
                 debounceTime(1),
                 distinctUntilChanged(),
             )
@@ -47,7 +47,7 @@ export class ProductVariantsTableComponent implements OnInit, OnDestroy {
     }
 
     optionGroupName(optionGroupId: string): string | undefined {
-        const group = this.optionGroups.find(g => g.id === optionGroupId);
+        const group = this.optionGroups.find((g) => g.id === optionGroupId);
         return group && group.name;
     }
 

+ 11 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1240,10 +1240,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
    __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   orderItems: Array<OrderItem>;
+  state: Scalars['String'];
   method: Scalars['String'];
   trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1319,9 +1321,10 @@ export enum HistoryEntryType {
   CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-  ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+  ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
   ORDER_CANCELLATION = 'ORDER_CANCELLATION',
   ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+  ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
   ORDER_NOTE = 'ORDER_NOTE',
   ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
   ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED'
@@ -1906,6 +1909,7 @@ export type Mutation = {
   setUiLanguage?: Maybe<LanguageCode>;
   settlePayment: Payment;
   settleRefund: Refund;
+  transitionFulfillmentToState: Fulfillment;
   transitionOrderToState?: Maybe<Order>;
   /** Update an existing Administrator */
   updateAdministrator: Administrator;
@@ -2302,6 +2306,12 @@ export type MutationSettleRefundArgs = {
 };
 
 
+export type MutationTransitionFulfillmentToStateArgs = {
+  id: Scalars['ID'];
+  state: Scalars['String'];
+};
+
+
 export type MutationTransitionOrderToStateArgs = {
   id: Scalars['ID'];
   state: Scalars['String'];

+ 2 - 2
packages/admin-ui/src/lib/core/src/data/data.module.ts

@@ -1,6 +1,6 @@
-import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
+import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
 import { APP_INITIALIZER, Injector, NgModule } from '@angular/core';
-import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular';
+import { APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
 import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloLink } from 'apollo-link';

+ 1 - 1
packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts

@@ -71,7 +71,7 @@ export class OrderDataService {
         });
     }
 
-    createFullfillment(input: FulfillOrderInput) {
+    createFulfillment(input: FulfillOrderInput) {
         return this.baseDataService.mutate<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
             CREATE_FULFILLMENT,
             {

+ 1 - 1
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -293,7 +293,7 @@ export class ProductDataService {
 
     createAssets(files: File[]) {
         return this.baseDataService.mutate<CreateAssets.Mutation, CreateAssets.Variables>(CREATE_ASSETS, {
-            input: files.map(file => ({ file })),
+            input: files.map((file) => ({ file })),
         });
     }
 

+ 2 - 2
packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts

@@ -74,10 +74,10 @@ import {
     GET_COUNTRY,
     GET_COUNTRY_LIST,
     GET_GLOBAL_SETTINGS,
-    GET_JOBS_BY_ID,
-    GET_JOBS_LIST,
     GET_JOB_INFO,
     GET_JOB_QUEUE_LIST,
+    GET_JOBS_BY_ID,
+    GET_JOBS_LIST,
     GET_PAYMENT_METHOD,
     GET_PAYMENT_METHOD_LIST,
     GET_TAX_CATEGORIES,

+ 3 - 3
packages/admin-ui/src/lib/core/src/data/server-config.ts

@@ -47,10 +47,10 @@ export class ServerConfigService {
             .query<GetServerConfig.Query>(GET_SERVER_CONFIG)
             .single$.toPromise()
             .then(
-                result => {
+                (result) => {
                     this._serverConfig = result.globalSettings.serverConfig;
                 },
-                err => {
+                (err) => {
                     // Let the error fall through to be caught by the http interceptor.
                 },
             );
@@ -59,7 +59,7 @@ export class ServerConfigService {
     getAvailableLanguages() {
         return this.baseDataService
             .query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS, {}, 'cache-first')
-            .mapSingle(res => res.globalSettings.availableLanguages);
+            .mapSingle((res) => res.globalSettings.availableLanguages);
     }
 
     /**

+ 4 - 4
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -38,7 +38,7 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
                 kind: Kind.FIELD,
                 selectionSet: {
                     kind: Kind.SELECTION_SET,
-                    selections: customFieldsForType.map(customField => {
+                    selections: customFieldsForType.map((customField) => {
                         return {
                             kind: Kind.FIELD,
                             name: {
@@ -50,11 +50,11 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
                 },
             });
 
-            const localeStrings = customFieldsForType.filter(field => field.type === 'localeString');
+            const localeStrings = customFieldsForType.filter((field) => field.type === 'localeString');
 
             const translationsField = fragmentDef.selectionSet.selections
                 .filter(isFieldNode)
-                .find(field => field.name.value === 'translations');
+                .find((field) => field.name.value === 'translations');
 
             if (localeStrings.length && translationsField && translationsField.selectionSet) {
                 (translationsField.selectionSet.selections as SelectionNode[]).push({
@@ -65,7 +65,7 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
                     kind: Kind.FIELD,
                     selectionSet: {
                         kind: Kind.SELECTION_SET,
-                        selections: localeStrings.map(customField => {
+                        selections: localeStrings.map((customField) => {
                             return {
                                 kind: Kind.FIELD,
                                 name: {

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.ts

@@ -71,7 +71,7 @@ export class AssetFileInputComponent implements OnInit {
         this.dragging = false;
         this.overDropZone = false;
         const files = Array.from(event.dataTransfer ? event.dataTransfer.items : [])
-            .map(i => i.getAsFile())
+            .map((i) => i.getAsFile())
             .filter(notNullOrUndefined);
         this.selectFiles.emit(files);
     }

+ 5 - 5
packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.component.ts

@@ -100,16 +100,16 @@ export class DatetimePickerComponent implements ControlValueAccessor, AfterViewI
         this.populateMinutes();
         this.calendarView$ = this.datetimePickerService.calendarView$;
         this.current$ = this.datetimePickerService.viewing$.pipe(
-            map(date => ({
+            map((date) => ({
                 date,
                 month: date.getMonth() + 1,
                 year: date.getFullYear(),
             })),
         );
         this.selected$ = this.datetimePickerService.selected$;
-        this.selectedHours$ = this.selected$.pipe(map(date => date && date.getHours()));
-        this.selectedMinutes$ = this.selected$.pipe(map(date => date && date.getMinutes()));
-        this.subscription = this.datetimePickerService.selected$.subscribe(val => {
+        this.selectedHours$ = this.selected$.pipe(map((date) => date && date.getHours()));
+        this.selectedMinutes$ = this.selected$.pipe(map((date) => date && date.getMinutes()));
+        this.subscription = this.datetimePickerService.selected$.subscribe((val) => {
             if (this.onChange) {
                 this.onChange(val == null ? val : val.toISOString());
             }
@@ -117,7 +117,7 @@ export class DatetimePickerComponent implements ControlValueAccessor, AfterViewI
     }
 
     ngAfterViewInit(): void {
-        this.dropdownComponent.onOpenChange(isOpen => {
+        this.dropdownComponent.onOpenChange((isOpen) => {
             if (isOpen) {
                 this.calendarTable.nativeElement.focus();
             }

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html

@@ -1,6 +1,6 @@
 <vdr-chip [ngClass]="state" [colorType]="chipColorType">
-    <clr-icon shape="success-standard" *ngIf="state === 'Fulfilled'" size="12"></clr-icon>
-    <clr-icon shape="success-standard" *ngIf="state === 'PartiallyFulfilled'" size="12"></clr-icon>
+    <clr-icon shape="success-standard" *ngIf="state === 'Delivered'" size="12"></clr-icon>
+    <clr-icon shape="success-standard" *ngIf="state === 'PartiallyDelivered'" size="12"></clr-icon>
     <clr-icon shape="ban" *ngIf="state === 'Cancelled'" size="12"></clr-icon>
     {{ state | orderStateI18nToken | translate }}
     <ng-content></ng-content>

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts

@@ -13,9 +13,9 @@ export class OrderStateLabelComponent {
         switch (this.state) {
             case 'PaymentAuthorized':
             case 'PaymentSettled':
-            case 'PartiallyFulfilled':
+            case 'PartiallyDelivered':
                 return 'warning';
-            case 'Fulfilled':
+            case 'Delivered':
                 return 'success';
             case 'Cancelled':
                 return 'error';

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/product-selector/product-selector.component.ts

@@ -31,13 +31,13 @@ export class ProductSelectorComponent implements OnInit {
             debounceTime(200),
             distinctUntilChanged(),
             tap(() => (this.searchLoading = true)),
-            switchMap(term => {
+            switchMap((term) => {
                 if (!term) {
                     return of([]);
                 }
                 return this.dataService.product
                     .productSelectorSearch(term, 10)
-                    .mapSingle(result => result.search.items);
+                    .mapSingle((result) => result.search.items);
             }),
             tap(() => (this.searchLoading = false)),
         );

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component.ts

@@ -28,7 +28,7 @@ export class CustomerGroupFormInputComponent implements FormInputComponent, OnIn
             .getCustomerGroupList({
                 take: 9999,
             })
-            .mapSingle(res => res.customerGroups.items)
+            .mapSingle((res) => res.customerGroups.items)
             .pipe(startWith([]));
     }
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts

@@ -96,5 +96,5 @@ export function registerCustomFieldComponent(
  * Registers the default form input components.
  */
 export function registerDefaultFormInputs(): FactoryProvider[] {
-    return defaultFormInputs.map(cmp => registerFormInputComponent(cmp.id, cmp));
+    return defaultFormInputs.map((cmp) => registerFormInputComponent(cmp.id, cmp));
 }

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts

@@ -10,8 +10,8 @@ export class OrderStateI18nTokenPipe implements PipeTransform {
         ArrangingPayment: _('order.state-arranging-payment'),
         PaymentAuthorized: _('order.state-payment-authorized'),
         PaymentSettled: _('order.state-payment-settled'),
-        PartiallyFulfilled: _('order.state-partially-fulfilled'),
-        Fulfilled: _('order.state-fulfilled'),
+        PartiallyDelivered: _('order.state-partially-delivered'),
+        Delivered: _('order.state-delivered'),
         Cancelled: _('order.state-cancelled'),
     };
     transform<T extends unknown>(value: T): T {

+ 18 - 18
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts

@@ -94,12 +94,12 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
         this.init();
         this.availableCountries$ = this.dataService.settings
             .getAvailableCountries()
-            .mapSingle(result => result.countries.items)
+            .mapSingle((result) => result.countries.items)
             .pipe(shareReplay(1));
 
         const customerWithUpdates$ = this.entity$.pipe(merge(this.orderListUpdates$));
-        this.orders$ = customerWithUpdates$.pipe(map(customer => customer.orders.items));
-        this.ordersCount$ = this.entity$.pipe(map(customer => customer.orders.totalItems));
+        this.orders$ = customerWithUpdates$.pipe(map((customer) => customer.orders.items));
+        this.ordersCount$ = this.entity$.pipe(map((customer) => customer.orders.totalItems));
         this.history$ = this.fetchHistory.pipe(
             startWith(null),
             switchMap(() => {
@@ -109,7 +109,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                             createdAt: SortOrder.DESC,
                         },
                     })
-                    .mapStream(data => data.customer?.history.items);
+                    .mapStream((data) => data.customer?.history.items);
             }),
         );
     }
@@ -182,7 +182,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
             customFields,
         };
         this.dataService.customer.createCustomer(customer, formValue.password).subscribe(
-            data => {
+            (data) => {
                 this.notificationService.success(_('common.notify-create-success'), {
                     entity: 'Customer',
                 });
@@ -199,7 +199,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 this.changeDetector.markForCheck();
                 this.router.navigate(['../', data.createCustomer.id], { relativeTo: this.route });
             },
-            err => {
+            (err) => {
                 this.notificationService.error(_('common.notify-create-error'), {
                     entity: 'Customer',
                 });
@@ -265,7 +265,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 }),
             )
             .subscribe(
-                data => {
+                (data) => {
                     this.notificationService.success(_('common.notify-update-success'), {
                         entity: 'Customer',
                     });
@@ -274,7 +274,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     this.changeDetector.markForCheck();
                     this.fetchHistory.next();
                 },
-                err => {
+                (err) => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Customer',
                     });
@@ -288,11 +288,11 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 size: 'md',
             })
             .pipe(
-                switchMap(groupIds => (groupIds ? from(groupIds) : EMPTY)),
-                concatMap(groupId => this.dataService.customer.addCustomersToGroup(groupId, [this.id])),
+                switchMap((groupIds) => (groupIds ? from(groupIds) : EMPTY)),
+                concatMap((groupId) => this.dataService.customer.addCustomersToGroup(groupId, [this.id])),
             )
             .subscribe({
-                next: res => {
+                next: (res) => {
                     this.notificationService.success(_(`customer.add-customers-to-group-success`), {
                         customerCount: 1,
                         groupName: res.addCustomersToGroup.name,
@@ -315,14 +315,14 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 ],
             })
             .pipe(
-                switchMap(response =>
+                switchMap((response) =>
                     response
                         ? this.dataService.customer.removeCustomersFromGroup(group.id, [this.id])
                         : EMPTY,
                 ),
                 switchMap(() => this.dataService.customer.getCustomer(this.id, { take: 0 }).single$),
             )
-            .subscribe(result => {
+            .subscribe((result) => {
                 this.notificationService.success(_(`customer.remove-customers-from-group-success`), {
                     customerCount: 1,
                     groupName: group.name,
@@ -350,7 +350,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 },
             })
             .pipe(
-                switchMap(result => {
+                switchMap((result) => {
                     if (result) {
                         return this.dataService.customer.updateCustomerNote({
                             noteId: entry.id,
@@ -361,7 +361,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     }
                 }),
             )
-            .subscribe(result => {
+            .subscribe((result) => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-update-success'), {
                     entity: 'Note',
@@ -379,7 +379,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     { type: 'danger', label: _('common.delete'), returnValue: true },
                 ],
             })
-            .pipe(switchMap(res => (res ? this.dataService.customer.deleteCustomerNote(entry.id) : EMPTY)))
+            .pipe(switchMap((res) => (res ? this.dataService.customer.deleteCustomerNote(entry.id) : EMPTY)))
             .subscribe(() => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-delete-success'), {
@@ -441,9 +441,9 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 skip: (this.currentOrdersPage - 1) * this.ordersPerPage,
             })
             .single$.pipe(
-                map(data => data.customer),
+                map((data) => data.customer),
                 filter(notNullOrUndefined),
             )
-            .subscribe(result => this.orderListUpdates$.next(result));
+            .subscribe((result) => this.orderListUpdates$.next(result));
     }
 }

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.html

@@ -1,4 +1,4 @@
-<vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyFulfilled'">
+<vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyDelivered'">
     <button type="button" class="icon-button" vdrDropdownTrigger>
         <clr-icon *ngIf="fulfillmentStatus === 'full'" class="item-fulfilled" shape="check-circle"></clr-icon>
         <clr-icon

+ 7 - 11
packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts

@@ -1,7 +1,6 @@
 import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { unique } from '@vendure/common/lib/unique';
-
 import { OrderDetail } from '@vendure/admin-ui/core';
+import { unique } from '@vendure/common/lib/unique';
 
 export type FulfillmentStatus = 'full' | 'partial' | 'none';
 
@@ -20,7 +19,7 @@ export class LineFulfillmentComponent implements OnChanges {
 
     ngOnChanges(changes: SimpleChanges): void {
         if (this.line) {
-            this.fulfilledCount = this.getFulfilledCount(this.line);
+            this.fulfilledCount = this.getDeliveredCount(this.line);
             this.fulfillmentStatus = this.getFulfillmentStatus(this.fulfilledCount, this.line.items.length);
             this.fulfillments = this.getFulfillments(this.line);
         }
@@ -29,7 +28,7 @@ export class LineFulfillmentComponent implements OnChanges {
     /**
      * Returns the number of items in an OrderLine which are fulfilled.
      */
-    private getFulfilledCount(line: OrderDetail.Lines): number {
+    private getDeliveredCount(line: OrderDetail.Lines): number {
         return line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
     }
 
@@ -57,18 +56,15 @@ export class LineFulfillmentComponent implements OnChanges {
                 }
             }
         }
-        const all = line.items.reduce(
-            (fulfillments, item) => {
-                return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
-            },
-            [] as OrderDetail.Fulfillments[],
-        );
+        const all = line.items.reduce((fulfillments, item) => {
+            return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
+        }, [] as OrderDetail.Fulfillments[]);
 
         return Object.entries(counts).map(([id, count]) => {
             return {
                 count,
                 // tslint:disable-next-line:no-non-null-assertion
-                fulfillment: all.find(f => f.id === id)!,
+                fulfillment: all.find((f) => f.id === id)!,
             };
         });
     }

+ 2 - 5
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -15,7 +15,7 @@
         <button
             class="btn btn-primary"
             (click)="fulfillOrder()"
-            [disabled]="order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled'"
+            [disabled]="order.state !== 'PaymentSettled' && order.state !== 'PartiallyDelivered'"
         >
             {{ 'order.fulfill-order' | translate }}
         </button>
@@ -273,10 +273,7 @@
                     </div>
                     <div class="card-block">
                         <div class="fulfillment-detail" *ngFor="let fulfillment of order.fulfillments">
-                            <vdr-fulfillment-detail
-                                [fulfillmentId]="fulfillment.id"
-                                [order]="order"
-                            ></vdr-fulfillment-detail>
+                            <vdr-fulfillment-detail [fulfillmentId]="fulfillment.id" [order]="order"></vdr-fulfillment-detail>
                         </div>
                     </div>
                 </div>

+ 30 - 30
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -47,8 +47,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         'ArrangingPayment',
         'PaymentAuthorized',
         'PaymentSettled',
-        'PartiallyFulfilled',
-        'Fulfilled',
+        'PartiallyDelivered',
+        'Delivered',
         'Cancelled',
     ];
     constructor(
@@ -85,15 +85,15 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                             createdAt: SortOrder.DESC,
                         },
                     })
-                    .mapStream((data) => data.order?.history.items);
+                    .mapStream(data => data.order?.history.items);
             }),
         );
         this.nextStates$ = this.entity$.pipe(
-            map((order) => {
+            map(order => {
                 const isInCustomState = !this.defaultStates.includes(order.state);
                 return isInCustomState
                     ? order.nextStates
-                    : order.nextStates.filter((s) => !this.defaultStates.includes(s));
+                    : order.nextStates.filter(s => !this.defaultStates.includes(s));
             }),
         );
     }
@@ -107,7 +107,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
     }
 
     getLinePromotions(line: OrderDetail.Lines) {
-        return line.adjustments.filter((a) => a.type === AdjustmentType.PROMOTION);
+        return line.adjustments.filter(a => a.type === AdjustmentType.PROMOTION);
     }
 
     getPromotionLink(promotion: OrderDetail.Adjustments): any[] {
@@ -119,7 +119,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         this.entity$
             .pipe(
                 take(1),
-                switchMap((order) =>
+                switchMap(order =>
                     this.modalService.fromComponent(OrderProcessGraphDialogComponent, {
                         closable: true,
                         locals: {
@@ -132,7 +132,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
     }
 
     transitionToState(state: string) {
-        this.dataService.order.transitionToState(this.id, state).subscribe((val) => {
+        this.dataService.order.transitionToState(this.id, state).subscribe(val => {
             this.notificationService.success(_('order.transitioned-to-state-success'), { state });
             this.fetchHistory.next();
         });
@@ -154,7 +154,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         promotionAdjustment: OrderDetail.Adjustments,
     ): string | undefined {
         const id = promotionAdjustment.adjustmentSource.split(':')[1];
-        const promotion = order.promotions.find((p) => p.id === id);
+        const promotion = order.promotions.find(p => p.id === id);
         if (promotion) {
             return promotion.couponCode || undefined;
         }
@@ -165,8 +165,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
             return [];
         }
         return Object.values(orderAddress)
-            .filter((val) => val !== 'OrderAddress')
-            .filter((line) => !!line);
+            .filter(val => val !== 'OrderAddress')
+            .filter(line => !!line);
     }
 
     settlePayment(payment: OrderDetail.Payments) {
@@ -187,7 +187,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         this.entity$
             .pipe(
                 take(1),
-                switchMap((order) => {
+                switchMap(order => {
                     return this.modalService.fromComponent(FulfillOrderDialogComponent, {
                         size: 'xl',
                         locals: {
@@ -195,16 +195,16 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                         },
                     });
                 }),
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
-                        return this.dataService.order.createFullfillment(input);
+                        return this.dataService.order.createFulfillment(input);
                     } else {
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.create-fulfillment-success'));
                 }
@@ -228,7 +228,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((transactionId) => {
+                switchMap(transactionId => {
                     if (transactionId) {
                         return this.dataService.order.settleRefund(
                             {
@@ -243,7 +243,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 }),
                 // switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.settle-refund-success'));
                 }
@@ -258,8 +258,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 note,
                 isPublic,
             })
-            .pipe(switchMap((result) => this.refetchOrder(result)))
-            .subscribe((result) => {
+            .pipe(switchMap(result => this.refetchOrder(result)))
+            .subscribe(result => {
                 this.notificationService.success(_('common.notify-create-success'), {
                     entity: 'Note',
                 });
@@ -277,7 +277,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((result) => {
+                switchMap(result => {
                     if (result) {
                         return this.dataService.order.updateOrderNote({
                             noteId: entry.id,
@@ -289,7 +289,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                     }
                 }),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-update-success'), {
                     entity: 'Note',
@@ -307,7 +307,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                     { type: 'danger', label: _('common.delete'), returnValue: true },
                 ],
             })
-            .pipe(switchMap((res) => (res ? this.dataService.order.deleteOrderNote(entry.id) : EMPTY)))
+            .pipe(switchMap(res => (res ? this.dataService.order.deleteOrderNote(entry.id) : EMPTY)))
             .subscribe(() => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-delete-success'), {
@@ -325,16 +325,16 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
                         return this.dataService.order.cancelOrder(input);
                     } else {
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.cancelled-order-success'));
                 }
@@ -350,10 +350,10 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
                         return this.dataService.order.refundOrder(omit(input, ['cancel'])).pipe(
-                            switchMap((result) => {
+                            switchMap(result => {
                                 if (input.cancel.length) {
                                     return this.dataService.order.cancelOrder({
                                         orderId: this.id,
@@ -369,9 +369,9 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.refund-order-success'));
                 }

+ 5 - 5
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html

@@ -30,13 +30,13 @@
     >
         <ng-container [ngSwitch]="entry.type">
             <ng-container *ngSwitchCase="type.ORDER_STATE_TRANSITION">
-                <div class="title" *ngIf="entry.data.to === 'Fulfilled'">
+                <div class="title" *ngIf="entry.data.to === 'Delivered'">
                     {{ 'order.history-order-fulfilled' | translate }}
                 </div>
                 <div class="title" *ngIf="entry.data.to === 'Cancelled'">
                     {{ 'order.history-order-cancelled' | translate }}
                 </div>
-                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Fulfilled'">
+                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Delivered'">
                     {{
                         'order.history-order-transition'
                             | translate: { from: entry.data.from, to: entry.data.to }
@@ -81,12 +81,12 @@
                     </vdr-labeled-data>
                 </vdr-history-entry-detail>
             </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_FULLFILLMENT">
+            <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT">
                 <div class="title">
                     {{ 'order.history-fulfillment-created' | translate }}
                 </div>
-                {{ 'order.tracking-code' | translate }}: {{ getFullfillment(entry)?.trackingCode }}
-                <vdr-history-entry-detail *ngIf="getFullfillment(entry) as fulfillment">
+                {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
+                <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
                     <vdr-fulfillment-detail
                         [fulfillmentId]="fulfillment.id"
                         [order]="order"

+ 9 - 9
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.ts

@@ -26,7 +26,7 @@ export class OrderHistoryComponent {
 
     getDisplayType(entry: GetOrderHistory.Items): TimelineDisplayType {
         if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
-            if (entry.data.to === 'Fulfilled') {
+            if (entry.data.to === 'Delivered') {
                 return 'success';
             }
             if (entry.data.to === 'Cancelled') {
@@ -49,7 +49,7 @@ export class OrderHistoryComponent {
 
     getTimelineIcon(entry: GetOrderHistory.Items) {
         if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
-            if (entry.data.to === 'Fulfilled') {
+            if (entry.data.to === 'Delivered') {
                 return ['success-standard', 'is-solid'];
             }
             if (entry.data.to === 'Cancelled') {
@@ -62,7 +62,7 @@ export class OrderHistoryComponent {
         if (entry.type === HistoryEntryType.ORDER_NOTE) {
             return 'note';
         }
-        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT) {
+        if (entry.type === HistoryEntryType.ORDER_FULFILLMENT) {
             return 'truck';
         }
     }
@@ -71,14 +71,14 @@ export class OrderHistoryComponent {
         switch (entry.type) {
             case HistoryEntryType.ORDER_STATE_TRANSITION: {
                 return (
-                    entry.data.to === 'Fulfilled' ||
+                    entry.data.to === 'Delivered' ||
                     entry.data.to === 'Cancelled' ||
                     entry.data.to === 'Settled'
                 );
             }
             case HistoryEntryType.ORDER_PAYMENT_TRANSITION:
                 return entry.data.to === 'Settled';
-            case HistoryEntryType.ORDER_FULLFILLMENT:
+            case HistoryEntryType.ORDER_FULFILLMENT:
             case HistoryEntryType.ORDER_NOTE:
                 return true;
             default:
@@ -86,15 +86,15 @@ export class OrderHistoryComponent {
         }
     }
 
-    getFullfillment(entry: GetOrderHistory.Items): OrderDetail.Fulfillments | undefined {
-        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT && this.order.fulfillments) {
-            return this.order.fulfillments.find((f) => f.id === entry.data.fulfillmentId);
+    getFulfillment(entry: GetOrderHistory.Items): OrderDetail.Fulfillments | undefined {
+        if (entry.type === HistoryEntryType.ORDER_FULFILLMENT && this.order.fulfillments) {
+            return this.order.fulfillments.find(f => f.id === entry.data.fulfillmentId);
         }
     }
 
     getPayment(entry: GetOrderHistory.Items): OrderDetail.Payments | undefined {
         if (entry.type === HistoryEntryType.ORDER_PAYMENT_TRANSITION && this.order.payments) {
-            return this.order.payments.find((p) => p.id === entry.data.paymentId);
+            return this.order.payments.find(p => p.id === entry.data.paymentId);
         }
     }
 

+ 3 - 3
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html

@@ -7,10 +7,10 @@
                 <option value="ArrangingPayment">{{ 'order.state-arranging-payment' | translate }}</option>
                 <option value="PaymentAuthorized">{{ 'order.state-payment-authorized' | translate }}</option>
                 <option value="PaymentSettled">{{ 'order.state-payment-settled' | translate }}</option>
-                <option value="PartiallyFulfilled">
-                    {{ 'order.state-partially-fulfilled' | translate }}
+                <option value="PartiallyDelivered">
+                    {{ 'order.state-partially-delivered' | translate }}
                 </option>
-                <option value="Fulfilled">{{ 'order.state-fulfilled' | translate }}</option>
+                <option value="Delivered">{{ 'order.state-delivered' | translate }}</option>
                 <option value="Cancelled">{{ 'order.state-cancelled' | translate }}</option>
             </select>
             <input

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts

@@ -22,7 +22,7 @@ export class OrderListComponent extends BaseListComponent<GetOrderList.Query, Ge
         super(router, route);
         super.setQueryFn(
             (...args: any[]) => this.dataService.order.getOrders(...args).refetchOnChannelChange(),
-            data => data.orders,
+            (data) => data.orders,
             (skip, take) => {
                 const stateFilter = this.stateFilter.value;
                 const state = stateFilter === 'all' ? null : { eq: stateFilter };

+ 3 - 3
packages/admin-ui/src/lib/settings/src/components/test-order-builder/test-order-builder.component.ts

@@ -36,7 +36,7 @@ export class TestOrderBuilderComponent implements OnInit {
         if (this.lines) {
             this.orderLinesChange.emit(this.lines);
         }
-        this.dataService.settings.getActiveChannel('cache-first').single$.subscribe(result => {
+        this.dataService.settings.getActiveChannel('cache-first').single$.subscribe((result) => {
             this.currencyCode = result.activeChannel.currencyCode;
         });
     }
@@ -48,7 +48,7 @@ export class TestOrderBuilderComponent implements OnInit {
     }
 
     private addToLines(result: ProductSelectorSearch.Items) {
-        if (!this.lines.find(l => l.id === result.productVariantId)) {
+        if (!this.lines.find((l) => l.id === result.productVariantId)) {
             this.lines.push({
                 id: result.productVariantId,
                 name: result.productVariantName,
@@ -69,7 +69,7 @@ export class TestOrderBuilderComponent implements OnInit {
     }
 
     removeLine(line: TestOrderLine) {
-        this.lines = this.lines.filter(l => l.id !== line.id);
+        this.lines = this.lines.filter((l) => l.id !== line.id);
         this.persistToLocalStorage();
         this.orderLinesChange.emit(this.lines);
     }

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -597,8 +597,8 @@
     "state-all-orders": "Alle Bestellungen",
     "state-arranging-payment": "Zahlung einrichten",
     "state-cancelled": "Storniert",
-    "state-fulfilled": "Ausgeführt",
-    "state-partially-fulfilled": "Teilweise ausgeführt",
+    "state-delivered": "Ausgeführt",
+    "state-partially-delivered": "Teilweise ausgeführt",
     "state-payment-authorized": "Zahlung autorisiert",
     "state-payment-settled": "Bezahlt",
     "sub-total": "Zwischensumme",
@@ -691,4 +691,4 @@
     "job-result": "Job-Ergebnis",
     "job-state": "Job-Status"
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -597,8 +597,8 @@
     "state-all-orders": "All orders",
     "state-arranging-payment": "Arranging payment",
     "state-cancelled": "Cancelled",
-    "state-fulfilled": "Fulfilled",
-    "state-partially-fulfilled": "Partially fulfilled",
+    "state-delivered": "Delivered",
+    "state-partially-delivered": "Partially fulfilled",
     "state-payment-authorized": "Payment authorized",
     "state-payment-settled": "Payment settled",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -597,8 +597,8 @@
     "state-all-orders": "",
     "state-arranging-payment": "",
     "state-cancelled": "",
-    "state-fulfilled": "",
-    "state-partially-fulfilled": "",
+    "state-delivered": "",
+    "state-partially-delivered": "",
     "state-payment-authorized": "",
     "state-payment-settled": "",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Resultado",
     "job-state": "Estado"
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -597,8 +597,8 @@
     "state-all-orders": "Wszystkie zamówienia",
     "state-arranging-payment": "Oczekiwanie na płatność",
     "state-cancelled": "Anulowano",
-    "state-fulfilled": "Zrealizowano",
-    "state-partially-fulfilled": "Częściowo zrealizowano",
+    "state-delivered": "Zrealizowano",
+    "state-partially-delivered": "Częściowo zrealizowano",
     "state-payment-authorized": "Płatność zaakceptowana",
     "state-payment-settled": "Płatność rozliczona",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Rezultat zlecenia",
     "job-state": "Status zlecenia"
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -597,8 +597,8 @@
     "state-all-orders": "Todos os pedidos",
     "state-arranging-payment": "Organização de pagamento",
     "state-cancelled": "Cancelado",
-    "state-fulfilled": "Realizado",
-    "state-partially-fulfilled": "Parcialmente realizado",
+    "state-delivered": "Realizado",
+    "state-partially-delivered": "Parcialmente realizado",
     "state-payment-authorized": "Pagamento autorizado",
     "state-payment-settled": "Pagamento liquidado",
     "sub-total": "Subtotal",
@@ -691,4 +691,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -597,8 +597,8 @@
     "state-all-orders": "所有订单",
     "state-arranging-payment": "正在付款",
     "state-cancelled": "已取消",
-    "state-fulfilled": "已完成",
-    "state-partially-fulfilled": "部分配货",
+    "state-delivered": "已完成",
+    "state-partially-delivered": "部分配货",
     "state-payment-authorized": "已授权支付",
     "state-payment-settled": "已结算",
     "sub-total": "小计金额",
@@ -691,4 +691,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

+ 3 - 3
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -597,8 +597,8 @@
     "state-all-orders": "所有訂單",
     "state-arranging-payment": "正在付款",
     "state-cancelled": "已取消",
-    "state-fulfilled": "已完成",
-    "state-partially-fulfilled": "部分配貨",
+    "state-delivered": "已完成",
+    "state-partially-delivered": "部分配貨",
     "state-payment-authorized": "已授權支付",
     "state-payment-settled": "已結算",
     "sub-total": "小計金額",
@@ -691,4 +691,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

+ 10 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1234,10 +1234,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
+    nextStates: Array<Scalars['String']>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1313,9 +1315,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',
@@ -1869,6 +1872,7 @@ export type Mutation = {
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<Order>;
+    transitionFulfillmentToState: Fulfillment;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -2162,6 +2166,11 @@ export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
 
+export type MutationTransitionFulfillmentToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };

+ 3 - 1
packages/common/src/generated-shop-types.ts

@@ -884,6 +884,7 @@ export type Fulfillment = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -953,9 +954,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',

+ 11 - 1
packages/common/src/generated-types.ts

@@ -1232,10 +1232,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
    __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   orderItems: Array<OrderItem>;
+  state: Scalars['String'];
   method: Scalars['String'];
   trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1311,9 +1313,10 @@ export enum HistoryEntryType {
   CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-  ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+  ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
   ORDER_CANCELLATION = 'ORDER_CANCELLATION',
   ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+  ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
   ORDER_NOTE = 'ORDER_NOTE',
   ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
   ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED'
@@ -1868,6 +1871,7 @@ export type Mutation = {
   updateOrderNote: HistoryEntry;
   deleteOrderNote: DeletionResponse;
   transitionOrderToState?: Maybe<Order>;
+  transitionFulfillmentToState: Fulfillment;
   setOrderCustomFields?: Maybe<Order>;
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod;
@@ -2214,6 +2218,12 @@ export type MutationTransitionOrderToStateArgs = {
 };
 
 
+export type MutationTransitionFulfillmentToStateArgs = {
+  id: Scalars['ID'];
+  state: Scalars['String'];
+};
+
+
 export type MutationSetOrderCustomFieldsArgs = {
   input: UpdateOrderInput;
 };

+ 31 - 31
packages/core/e2e/asset.e2e-spec.ts

@@ -6,7 +6,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { ASSET_FRAGMENT } from './graphql/fragments';
 import {
@@ -65,7 +65,7 @@ describe('Asset resolver', () => {
         );
 
         expect(assets.totalItems).toBe(4);
-        expect(assets.items.map(a => omit(a, ['id']))).toEqual([
+        expect(assets.items.map((a) => omit(a, ['id']))).toEqual([
             {
                 fileSize: 1680,
                 mimeType: 'image/jpeg',
@@ -130,33 +130,33 @@ describe('Asset resolver', () => {
             const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
                 mutation: CREATE_ASSETS,
                 filePaths: filesToUpload,
-                mapVariables: filePaths => ({
-                    input: filePaths.map(p => ({ file: null })),
+                mapVariables: (filePaths) => ({
+                    input: filePaths.map((p) => ({ file: null })),
                 }),
             });
 
-            expect(createAssets.map(a => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1))).toEqual(
-                [
-                    {
-                        fileSize: 1680,
-                        focalPoint: null,
-                        mimeType: 'image/jpeg',
-                        name: 'pps1.jpg',
-                        preview: 'test-url/test-assets/pps1__preview.jpg',
-                        source: 'test-url/test-assets/pps1.jpg',
-                        type: 'IMAGE',
-                    },
-                    {
-                        fileSize: 1680,
-                        focalPoint: null,
-                        mimeType: 'image/jpeg',
-                        name: 'pps2.jpg',
-                        preview: 'test-url/test-assets/pps2__preview.jpg',
-                        source: 'test-url/test-assets/pps2.jpg',
-                        type: 'IMAGE',
-                    },
-                ],
-            );
+            expect(
+                createAssets.map((a) => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1)),
+            ).toEqual([
+                {
+                    fileSize: 1680,
+                    focalPoint: null,
+                    mimeType: 'image/jpeg',
+                    name: 'pps1.jpg',
+                    preview: 'test-url/test-assets/pps1__preview.jpg',
+                    source: 'test-url/test-assets/pps1.jpg',
+                    type: 'IMAGE',
+                },
+                {
+                    fileSize: 1680,
+                    focalPoint: null,
+                    mimeType: 'image/jpeg',
+                    name: 'pps2.jpg',
+                    preview: 'test-url/test-assets/pps2__preview.jpg',
+                    source: 'test-url/test-assets/pps2.jpg',
+                    type: 'IMAGE',
+                },
+            ]);
 
             createdAssetId = createAssets[0].id;
         });
@@ -166,12 +166,12 @@ describe('Asset resolver', () => {
             const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
                 mutation: CREATE_ASSETS,
                 filePaths: filesToUpload,
-                mapVariables: filePaths => ({
-                    input: filePaths.map(p => ({ file: null })),
+                mapVariables: (filePaths) => ({
+                    input: filePaths.map((p) => ({ file: null })),
                 }),
             });
 
-            expect(createAssets.map(a => omit(a, ['id']))).toEqual([
+            expect(createAssets.map((a) => omit(a, ['id']))).toEqual([
                 {
                     fileSize: 1680,
                     focalPoint: null,
@@ -191,8 +191,8 @@ describe('Asset resolver', () => {
                 const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
                     mutation: CREATE_ASSETS,
                     filePaths: filesToUpload,
-                    mapVariables: filePaths => ({
-                        input: filePaths.map(p => ({ file: null })),
+                    mapVariables: (filePaths) => ({
+                        input: filePaths.map((p) => ({ file: null })),
                     }),
                 });
             }, `The MIME type 'text/plain' is not permitted.`),

+ 1 - 1
packages/core/e2e/customer-group.e2e-spec.ts

@@ -4,7 +4,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     AddCustomersToGroup,

+ 242 - 0
packages/core/e2e/fulfillment-process.e2e-spec.ts

@@ -0,0 +1,242 @@
+/* tslint:disable:no-non-null-assertion */
+import { CustomFulfillmentProcess, FulfillmentState, mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import {
+    CreateFulfillment,
+    GetCustomerList,
+    GetOrderFulfillments,
+    TransitFulfillment,
+} from './graphql/generated-e2e-admin-types';
+import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
+import {
+    CREATE_FULFILLMENT,
+    GET_CUSTOMER_LIST,
+    GET_ORDER_FULFILLMENTS,
+    TRANSIT_FULFILLMENT,
+} from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
+
+const initSpy = jest.fn();
+const transitionStartSpy = jest.fn();
+const transitionEndSpy = jest.fn();
+const transitionEndSpy2 = jest.fn();
+const transitionErrorSpy = jest.fn();
+
+describe('Fulfillment process', () => {
+    const VALIDATION_ERROR_MESSAGE = 'Fulfillment must have a tracking code';
+    const customOrderProcess: CustomFulfillmentProcess<'AwaitingPickup'> = {
+        init(injector) {
+            initSpy(injector.getConnection().name);
+        },
+        transitions: {
+            Pending: {
+                to: ['AwaitingPickup'],
+                mergeStrategy: 'replace',
+            },
+            AwaitingPickup: {
+                to: ['Shipped'],
+            },
+        },
+        onTransitionStart(fromState, toState, data) {
+            transitionStartSpy(fromState, toState, data);
+            if (fromState === 'AwaitingPickup' && toState === 'Shipped') {
+                if (!data.fulfillment.trackingCode) {
+                    return VALIDATION_ERROR_MESSAGE;
+                }
+            }
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy(fromState, toState, data);
+        },
+        onTransitionError(fromState, toState, message) {
+            transitionErrorSpy(fromState, toState, message);
+        },
+    };
+
+    const customOrderProcess2: CustomFulfillmentProcess<'AwaitingPickup'> = {
+        transitions: {
+            AwaitingPickup: {
+                to: ['Cancelled'],
+            },
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy2(fromState, toState, data);
+        },
+    };
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            shippingOptions: {
+                ...testConfig.shippingOptions,
+                customFulfillmentProcess: [customOrderProcess as any, customOrderProcess2 as any],
+            },
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        // Create a couple of orders to be queried
+        const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: {
+                    take: 3,
+                },
+            },
+        );
+        const customers = result.customers.items;
+
+        /**
+         * Creates a Orders to test Fulfillment Process
+         */
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        // Add Items
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_2',
+            quantity: 1,
+        });
+        // Transit to payment
+        await proceedToArrangingPayment(shopClient);
+        await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+
+        // Add a fulfillment without tracking code
+        await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
+            input: {
+                lines: [{ orderLineId: 'T_1', quantity: 1 }],
+                method: 'Test1',
+            },
+        });
+
+        // Add a fulfillment with tracking code
+        await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
+            input: {
+                lines: [{ orderLineId: 'T_2', quantity: 1 }],
+                method: 'Test1',
+                trackingCode: '222',
+            },
+        });
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('CustomFulfillmentProcess', () => {
+        it('replaced transition target', async () => {
+            const { order } = await adminClient.query<
+                GetOrderFulfillments.Query,
+                GetOrderFulfillments.Variables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: 'T_1',
+            });
+            const [fulfillment] = order?.fulfillments || [];
+            expect(fulfillment.nextStates).toEqual(['AwaitingPickup']);
+        });
+
+        it('custom onTransitionStart handler returning error message', async () => {
+            // First transit to AwaitingPickup
+            await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                TRANSIT_FULFILLMENT,
+                {
+                    id: 'T_1',
+                    state: 'AwaitingPickup',
+                },
+            );
+
+            transitionStartSpy.mockClear();
+            transitionErrorSpy.mockClear();
+            transitionEndSpy.mockClear();
+
+            try {
+                await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                    TRANSIT_FULFILLMENT,
+                    {
+                        id: 'T_1',
+                        state: 'Shipped',
+                    },
+                );
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(VALIDATION_ERROR_MESSAGE);
+            }
+
+            expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+            expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy).not.toHaveBeenCalled();
+            expect(transitionErrorSpy.mock.calls[0]).toEqual([
+                'AwaitingPickup',
+                'Shipped',
+                VALIDATION_ERROR_MESSAGE,
+            ]);
+        });
+
+        it('custom onTransitionStart handler allows transition', async () => {
+            transitionEndSpy.mockClear();
+
+            // First transit to AwaitingPickup
+            await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                TRANSIT_FULFILLMENT,
+                {
+                    id: 'T_2',
+                    state: 'AwaitingPickup',
+                },
+            );
+
+            transitionEndSpy.mockClear();
+
+            const { transitionFulfillmentToState } = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Shipped',
+            });
+
+            expect(transitionEndSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AwaitingPickup', 'Shipped']);
+            expect(transitionFulfillmentToState?.state).toBe('Shipped');
+        });
+
+        it('composes multiple CustomFulfillmentProcesses', async () => {
+            const { order } = await adminClient.query<
+                GetOrderFulfillments.Query,
+                GetOrderFulfillments.Variables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: 'T_1',
+            });
+            const [fulfillment] = order?.fulfillments || [];
+            expect(fulfillment.nextStates).toEqual(['Shipped', 'Cancelled']);
+        });
+    });
+});
+
+export const ADMIN_TRANSITION_TO_STATE = gql`
+    mutation AdminTransition($id: ID!, $state: String!) {
+        transitionOrderToState(id: $id, state: $state) {
+            id
+            state
+            nextStates
+        }
+    }
+`;

+ 60 - 10
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1234,10 +1234,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
+    nextStates: Array<Scalars['String']>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1313,9 +1315,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',
@@ -1869,6 +1872,7 @@ export type Mutation = {
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<Order>;
+    transitionFulfillmentToState: Fulfillment;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -2162,6 +2166,11 @@ export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
 
+export type MutationTransitionFulfillmentToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };
@@ -4438,6 +4447,15 @@ export type UpdateFacetValuesMutation = { __typename?: 'Mutation' } & {
     updateFacetValues: Array<{ __typename?: 'FacetValue' } & FacetValueFragment>;
 };
 
+export type AdminTransitionMutationVariables = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
+export type AdminTransitionMutation = { __typename?: 'Mutation' } & {
+    transitionOrderToState?: Maybe<{ __typename?: 'Order' } & Pick<Order, 'id' | 'state' | 'nextStates'>>;
+};
+
 export type AdministratorFragment = { __typename?: 'Administrator' } & Pick<
     Administrator,
     'id' | 'firstName' | 'lastName' | 'emailAddress'
@@ -5146,9 +5164,19 @@ export type CreateFulfillmentMutationVariables = {
 };
 
 export type CreateFulfillmentMutation = { __typename?: 'Mutation' } & {
-    fulfillOrder: { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method' | 'trackingCode'> & {
-            orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
-        };
+    fulfillOrder: { __typename?: 'Fulfillment' } & Pick<
+        Fulfillment,
+        'id' | 'method' | 'state' | 'trackingCode'
+    > & { orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>> };
+};
+
+export type TransitFulfillmentMutationVariables = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
+export type TransitFulfillmentMutation = { __typename?: 'Mutation' } & {
+    transitionFulfillmentToState: { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'state'>;
 };
 
 export type GetOrderFulfillmentsQueryVariables = {
@@ -5157,9 +5185,14 @@ export type GetOrderFulfillmentsQueryVariables = {
 
 export type GetOrderFulfillmentsQuery = { __typename?: 'Query' } & {
     order?: Maybe<
-        { __typename?: 'Order' } & Pick<Order, 'id'> & {
+        { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
                 fulfillments?: Maybe<
-                    Array<{ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method'>>
+                    Array<
+                        { __typename?: 'Fulfillment' } & Pick<
+                            Fulfillment,
+                            'id' | 'state' | 'nextStates' | 'method'
+                        >
+                    >
                 >;
             }
     >;
@@ -5170,9 +5203,14 @@ export type GetOrderListFulfillmentsQueryVariables = {};
 export type GetOrderListFulfillmentsQuery = { __typename?: 'Query' } & {
     orders: { __typename?: 'OrderList' } & {
         items: Array<
-            { __typename?: 'Order' } & Pick<Order, 'id'> & {
+            { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
                     fulfillments?: Maybe<
-                        Array<{ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method'>>
+                        Array<
+                            { __typename?: 'Fulfillment' } & Pick<
+                                Fulfillment,
+                                'id' | 'state' | 'nextStates' | 'method'
+                            >
+                        >
                     >;
                 }
         >;
@@ -5185,10 +5223,10 @@ export type GetOrderFulfillmentItemsQueryVariables = {
 
 export type GetOrderFulfillmentItemsQuery = { __typename?: 'Query' } & {
     order?: Maybe<
-        { __typename?: 'Order' } & Pick<Order, 'id'> & {
+        { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
                 fulfillments?: Maybe<
                     Array<
-                        { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id'> & {
+                        { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'state'> & {
                                 orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
                             }
                     >
@@ -6370,6 +6408,12 @@ export namespace UpdateFacetValues {
     export type UpdateFacetValues = FacetValueFragment;
 }
 
+export namespace AdminTransition {
+    export type Variables = AdminTransitionMutationVariables;
+    export type Mutation = AdminTransitionMutation;
+    export type TransitionOrderToState = NonNullable<AdminTransitionMutation['transitionOrderToState']>;
+}
+
 export namespace Administrator {
     export type Fragment = AdministratorFragment;
     export type User = AdministratorFragment['user'];
@@ -6821,6 +6865,12 @@ export namespace CreateFulfillment {
     export type OrderItems = NonNullable<CreateFulfillmentMutation['fulfillOrder']['orderItems'][0]>;
 }
 
+export namespace TransitFulfillment {
+    export type Variables = TransitFulfillmentMutationVariables;
+    export type Mutation = TransitFulfillmentMutation;
+    export type TransitionFulfillmentToState = TransitFulfillmentMutation['transitionFulfillmentToState'];
+}
+
 export namespace GetOrderFulfillments {
     export type Variables = GetOrderFulfillmentsQueryVariables;
     export type Query = GetOrderFulfillmentsQuery;

+ 3 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -884,6 +884,7 @@ export type Fulfillment = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -953,9 +954,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',

+ 36 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -436,3 +436,39 @@ export const REMOVE_CUSTOMERS_FROM_GROUP = gql`
     }
     ${CUSTOMER_GROUP_FRAGMENT}
 `;
+export const CREATE_FULFILLMENT = gql`
+    mutation CreateFulfillment($input: FulfillOrderInput!) {
+        fulfillOrder(input: $input) {
+            id
+            method
+            state
+            trackingCode
+            orderItems {
+                id
+            }
+        }
+    }
+`;
+
+export const TRANSIT_FULFILLMENT = gql`
+    mutation TransitFulfillment($id: ID!, $state: String!) {
+        transitionFulfillmentToState(id: $id, state: $state) {
+            id
+            state
+        }
+    }
+`;
+export const GET_ORDER_FULFILLMENTS = gql`
+    query GetOrderFulfillments($id: ID!) {
+        order(id: $id) {
+            id
+            state
+            fulfillments {
+                id
+                state
+                nextStates
+                method
+            }
+        }
+    }
+`;

+ 6 - 6
packages/core/e2e/order-process.e2e-spec.ts

@@ -357,19 +357,19 @@ describe('Order process', () => {
             expect(result.order?.state).toBe('PaymentSettled');
         });
 
-        it('cannot manually transition to PartiallyFulfilled', async () => {
+        it('cannot manually transition to PartiallyDelivered', async () => {
             try {
                 const { transitionOrderToState } = await adminClient.query<
                     AdminTransition.Mutation,
                     AdminTransition.Variables
                 >(ADMIN_TRANSITION_TO_STATE, {
                     id: order.id,
-                    state: 'PartiallyFulfilled',
+                    state: 'PartiallyDelivered',
                 });
                 fail('Should have thrown');
             } catch (e) {
                 expect(e.message).toContain(
-                    'Cannot transition Order to the "PartiallyFulfilled" state unless some OrderItems are fulfilled',
+                    'Cannot transition Order to the "PartiallyDelivered" state unless some OrderItems are delivered',
                 );
             }
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
@@ -378,19 +378,19 @@ describe('Order process', () => {
             expect(result.order?.state).toBe('PaymentSettled');
         });
 
-        it('cannot manually transition to PartiallyFulfilled', async () => {
+        it('cannot manually transition to PartiallyDelivered', async () => {
             try {
                 const { transitionOrderToState } = await adminClient.query<
                     AdminTransition.Mutation,
                     AdminTransition.Variables
                 >(ADMIN_TRANSITION_TO_STATE, {
                     id: order.id,
-                    state: 'Fulfilled',
+                    state: 'Delivered',
                 });
                 fail('Should have thrown');
             } catch (e) {
                 expect(e.message).toContain(
-                    'Cannot transition Order to the "Fulfilled" state unless all OrderItems are fulfilled',
+                    'Cannot transition Order to the "Delivered" state unless all OrderItems are delivered',
                 );
             }
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {

+ 8 - 8
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -15,7 +15,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import {
@@ -174,7 +174,7 @@ describe('Promotions applied to Orders', () => {
         it('order history records application', async () => {
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
 
-            expect(activeOrder!.history.items.map(i => omit(i, ['id']))).toEqual([
+            expect(activeOrder!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
@@ -211,7 +211,7 @@ describe('Promotions applied to Orders', () => {
         it('order history records removal', async () => {
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
 
-            expect(activeOrder!.history.items.map(i => omit(i, ['id']))).toEqual([
+            expect(activeOrder!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
@@ -236,7 +236,7 @@ describe('Promotions applied to Orders', () => {
                 couponCode: 'NOT_THERE',
             });
 
-            expect(removeCouponCode!.history.items.map(i => omit(i, ['id']))).toEqual([
+            expect(removeCouponCode!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
@@ -522,7 +522,7 @@ describe('Promotions applied to Orders', () => {
             });
 
             function getItemSale1Line(lines: TestOrderFragment.Lines[]): TestOrderFragment.Lines {
-                return lines.find(l => l.productVariant.id === getVariantBySlug('item-sale-1').id)!;
+                return lines.find((l) => l.productVariant.id === getVariantBySlug('item-sale-1').id)!;
             }
             expect(addItemToOrder!.adjustments.length).toBe(0);
             expect(getItemSale1Line(addItemToOrder!.lines).adjustments.length).toBe(2); // 2x tax
@@ -653,7 +653,7 @@ describe('Promotions applied to Orders', () => {
 
             expect(apply1?.lines[0].adjustments.length).toBe(2);
             expect(
-                apply1?.lines[0].adjustments.find(a => a.type === AdjustmentType.PROMOTION)?.description,
+                apply1?.lines[0].adjustments.find((a) => a.type === AdjustmentType.PROMOTION)?.description,
             ).toBe('item promo');
             expect(apply1?.adjustments.length).toBe(0);
 
@@ -667,7 +667,7 @@ describe('Promotions applied to Orders', () => {
 
             expect(apply2?.lines[0].adjustments.length).toBe(2);
             expect(
-                apply2?.lines[0].adjustments.find(a => a.type === AdjustmentType.PROMOTION)?.description,
+                apply2?.lines[0].adjustments.find((a) => a.type === AdjustmentType.PROMOTION)?.description,
             ).toBe('item promo');
             expect(apply2?.adjustments.length).toBe(1);
             expect(apply2?.adjustments[0].description).toBe('order promo');
@@ -892,7 +892,7 @@ describe('Promotions applied to Orders', () => {
     function getVariantBySlug(
         slug: 'item-1' | 'item-12' | 'item-60' | 'item-sale-1' | 'item-sale-12',
     ): GetPromoProducts.Variants {
-        return products.find(p => p.slug === slug)!.variants[0];
+        return products.find((p) => p.slug === slug)!.variants[0];
     }
 
     async function deletePromotion(promotionId: string) {

+ 152 - 111
packages/core/e2e/order.e2e-spec.ts

@@ -34,15 +34,19 @@ import {
     SettlePayment,
     SettleRefund,
     StockMovementType,
+    TransitFulfillment,
     UpdateOrderNote,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import { AddItemToOrder, DeletionResult, GetActiveOrder } from './graphql/generated-e2e-shop-types';
 import {
+    CREATE_FULFILLMENT,
     GET_CUSTOMER_LIST,
     GET_ORDER,
+    GET_ORDER_FULFILLMENTS,
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
+    TRANSIT_FULFILLMENT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
@@ -107,7 +111,7 @@ describe('Orders resolver', () => {
 
     it('orders', async () => {
         const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
-        expect(result.orders.items.map((o) => o.id)).toEqual(['T_1', 'T_2']);
+        expect(result.orders.items.map(o => o.id)).toEqual(['T_1', 'T_2']);
     });
 
     it('order', async () => {
@@ -248,7 +252,7 @@ describe('Orders resolver', () => {
                     CREATE_FULFILLMENT,
                     {
                         input: {
-                            lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: l.quantity })),
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
                             method: 'Test',
                         },
                     },
@@ -286,7 +290,7 @@ describe('Orders resolver', () => {
                     CREATE_FULFILLMENT,
                     {
                         input: {
-                            lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 0 })),
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
                             method: 'Test',
                         },
                     },
@@ -294,7 +298,7 @@ describe('Orders resolver', () => {
             }, 'Nothing to fulfill'),
         );
 
-        it('creates a partial fulfillment', async () => {
+        it('creates the first fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
@@ -306,58 +310,60 @@ describe('Orders resolver', () => {
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    lines: lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                    lines: [{ orderLineId: lines[0].id, quantity: lines[0].quantity }],
                     method: 'Test1',
                     trackingCode: '111',
                 },
             });
 
+            expect(fulfillOrder!.id).toBe('T_1');
             expect(fulfillOrder!.method).toBe('Test1');
             expect(fulfillOrder!.trackingCode).toBe('111');
-            expect(fulfillOrder!.orderItems).toEqual([
-                { id: lines[0].items[0].id },
-                { id: lines[1].items[0].id },
-            ]);
+            expect(fulfillOrder!.state).toBe('Pending');
+            expect(fulfillOrder!.orderItems).toEqual([{ id: lines[0].items[0].id }]);
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
 
-            expect(result.order!.state).toBe('PartiallyFulfilled');
-
             expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(fulfillOrder!.id);
             expect(
                 result.order!.lines[1].items.filter(
-                    (i) => i.fulfillment && i.fulfillment.id === fulfillOrder.id,
+                    i => i.fulfillment && i.fulfillment.id === fulfillOrder.id,
                 ).length,
-            ).toBe(1);
-            expect(result.order!.lines[1].items.filter((i) => i.fulfillment == null).length).toBe(2);
+            ).toBe(0);
+            expect(result.order!.lines[1].items.filter(i => i.fulfillment == null).length).toBe(3);
         });
 
-        it('creates a second partial fulfillment', async () => {
+        it('creates the second fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(order!.state).toBe('PartiallyFulfilled');
-            const lines = order!.lines;
+
+            const unfulfilledItems =
+                order?.lines.filter(l => {
+                    const items = l.items.filter(i => i.fulfillment === null);
+                    return items.length > 0 ? true : false;
+                }) || [];
 
             const { fulfillOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    lines: [{ orderLineId: lines[1].id, quantity: 1 }],
+                    lines: unfulfilledItems.map(l => ({
+                        orderLineId: l.id,
+                        quantity: l.items.length,
+                    })),
                     method: 'Test2',
                     trackingCode: '222',
                 },
             });
 
-            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
-            });
-            expect(result.order!.state).toBe('PartiallyFulfilled');
-            expect(result.order!.lines[1].items.filter((i) => i.fulfillment != null).length).toBe(2);
-            expect(result.order!.lines[1].items.filter((i) => i.fulfillment == null).length).toBe(1);
+            expect(fulfillOrder!.id).toBe('T_2');
+            expect(fulfillOrder!.method).toBe('Test2');
+            expect(fulfillOrder!.trackingCode).toBe('222');
+            expect(fulfillOrder!.state).toBe('Pending');
         });
 
         it(
@@ -366,7 +372,6 @@ describe('Orders resolver', () => {
                 const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                     id: 'T_2',
                 });
-                expect(order!.state).toBe('PartiallyFulfilled');
                 await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
                     CREATE_FULFILLMENT,
                     {
@@ -383,43 +388,69 @@ describe('Orders resolver', () => {
                 );
             }, 'One or more OrderItems have already been fulfilled'),
         );
+        it('transits the first fulfillment from created to Shipped and automatically change the order state to PartiallyShipped', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_1',
+                state: 'Shipped',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
 
-        it('completes fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(order!.state).toBe('PartiallyFulfilled');
-
-            const orderItems = order!.lines.reduce(
-                (items, line) => [...items, ...line.items],
-                [] as OrderItemFragment[],
-            );
-            const unfulfilledItem = order!.lines[1].items.find((i) => i.fulfillment == null)!;
+            expect(order?.state).toBe('PartiallyShipped');
+        });
+        it('transits the second fulfillment from created to Shipped and automatically change the order state to Shipped', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Shipped',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
 
-            const { fulfillOrder } = await adminClient.query<
-                CreateFulfillment.Mutation,
-                CreateFulfillment.Variables
-            >(CREATE_FULFILLMENT, {
-                input: {
-                    lines: [
-                        {
-                            orderLineId: order!.lines[1].id,
-                            quantity: 1,
-                        },
-                    ],
-                    method: 'Test3',
-                    trackingCode: '333',
-                },
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
             });
+            expect(order?.state).toBe('Shipped');
+        });
+        it('transits the first fulfillment from Shipped to Delivered and change the order state to PartiallyDelivered', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_1',
+                state: 'Delivered',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
 
-            expect(fulfillOrder!.method).toBe('Test3');
-            expect(fulfillOrder!.trackingCode).toBe('333');
-            expect(fulfillOrder!.orderItems).toEqual([{ id: unfulfilledItem.id }]);
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(order?.state).toBe('PartiallyDelivered');
+        });
+        it('transits the second fulfillment from Shipped to Delivered and change the order state to Delivered', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Delivered',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
 
-            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(result.order!.state).toBe('Fulfilled');
+            expect(order?.state).toBe('Delivered');
         });
 
         it('order history contains expected entries', async () => {
@@ -434,49 +465,81 @@ describe('Orders resolver', () => {
             );
             expect(order!.history.items.map(pick(['type', 'data']))).toEqual([
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
                         fulfillmentId: 'T_1',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT,
+                },
+                {
+                    data: {
+                        fulfillmentId: 'T_2',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT,
+                },
+                {
+                    data: {
+                        from: 'Pending',
+                        fulfillmentId: 'T_1',
+                        to: 'Shipped',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                     data: {
                         from: 'PaymentSettled',
-                        to: 'PartiallyFulfilled',
+                        to: 'PartiallyShipped',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
-
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
+                        from: 'Pending',
                         fulfillmentId: 'T_2',
+                        to: 'Shipped',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                     data: {
-                        from: 'PartiallyFulfilled',
-                        to: 'PartiallyFulfilled',
+                        from: 'PartiallyShipped',
+                        to: 'Shipped',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
-                        fulfillmentId: 'T_3',
+                        from: 'Shipped',
+                        fulfillmentId: 'T_1',
+                        to: 'Delivered',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
+                    data: {
+                        from: 'Shipped',
+                        to: 'PartiallyDelivered',
+                    },
                     type: HistoryEntryType.ORDER_STATE_TRANSITION,
+                },
+                {
+                    data: {
+                        from: 'Shipped',
+                        fulfillmentId: 'T_2',
+                        to: 'Delivered',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                },
+                {
                     data: {
-                        from: 'PartiallyFulfilled',
-                        to: 'Fulfilled',
+                        from: 'PartiallyDelivered',
+                        to: 'Delivered',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
             ]);
         });
 
-        it('order.fullfillments resolver for single order', async () => {
+        it('order.fulfillments resolver for single order', async () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillments.Query,
                 GetOrderFulfillments.Variables
@@ -485,26 +548,24 @@ describe('Orders resolver', () => {
             });
 
             expect(order!.fulfillments).toEqual([
-                { id: 'T_1', method: 'Test1' },
-                { id: 'T_2', method: 'Test2' },
-                { id: 'T_3', method: 'Test3' },
+                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
-        it('order.fullfillments resolver for order list', async () => {
+        it('order.fulfillments resolver for order list', async () => {
             const { orders } = await adminClient.query<GetOrderListFulfillments.Query>(
                 GET_ORDER_LIST_FULFILLMENTS,
             );
 
             expect(orders.items[0].fulfillments).toEqual([]);
             expect(orders.items[1].fulfillments).toEqual([
-                { id: 'T_1', method: 'Test1' },
-                { id: 'T_2', method: 'Test2' },
-                { id: 'T_3', method: 'Test3' },
+                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
-        it('order.fullfillments.orderItems resolver', async () => {
+        it('order.fulfillments.orderItems resolver', async () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillmentItems.Query,
                 GetOrderFulfillmentItems.Variables
@@ -512,8 +573,8 @@ describe('Orders resolver', () => {
                 id: 'T_2',
             });
 
-            expect(order!.fulfillments![0].orderItems).toEqual([{ id: 'T_3' }, { id: 'T_4' }]);
-            expect(order!.fulfillments![1].orderItems).toEqual([{ id: 'T_5' }]);
+            expect(order!.fulfillments![0].orderItems).toEqual([{ id: 'T_3' }]);
+            expect(order!.fulfillments![1].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
         });
     });
 
@@ -605,7 +666,7 @@ describe('Orders resolver', () => {
                     },
                 },
             );
-            expect(cancelOrder.lines.map((l) => l.items.map(pick(['id', 'cancelled'])))).toEqual([
+            expect(cancelOrder.lines.map(l => l.items.map(pick(['id', 'cancelled'])))).toEqual([
                 [
                     { id: 'T_11', cancelled: true },
                     { id: 'T_12', cancelled: true },
@@ -675,7 +736,7 @@ describe('Orders resolver', () => {
                 await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
                     input: {
                         orderId,
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     },
                 });
             }, 'Cannot cancel OrderLines from an Order in the "AddingItems" state'),
@@ -692,7 +753,7 @@ describe('Orders resolver', () => {
                 await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
                     input: {
                         orderId,
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     },
                 });
             }, 'Cannot cancel OrderLines from an Order in the "ArrangingPayment" state'),
@@ -722,7 +783,7 @@ describe('Orders resolver', () => {
                 await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
                     input: {
                         orderId,
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 0 })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
                     },
                 });
             }, 'Nothing to cancel'),
@@ -754,7 +815,7 @@ describe('Orders resolver', () => {
                 {
                     input: {
                         orderId,
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                         reason: 'cancel reason 1',
                     },
                 },
@@ -798,7 +859,7 @@ describe('Orders resolver', () => {
             await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
                 input: {
                     orderId,
-                    lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     reason: 'cancel reason 2',
                 },
             });
@@ -914,7 +975,7 @@ describe('Orders resolver', () => {
 
                 await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
                     input: {
-                        lines: order.lines.map((l) => ({ orderLineId: l.id, quantity: 1 })),
+                        lines: order.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                         shipping: 0,
                         adjustment: 0,
                         paymentId,
@@ -940,7 +1001,7 @@ describe('Orders resolver', () => {
 
                 await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
                     input: {
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: 0 })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
                         shipping: 0,
                         adjustment: 0,
                         paymentId,
@@ -979,7 +1040,7 @@ describe('Orders resolver', () => {
                     REFUND_ORDER,
                     {
                         input: {
-                            lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: l.quantity })),
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
                             shipping: 100,
                             adjustment: 0,
                             paymentId: 'T_1',
@@ -997,7 +1058,7 @@ describe('Orders resolver', () => {
                 REFUND_ORDER,
                 {
                     input: {
-                        lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: l.quantity })),
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
                         shipping: order!.shipping,
                         adjustment: 0,
                         reason: 'foo',
@@ -1024,7 +1085,7 @@ describe('Orders resolver', () => {
                     REFUND_ORDER,
                     {
                         input: {
-                            lines: order!.lines.map((l) => ({ orderLineId: l.id, quantity: l.quantity })),
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
                             shipping: order!.shipping,
                             adjustment: 0,
                             paymentId,
@@ -1318,38 +1379,16 @@ export const SETTLE_PAYMENT = gql`
     }
 `;
 
-export const CREATE_FULFILLMENT = gql`
-    mutation CreateFulfillment($input: FulfillOrderInput!) {
-        fulfillOrder(input: $input) {
-            id
-            method
-            trackingCode
-            orderItems {
-                id
-            }
-        }
-    }
-`;
-
-export const GET_ORDER_FULFILLMENTS = gql`
-    query GetOrderFulfillments($id: ID!) {
-        order(id: $id) {
-            id
-            fulfillments {
-                id
-                method
-            }
-        }
-    }
-`;
-
 export const GET_ORDER_LIST_FULFILLMENTS = gql`
     query GetOrderListFulfillments {
         orders {
             items {
                 id
+                state
                 fulfillments {
                     id
+                    state
+                    nextStates
                     method
                 }
             }
@@ -1361,8 +1400,10 @@ export const GET_ORDER_FULFILLMENT_ITEMS = gql`
     query GetOrderFulfillmentItems($id: ID!) {
         order(id: $id) {
             id
+            state
             fulfillments {
                 id
+                state
                 orderItems {
                     id
                 }

+ 24 - 23
packages/core/e2e/product.e2e-spec.ts

@@ -6,7 +6,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     AddOptionGroupToProduct,
@@ -138,7 +138,7 @@ describe('Product resolver', () => {
                 },
             );
 
-            expect(result.products.items.map(p => p.name)).toEqual([
+            expect(result.products.items.map((p) => p.name)).toEqual([
                 'Bonsai Tree',
                 'Boxing Gloves',
                 'Camera Lens',
@@ -192,7 +192,7 @@ describe('Product resolver', () => {
                 },
             );
 
-            expect(result.products.items.map(p => p.name)).toEqual([
+            expect(result.products.items.map((p) => p.name)).toEqual([
                 'Bonsai Tree',
                 'Boxing Gloves',
                 'Camera Lens',
@@ -365,7 +365,7 @@ describe('Product resolver', () => {
                 },
             );
             expect(omit(result.createProduct, ['translations'])).toMatchSnapshot();
-            expect(result.createProduct.translations.map(t => t.description).sort()).toEqual([
+            expect(result.createProduct.translations.map((t) => t.description).sort()).toEqual([
                 'A baked potato',
                 'Eine baked Erdapfel',
             ]);
@@ -376,7 +376,7 @@ describe('Product resolver', () => {
             const assetsResult = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
                 GET_ASSET_LIST,
             );
-            const assetIds = assetsResult.assets.items.slice(0, 2).map(a => a.id);
+            const assetIds = assetsResult.assets.items.slice(0, 2).map((a) => a.id);
             const featuredAssetId = assetsResult.assets.items[0].id;
 
             const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
@@ -396,7 +396,7 @@ describe('Product resolver', () => {
                     },
                 },
             );
-            expect(result.createProduct.assets.map(a => a.id)).toEqual(assetIds);
+            expect(result.createProduct.assets.map((a) => a.id)).toEqual(assetIds);
             expect(result.createProduct.featuredAsset!.id).toBe(featuredAssetId);
             newProductWithAssets = result.createProduct;
         });
@@ -424,7 +424,7 @@ describe('Product resolver', () => {
                     },
                 },
             );
-            expect(result.updateProduct.translations.map(t => t.description).sort()).toEqual([
+            expect(result.updateProduct.translations.map((t) => t.description).sort()).toEqual([
                 'A blob of mashed potato',
                 'Eine blob von gemashed Erdapfel',
             ]);
@@ -524,13 +524,14 @@ describe('Product resolver', () => {
             );
             expect(result.updateProduct.translations.length).toBe(2);
             expect(
-                result.updateProduct.translations.find(t => t.languageCode === LanguageCode.de)!.name,
+                result.updateProduct.translations.find((t) => t.languageCode === LanguageCode.de)!.name,
             ).toBe('de Mashed Potato');
             expect(
-                result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.name,
+                result.updateProduct.translations.find((t) => t.languageCode === LanguageCode.en)!.name,
             ).toBe('en Very Mashed Potato');
             expect(
-                result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.description,
+                result.updateProduct.translations.find((t) => t.languageCode === LanguageCode.en)!
+                    .description,
             ).toBe('Possibly the final baked potato');
         });
 
@@ -538,7 +539,7 @@ describe('Product resolver', () => {
             const assetsResult = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
                 GET_ASSET_LIST,
             );
-            const assetIds = assetsResult.assets.items.map(a => a.id);
+            const assetIds = assetsResult.assets.items.map((a) => a.id);
             const featuredAssetId = assetsResult.assets.items[2].id;
 
             const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
@@ -551,7 +552,7 @@ describe('Product resolver', () => {
                     },
                 },
             );
-            expect(result.updateProduct.assets.map(a => a.id)).toEqual(assetIds);
+            expect(result.updateProduct.assets.map((a) => a.id)).toEqual(assetIds);
             expect(result.updateProduct.featuredAsset!.id).toBe(featuredAssetId);
         });
 
@@ -587,7 +588,7 @@ describe('Product resolver', () => {
                     },
                 },
             );
-            expect(result.updateProduct.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
+            expect(result.updateProduct.assets.map((a) => a.id)).toEqual(['T_1', 'T_2']);
         });
 
         it('updateProduct updates FacetValues', async () => {
@@ -844,8 +845,8 @@ describe('Product resolver', () => {
                         },
                     ],
                 });
-                const variant2 = createProductVariants.find(v => v!.name === 'Variant 2')!;
-                const variant3 = createProductVariants.find(v => v!.name === 'Variant 3')!;
+                const variant2 = createProductVariants.find((v) => v!.name === 'Variant 2')!;
+                const variant3 = createProductVariants.find((v) => v!.name === 'Variant 3')!;
                 expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
                 expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup3.options[0].id });
                 expect(variant3.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
@@ -916,7 +917,7 @@ describe('Product resolver', () => {
                     fail('no updated variant returned.');
                     return;
                 }
-                expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
+                expect(updatedVariant.assets.map((a) => a.id)).toEqual(['T_1', 'T_2']);
                 expect(updatedVariant.featuredAsset!.id).toBe('T_2');
             });
 
@@ -939,7 +940,7 @@ describe('Product resolver', () => {
                     fail('no updated variant returned.');
                     return;
                 }
-                expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_4', 'T_3']);
+                expect(updatedVariant.assets.map((a) => a.id)).toEqual(['T_4', 'T_3']);
                 expect(updatedVariant.featuredAsset!.id).toBe('T_4');
             });
 
@@ -1018,7 +1019,7 @@ describe('Product resolver', () => {
                 >(GET_PRODUCT_WITH_VARIANTS, {
                     id: newProduct.id,
                 });
-                const sortedVariantIds = result1.product!.variants.map(v => v.id).sort();
+                const sortedVariantIds = result1.product!.variants.map((v) => v.id).sort();
                 expect(sortedVariantIds).toEqual(['T_35', 'T_36', 'T_37']);
 
                 const { deleteProductVariant } = await adminClient.query<
@@ -1036,9 +1037,9 @@ describe('Product resolver', () => {
                 >(GET_PRODUCT_WITH_VARIANTS, {
                     id: newProduct.id,
                 });
-                expect(result2.product!.variants.map(v => v.id).sort()).toEqual(['T_36', 'T_37']);
+                expect(result2.product!.variants.map((v) => v.id).sort()).toEqual(['T_36', 'T_37']);
 
-                deletedVariant = result1.product?.variants.find(v => v.id === 'T_35')!;
+                deletedVariant = result1.product?.variants.find((v) => v.id === 'T_35')!;
             });
 
             /** Testing https://github.com/vendure-ecommerce/vendure/issues/412 **/
@@ -1058,8 +1059,8 @@ describe('Product resolver', () => {
                 });
 
                 expect(createProductVariants.length).toBe(1);
-                expect(createProductVariants[0]!.options.map(o => o.code)).toEqual(
-                    deletedVariant.options.map(o => o.code),
+                expect(createProductVariants[0]!.options.map((o) => o.code)).toEqual(
+                    deletedVariant.options.map((o) => o.code),
                 );
             });
         });
@@ -1108,7 +1109,7 @@ describe('Product resolver', () => {
             const result = await adminClient.query<GetProductList.Query>(GET_PRODUCT_LIST);
 
             expect(result.products.items.length).toBe(allProducts.length - 1);
-            expect(result.products.items.map(c => c.id).includes(productToDelete.id)).toBe(false);
+            expect(result.products.items.map((c) => c.id).includes(productToDelete.id)).toBe(false);
         });
 
         it(

+ 9 - 9
packages/core/e2e/shop-order.e2e-spec.ts

@@ -5,7 +5,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     testErrorPaymentMethod,
@@ -104,7 +104,7 @@ describe('Shop orders', () => {
     it('availableCountries returns enabled countries', async () => {
         // disable Austria
         const { countries } = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
-        const AT = countries.items.find(c => c.code === 'AT')!;
+        const AT = countries.items.find((c) => c.code === 'AT')!;
         await adminClient.query<UpdateCountry.Mutation, UpdateCountry.Variables>(UPDATE_COUNTRY, {
             input: {
                 id: AT.id,
@@ -114,7 +114,7 @@ describe('Shop orders', () => {
 
         const result = await shopClient.query<GetAvailableCountries.Query>(GET_AVAILABLE_COUNTRIES);
         expect(result.availableCountries.length).toBe(countries.items.length - 1);
-        expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined();
+        expect(result.availableCountries.find((c) => c.id === AT.id)).toBeUndefined();
     });
 
     describe('ordering as anonymous user', () => {
@@ -221,7 +221,7 @@ describe('Shop orders', () => {
                 quantity: 3,
             });
             expect(addItemToOrder!.lines.length).toBe(2);
-            expect(addItemToOrder!.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(addItemToOrder!.lines.map((i) => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const { adjustOrderLine } = await shopClient.query<
                 AdjustItemQuantity.Mutation,
@@ -232,7 +232,7 @@ describe('Shop orders', () => {
             });
 
             expect(adjustOrderLine!.lines.length).toBe(1);
-            expect(adjustOrderLine!.lines.map(i => i.productVariant.id)).toEqual(['T_1']);
+            expect(adjustOrderLine!.lines.map((i) => i.productVariant.id)).toEqual(['T_1']);
         });
 
         it(
@@ -287,7 +287,7 @@ describe('Shop orders', () => {
                 quantity: 3,
             });
             expect(addItemToOrder!.lines.length).toBe(2);
-            expect(addItemToOrder!.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(addItemToOrder!.lines.map((i) => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const { removeOrderLine } = await shopClient.query<
                 RemoveItemFromOrder.Mutation,
@@ -296,7 +296,7 @@ describe('Shop orders', () => {
                 orderLineId: firstOrderLineId,
             });
             expect(removeOrderLine!.lines.length).toBe(1);
-            expect(removeOrderLine!.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(removeOrderLine!.lines.map((i) => i.productVariant.id)).toEqual(['T_3']);
         });
 
         it(
@@ -595,7 +595,7 @@ describe('Shop orders', () => {
                 quantity: 3,
             });
             expect(addItemToOrder!.lines.length).toBe(2);
-            expect(addItemToOrder!.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(addItemToOrder!.lines.map((i) => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const { removeOrderLine } = await shopClient.query<
                 RemoveItemFromOrder.Mutation,
@@ -604,7 +604,7 @@ describe('Shop orders', () => {
                 orderLineId: firstOrderLineId,
             });
             expect(removeOrderLine!.lines.length).toBe(1);
-            expect(removeOrderLine!.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(removeOrderLine!.lines.map((i) => i.productVariant.id)).toEqual(['T_3']);
         });
 
         it('nextOrderStates returns next valid states', async () => {

+ 5 - 1
packages/core/src/api/api-internal-modules.ts

@@ -39,7 +39,10 @@ import {
 import { CustomerGroupEntityResolver } from './resolvers/entity/customer-group-entity.resolver';
 import { FacetEntityResolver } from './resolvers/entity/facet-entity.resolver';
 import { FacetValueEntityResolver } from './resolvers/entity/facet-value-entity.resolver';
-import { FulfillmentEntityResolver } from './resolvers/entity/fulfillment-entity.resolver';
+import {
+    FulfillmentAdminEntityResolver,
+    FulfillmentEntityResolver,
+} from './resolvers/entity/fulfillment-entity.resolver';
 import { OrderAdminEntityResolver, OrderEntityResolver } from './resolvers/entity/order-entity.resolver';
 import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.resolver';
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
@@ -120,6 +123,7 @@ export const entityResolvers = [
 export const adminEntityResolvers = [
     CustomerAdminEntityResolver,
     OrderAdminEntityResolver,
+    FulfillmentAdminEntityResolver,
     ProductVariantAdminEntityResolver,
     ProductAdminEntityResolver,
 ];

+ 4 - 4
packages/core/src/api/common/graphql-value-transformer.ts

@@ -79,7 +79,7 @@ export class GraphqlValueTransformer {
         typeTree.operation = rootNode;
         let currentNode = rootNode;
         const visitor: Visitor<ASTKindToNode> = {
-            enter: node => {
+            enter: (node) => {
                 const type = typeInfo.getType();
                 const fieldDef = typeInfo.getFieldDef();
                 if (node.kind === 'Field') {
@@ -108,7 +108,7 @@ export class GraphqlValueTransformer {
                     typeTree.fragments[node.name.value] = rootFragmentNode;
                 }
             },
-            leave: node => {
+            leave: (node) => {
                 if (node.kind === 'Field') {
                     if (!this.isTypeTree(currentNode.parent)) {
                         currentNode = currentNode.parent;
@@ -146,7 +146,7 @@ export class GraphqlValueTransformer {
         typeTree.operation = rootNode;
         let currentNode = rootNode;
         const visitor: Visitor<ASTKindToNode> = {
-            enter: node => {
+            enter: (node) => {
                 if (node.kind === 'Argument') {
                     const type = typeInfo.getType();
                     const args = typeInfo.getArgument();
@@ -169,7 +169,7 @@ export class GraphqlValueTransformer {
                     }
                 }
             },
-            leave: node => {
+            leave: (node) => {
                 if (node.kind === 'Argument') {
                     if (!this.isTypeTree(currentNode.parent)) {
                         currentNode = currentNode.parent;

+ 7 - 7
packages/core/src/api/config/graphql-custom-fields.ts

@@ -32,15 +32,15 @@ export function addGraphQLCustomFields(
 
     for (const entityName of Object.keys(customFieldConfig)) {
         const customEntityFields = (customFieldConfig[entityName as keyof CustomFields] || []).filter(
-            config => {
+            (config) => {
                 return !config.internal && (publicOnly === true ? config.public !== false : true);
             },
         );
 
-        const localeStringFields = customEntityFields.filter(field => field.type === 'localeString');
-        const nonLocaleStringFields = customEntityFields.filter(field => field.type !== 'localeString');
-        const writeableLocaleStringFields = localeStringFields.filter(field => !field.readonly);
-        const writeableNonLocaleStringFields = nonLocaleStringFields.filter(field => !field.readonly);
+        const localeStringFields = customEntityFields.filter((field) => field.type === 'localeString');
+        const nonLocaleStringFields = customEntityFields.filter((field) => field.type !== 'localeString');
+        const writeableLocaleStringFields = localeStringFields.filter((field) => !field.readonly);
+        const writeableNonLocaleStringFields = nonLocaleStringFields.filter((field) => !field.readonly);
 
         if (schema.getType(entityName)) {
             if (customEntityFields.length) {
@@ -207,7 +207,7 @@ export function addRegisterCustomerCustomFieldsInput(
     if (!customerCustomFields || customerCustomFields.length === 0) {
         return schema;
     }
-    const publicWritableCustomFields = customerCustomFields.filter(fieldDef => {
+    const publicWritableCustomFields = customerCustomFields.filter((fieldDef) => {
         return fieldDef.public !== false && !fieldDef.readonly && !fieldDef.internal;
     });
     if (publicWritableCustomFields.length < 1) {
@@ -282,7 +282,7 @@ type GraphQLFieldType = 'DateTime' | 'String' | 'Int' | 'Float' | 'Boolean' | 'I
  */
 function mapToFields(fieldDefs: CustomFieldConfig[], typeFn: (fieldType: CustomFieldType) => string): string {
     return fieldDefs
-        .map(field => {
+        .map((field) => {
             const primitiveType = typeFn(field.type);
             const finalType = field.list ? `[${primitiveType}!]` : primitiveType;
             return `${field.name}: ${finalType}`;

+ 1 - 1
packages/core/src/api/middleware/asset-interceptor-plugin.ts

@@ -29,7 +29,7 @@ export class AssetInterceptorPlugin implements ApolloServerPlugin {
 
     requestDidStart(): GraphQLRequestListener {
         return {
-            willSendResponse: requestContext => {
+            willSendResponse: (requestContext) => {
                 const { document } = requestContext;
                 if (document) {
                     const data = requestContext.response.data;

+ 5 - 5
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -41,8 +41,8 @@ export class GlobalSettingsResolver {
         const exposedCustomFieldConfig: CustomFields = {};
         for (const [entityType, customFields] of Object.entries(this.configService.customFields)) {
             exposedCustomFieldConfig[entityType as keyof CustomFields] = customFields
-                .filter(c => !c.internal)
-                .map(c => ({ ...c, list: !!c.list as any }));
+                .filter((c) => !c.internal)
+                .map((c) => ({ ...c, list: !!c.list as any }));
         }
         return {
             customFieldConfig: exposedCustomFieldConfig,
@@ -60,12 +60,12 @@ export class GlobalSettingsResolver {
         if (availableLanguages) {
             const channels = await this.channelService.findAll();
             const unavailableDefaults = channels.filter(
-                c => !availableLanguages.includes(c.defaultLanguageCode),
+                (c) => !availableLanguages.includes(c.defaultLanguageCode),
             );
             if (unavailableDefaults.length) {
                 throw new UserInputError('error.cannot-set-default-language-as-unavailable', {
-                    language: unavailableDefaults.map(c => c.defaultLanguageCode).join(', '),
-                    channelCode: unavailableDefaults.map(c => c.code).join(', '),
+                    language: unavailableDefaults.map((c) => c.defaultLanguageCode).join(', '),
+                    channelCode: unavailableDefaults.map((c) => c.code).join(', '),
                 });
             }
         }

+ 11 - 0
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -8,6 +8,7 @@ import {
     MutationSetOrderCustomFieldsArgs,
     MutationSettlePaymentArgs,
     MutationSettleRefundArgs,
+    MutationTransitionFulfillmentToStateArgs,
     MutationTransitionOrderToStateArgs,
     MutationUpdateOrderNoteArgs,
     Permission,
@@ -17,6 +18,7 @@ import {
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Order } from '../../../entity/order/order.entity';
+import { FulfillmentState } from '../../../service/helpers/fulfillment-state-machine/fulfillment-state';
 import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
 import { OrderService } from '../../../service/services/order.service';
 import { ShippingMethodService } from '../../../service/services/shipping-method.service';
@@ -102,4 +104,13 @@ export class OrderResolver {
     ) {
         return this.orderService.transitionToState(ctx, args.id, args.state as OrderState);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async transitionFulfillmentToState(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationTransitionFulfillmentToStateArgs,
+    ) {
+        return this.orderService.transitionFulfillmentToState(ctx, args.id, args.state as FulfillmentState);
+    }
 }

+ 12 - 3
packages/core/src/api/resolvers/entity/fulfillment-entity.resolver.ts

@@ -1,14 +1,23 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
-import { OrderService } from '../../../service/services/order.service';
+import { FulfillmentService } from '../../../service/services/fulfillment.service';
 
 @Resolver('Fulfillment')
 export class FulfillmentEntityResolver {
-    constructor(private orderService: OrderService) {}
+    constructor(private fulfillmentService: FulfillmentService) {}
 
     @ResolveField()
     async orderItems(@Parent() fulfillment: Fulfillment) {
-        return this.orderService.getFulfillmentOrderItems(fulfillment.id);
+        return this.fulfillmentService.getOrderItemsByFulfillmentId(fulfillment.id);
+    }
+}
+@Resolver('Fulfillment')
+export class FulfillmentAdminEntityResolver {
+    constructor(private fulfillmentService: FulfillmentService) {}
+
+    @ResolveField()
+    async nextStates(@Parent() fulfillment: Fulfillment) {
+        return this.fulfillmentService.getNextStates(fulfillment);
     }
 }

+ 2 - 2
packages/core/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -59,7 +59,7 @@ export class ShopCustomerResolver {
     ): Promise<Address> {
         const customer = await this.getCustomerForOwner(ctx);
         const customerAddresses = await this.customerService.findAddressesByCustomerId(ctx, customer.id);
-        if (!customerAddresses.find(address => idsAreEqual(address.id, args.input.id))) {
+        if (!customerAddresses.find((address) => idsAreEqual(address.id, args.input.id))) {
             throw new ForbiddenError();
         }
         return this.customerService.updateAddress(ctx, args.input);
@@ -73,7 +73,7 @@ export class ShopCustomerResolver {
     ): Promise<boolean> {
         const customer = await this.getCustomerForOwner(ctx);
         const customerAddresses = await this.customerService.findAddressesByCustomerId(ctx, customer.id);
-        if (!customerAddresses.find(address => idsAreEqual(address.id, args.id))) {
+        if (!customerAddresses.find((address) => idsAreEqual(address.id, args.id))) {
             throw new ForbiddenError();
         }
         return this.customerService.deleteAddress(ctx, args.id);

+ 1 - 1
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -57,7 +57,7 @@ export class ShopOrderResolver {
                 skip: 0,
                 take: 99999,
             })
-            .then(data => data.items);
+            .then((data) => data.items);
     }
 
     @Query()

+ 3 - 0
packages/core/src/api/schema/admin-api/fulfillment.api.graphql

@@ -0,0 +1,3 @@
+type Fulfillment {
+    nextStates: [String!]!
+}

+ 1 - 0
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -13,6 +13,7 @@ type Mutation {
     updateOrderNote(input: UpdateOrderNoteInput!): HistoryEntry!
     deleteOrderNote(id: ID!): DeletionResponse!
     transitionOrderToState(id: ID!, state: String!): Order
+    transitionFulfillmentToState(id: ID!, state: String!): Fulfillment!
     setOrderCustomFields(input: UpdateOrderInput!): Order
 }
 

+ 2 - 1
packages/core/src/api/schema/type/history-entry.type.graphql

@@ -25,9 +25,10 @@ enum HistoryEntryType {
     CUSTOMER_NOTE
     ORDER_STATE_TRANSITION
     ORDER_PAYMENT_TRANSITION
-    ORDER_FULLFILLMENT
+    ORDER_FULFILLMENT
     ORDER_CANCELLATION
     ORDER_REFUND_TRANSITION
+    ORDER_FULFILLMENT_TRANSITION
     ORDER_NOTE
     ORDER_COUPON_APPLIED
     ORDER_COUPON_REMOVED

+ 1 - 0
packages/core/src/api/schema/type/order.type.graphql

@@ -120,6 +120,7 @@ type Fulfillment implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     orderItems: [OrderItem!]!
+    state: String!
     method: String!
     trackingCode: String
 }

+ 8 - 8
packages/core/src/bootstrap.ts

@@ -211,7 +211,7 @@ export async function getAllEntities(userConfig: Partial<VendureConfig>): Promis
     // Check to ensure that no plugins are defining entities with names
     // which conflict with existing entities.
     for (const pluginEntity of pluginEntities) {
-        if (allEntities.find(e => e.name === pluginEntity.name)) {
+        if (allEntities.find((e) => e.name === pluginEntity.name)) {
             throw new InternalServerError(`error.entity-name-conflict`, { entityName: pluginEntity.name });
         } else {
             allEntities.push(pluginEntity);
@@ -236,7 +236,7 @@ function setExposedHeaders(config: Readonly<RuntimeVendureConfig>) {
             } else if (typeof exposedHeaders === 'string') {
                 exposedHeadersWithAuthKey = exposedHeaders
                     .split(',')
-                    .map(x => x.trim())
+                    .map((x) => x.trim())
                     .concat(authTokenHeaderKey);
             } else {
                 exposedHeadersWithAuthKey = exposedHeaders.concat(authTokenHeaderKey);
@@ -316,18 +316,18 @@ function logWelcomeMessage(config: RuntimeVendureConfig) {
     apiCliGreetings.push(...getProxyMiddlewareCliGreetings(config));
     const columnarGreetings = arrangeCliGreetingsInColumns(apiCliGreetings);
     const title = `Vendure server (v${version}) now running on port ${port}`;
-    const maxLineLength = Math.max(title.length, ...columnarGreetings.map(l => l.length));
+    const maxLineLength = Math.max(title.length, ...columnarGreetings.map((l) => l.length));
     const titlePadLength = title.length < maxLineLength ? Math.floor((maxLineLength - title.length) / 2) : 0;
     Logger.info(`=`.repeat(maxLineLength));
     Logger.info(title.padStart(title.length + titlePadLength));
     Logger.info('-'.repeat(maxLineLength).padStart(titlePadLength));
-    columnarGreetings.forEach(line => Logger.info(line));
+    columnarGreetings.forEach((line) => Logger.info(line));
     Logger.info(`=`.repeat(maxLineLength));
 }
 
 function arrangeCliGreetingsInColumns(lines: Array<[string, string]>): string[] {
-    const columnWidth = Math.max(...lines.map(l => l[0].length)) + 2;
-    return lines.map(l => `${(l[0] + ':').padEnd(columnWidth)}${l[1]}`);
+    const columnWidth = Math.max(...lines.map((l) => l[0].length)) + 2;
+    return lines.map((l) => `${(l[0] + ':').padEnd(columnWidth)}${l[1]}`);
 }
 
 /**
@@ -379,7 +379,7 @@ async function validateDbTablesForWorker(worker: INestMicroservice) {
             Logger.verbose(
                 `Table structure could not be verified, trying again after ${pollIntervalMs}ms (attempt ${attempts} of ${maxAttempts})`,
             );
-            await new Promise(resolve1 => setTimeout(resolve1, pollIntervalMs));
+            await new Promise((resolve1) => setTimeout(resolve1, pollIntervalMs));
         }
         reject(`Could not validate DB table structure. Aborting bootstrap.`);
     });
@@ -396,7 +396,7 @@ function checkForDeprecatedOptions(config: Partial<VendureConfig>) {
         'middleware',
         'apolloServerPlugins',
     ];
-    const deprecatedOptionsUsed = deprecatedApiOptions.filter(option => config.hasOwnProperty(option));
+    const deprecatedOptionsUsed = deprecatedApiOptions.filter((option) => config.hasOwnProperty(option));
     if (deprecatedOptionsUsed.length) {
         throw new Error(
             `The following VendureConfig options are deprecated: ${deprecatedOptionsUsed.join(', ')}\n` +

+ 3 - 3
packages/core/src/common/configurable-operation.ts

@@ -358,9 +358,9 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
 }
 
 function localizeString(stringArray: LocalizedStringArray, languageCode: LanguageCode): string {
-    let match = stringArray.find(x => x.languageCode === languageCode);
+    let match = stringArray.find((x) => x.languageCode === languageCode);
     if (!match) {
-        match = stringArray.find(x => x.languageCode === DEFAULT_LANGUAGE_CODE);
+        match = stringArray.find((x) => x.languageCode === DEFAULT_LANGUAGE_CODE);
     }
     if (!match) {
         match = stringArray[0];
@@ -375,7 +375,7 @@ function coerceValueToType<T extends ConfigArgs>(
 ): ConfigArgValues<T>[keyof T] {
     if (isList) {
         try {
-            return (JSON.parse(value) as string[]).map(v => coerceValueToType(v, type, false)) as any;
+            return (JSON.parse(value) as string[]).map((v) => coerceValueToType(v, type, false)) as any;
         } catch (err) {
             throw new InternalServerError(err.message);
         }

+ 10 - 4
packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts

@@ -27,12 +27,18 @@ describe('FSM validateTransitionDefinition()', () => {
                 to: ['PaymentSettled', 'Cancelled'],
             },
             PaymentSettled: {
-                to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
+                to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled'],
             },
-            PartiallyFulfilled: {
-                to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
+            PartiallyShipped: {
+                to: ['Shipped', 'PartiallyDelivered', 'Cancelled'],
             },
-            Fulfilled: {
+            Shipped: {
+                to: ['PartiallyDelivered', 'Delivered', 'Cancelled'],
+            },
+            PartiallyDelivered: {
+                to: ['Delivered', 'Cancelled'],
+            },
+            Delivered: {
                 to: ['Cancelled'],
             },
             Cancelled: {

+ 1 - 3
packages/core/src/config/default-config.ts

@@ -60,9 +60,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         tokenMethod: 'cookie',
         sessionSecret: '',
         cookieOptions: {
-            secret: Math.random()
-                .toString(36)
-                .substr(3),
+            secret: Math.random().toString(36).substr(3),
             httpOnly: true,
         },
         authTokenHeaderKey: DEFAULT_AUTH_TOKEN_HEADER_KEY,

+ 27 - 0
packages/core/src/config/fulfillment/custom-fulfillment-process.ts

@@ -0,0 +1,27 @@
+import {
+    OnTransitionEndFn,
+    OnTransitionErrorFn,
+    OnTransitionStartFn,
+    Transitions,
+} from '../../common/finite-state-machine/types';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import {
+    FulfillmentState,
+    FulfillmentTransitionData,
+} from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
+
+/**
+ * @description
+ * Used to define extensions to or modifications of the default fulfillment process.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @docsCategory fulfillments
+ */
+export interface CustomFulfillmentProcess<State extends string> extends InjectableStrategy {
+    transitions?: Transitions<State, State | FulfillmentState> &
+        Partial<Transitions<FulfillmentState | State>>;
+    onTransitionStart?: OnTransitionStartFn<State | FulfillmentState, FulfillmentTransitionData>;
+    onTransitionEnd?: OnTransitionEndFn<State | FulfillmentState, FulfillmentTransitionData>;
+    onTransitionError?: OnTransitionErrorFn<State | FulfillmentState>;
+}

+ 1 - 0
packages/core/src/config/index.ts

@@ -30,4 +30,5 @@ export * from './shipping-method/default-shipping-calculator';
 export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-eligibility-checker';
+export * from './fulfillment/custom-fulfillment-process';
 export * from './vendure-config';

+ 1 - 1
packages/core/src/config/promotion/actions/product-discount-action.ts

@@ -33,5 +33,5 @@ export const productsPercentageDiscount = new PromotionItemAction({
 });
 
 function lineContainsIds(ids: ID[], line: OrderLine): boolean {
-    return !!ids.find(id => idsAreEqual(id, line.productVariant.id));
+    return !!ids.find((id) => idsAreEqual(id, line.productVariant.id));
 }

+ 1 - 1
packages/core/src/config/promotion/conditions/contains-products-condition.ts

@@ -33,5 +33,5 @@ export const containsProducts = new PromotionCondition({
 });
 
 function lineContainsIds(ids: ID[], line: OrderLine): boolean {
-    return !!ids.find(id => idsAreEqual(id, line.productVariant.id));
+    return !!ids.find((id) => idsAreEqual(id, line.productVariant.id));
 }

+ 2 - 2
packages/core/src/config/promotion/conditions/customer-group-condition.ts

@@ -34,9 +34,9 @@ export const customerGroup = new PromotionCondition({
         let groupIds = cache.get(customerId);
         if (!groupIds) {
             const groups = await customerService.getCustomerGroups(customerId);
-            groupIds = groups.map(g => g.id);
+            groupIds = groups.map((g) => g.id);
             cache.set(customerId, groupIds);
         }
-        return !!groupIds.find(id => idsAreEqual(id, args.customerGroupId));
+        return !!groupIds.find((id) => idsAreEqual(id, args.customerGroupId));
     },
 });

+ 8 - 0
packages/core/src/config/vendure-config.ts

@@ -19,6 +19,7 @@ import { AuthenticationStrategy } from './auth/authentication-strategy';
 import { CollectionFilter } from './collection/collection-filter';
 import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
+import { CustomFulfillmentProcess } from './fulfillment/custom-fulfillment-process';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
 import { VendureLogger } from './logger/vendure-logger';
 import { CustomOrderProcess } from './order/custom-order-process';
@@ -511,6 +512,13 @@ export interface ShippingOptions {
      * An array of available ShippingCalculators for use in configuring ShippingMethods
      */
     shippingCalculators?: Array<ShippingCalculator<any>>;
+
+    /**
+     * @description
+     * Allows the definition of custom states and transition logic for the fulfillment process state machine.
+     * Takes an array of objects implementing the {@link CustomFulfillmentProcess} interface.
+     */
+    customFulfillmentProcess?: Array<CustomFulfillmentProcess<any>>;
 }
 
 /**

+ 10 - 10
packages/core/src/data-import/providers/importer/importer.ts

@@ -55,8 +55,8 @@ export class Importer {
     ): Observable<ImportProgress> {
         let bar: ProgressBar | undefined;
 
-        return new Observable(subscriber => {
-            const p = this.doParseAndImport(input, ctxOrLanguageCode, progress => {
+        return new Observable((subscriber) => {
+            const p = this.doParseAndImport(input, ctxOrLanguageCode, (progress) => {
                 if (reportProgress) {
                     if (!bar) {
                         bar = new ProgressBar('  importing [:bar] :percent :etas  Importing: :prodName', {
@@ -69,7 +69,7 @@ export class Importer {
                     bar.tick({ prodName: progress.currentProduct });
                 }
                 subscriber.next(progress);
-            }).then(value => {
+            }).then((value) => {
                 subscriber.next({ ...value, currentProduct: 'Complete' });
                 subscriber.complete();
             });
@@ -85,7 +85,7 @@ export class Importer {
         const parsed = await this.importParser.parseProducts(input);
         if (parsed && parsed.results.length) {
             try {
-                const importErrors = await this.importProducts(ctx, parsed.results, progess => {
+                const importErrors = await this.importProducts(ctx, parsed.results, (progess) => {
                     onProgress({
                         ...progess,
                         processed: parsed.processed,
@@ -150,7 +150,7 @@ export class Importer {
             }
             const createdProductId = await this.fastImporter.createProduct({
                 featuredAssetId: productAssets.length ? productAssets[0].id : undefined,
-                assetIds: productAssets.map(a => a.id),
+                assetIds: productAssets.map((a) => a.id),
                 facetValueIds: await this.getFacetValueIds(product.facets, languageCode),
                 translations: [
                     {
@@ -168,7 +168,7 @@ export class Importer {
                 const code = normalizeString(`${product.name}-${optionGroup.name}`, '-');
                 const groupId = await this.fastImporter.createProductOptionGroup({
                     code,
-                    options: optionGroup.values.map(name => ({} as any)),
+                    options: optionGroup.values.map((name) => ({} as any)),
                     translations: [
                         {
                             languageCode,
@@ -206,12 +206,12 @@ export class Importer {
                     productId: createdProductId,
                     facetValueIds,
                     featuredAssetId: variantAssets.length ? variantAssets[0].id : undefined,
-                    assetIds: variantAssets.map(a => a.id),
+                    assetIds: variantAssets.map((a) => a.id),
                     sku: variant.sku,
                     taxCategoryId: this.getMatchingTaxCategoryId(variant.taxCategory, taxCategories),
                     stockOnHand: variant.stockOnHand,
                     trackInventory: variant.trackInventory,
-                    optionIds: variant.optionValues.map(v => optionsMap[v]),
+                    optionIds: variant.optionValues.map((v) => optionsMap[v]),
                     translations: [
                         {
                             languageCode,
@@ -267,7 +267,7 @@ export class Importer {
             if (cachedFacetValue) {
                 facetValueEntity = cachedFacetValue;
             } else {
-                const existing = facetEntity.values.find(v => v.name === valueName);
+                const existing = facetEntity.values.find((v) => v.name === valueName);
                 if (existing) {
                     facetValueEntity = existing;
                 } else {
@@ -293,7 +293,7 @@ export class Importer {
             return this.taxCategoryMatches[name];
         }
         const regex = new RegExp(name, 'i');
-        const found = taxCategories.find(tc => !!tc.name.match(regex));
+        const found = taxCategories.find((tc) => !!tc.name.match(regex));
         const match = found ? found : taxCategories[0];
         this.taxCategoryMatches[name] = match.id;
         return match.id;

+ 5 - 5
packages/core/src/data-import/providers/populator/populator.ts

@@ -72,9 +72,9 @@ export class Populator {
                 ],
                 isPrivate: collectionDef.private || false,
                 parentId,
-                assetIds: assets.map(a => a.id.toString()),
+                assetIds: assets.map((a) => a.id.toString()),
                 featuredAssetId: assets.length ? assets[0].id.toString() : undefined,
-                filters: (collectionDef.filters || []).map(filter =>
+                filters: (collectionDef.filters || []).map((filter) =>
                     this.processFilterDefinition(filter, allFacetValues),
                 ),
             });
@@ -82,7 +82,7 @@ export class Populator {
         }
         // Wait for the created collection operations to complete before running
         // the reindex of the search index.
-        await new Promise(resolve => setTimeout(resolve, 50));
+        await new Promise((resolve) => setTimeout(resolve, 50));
         await this.searchService.reindex(ctx);
     }
 
@@ -93,9 +93,9 @@ export class Populator {
         switch (filter.code) {
             case 'facet-value-filter':
                 const facetValueIds = filter.args.facetValueNames
-                    .map(name => allFacetValues.find(fv => fv.name === name))
+                    .map((name) => allFacetValues.find((fv) => fv.name === name))
                     .filter(notNullOrUndefined)
-                    .map(fv => fv.id);
+                    .map((fv) => fv.id);
                 return {
                     code: filter.code,
                     arguments: [

+ 4 - 1
packages/core/src/entity/fulfillment/fulfillment.entity.ts

@@ -1,6 +1,7 @@
 import { Column, Entity, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../common/lib/shared-types';
+import { FulfillmentState } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
 import { VendureEntity } from '../base/base.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 
@@ -17,12 +18,14 @@ export class Fulfillment extends VendureEntity {
         super(input);
     }
 
+    @Column('varchar') state: FulfillmentState;
+
     @Column({ default: '' })
     trackingCode: string;
 
     @Column()
     method: string;
 
-    @OneToMany(type => OrderItem, orderItem => orderItem.fulfillment)
+    @OneToMany((type) => OrderItem, (orderItem) => orderItem.fulfillment)
     orderItems: OrderItem[];
 }

+ 13 - 16
packages/core/src/entity/order/order.entity.ts

@@ -3,9 +3,11 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { ChannelAware } from '../../common/types/common-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { OrderState } from '../../service/helpers/order-state-machine/order-state';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomOrderFields } from '../custom-entity-fields';
 import { Customer } from '../customer/customer.entity';
 import { EntityId } from '../entity-id.decorator';
@@ -14,8 +16,6 @@ import { OrderLine } from '../order-line/order-line.entity';
 import { Payment } from '../payment/payment.entity';
 import { Promotion } from '../promotion/promotion.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
-import { ChannelAware } from '../../common/types/common-types';
-import { Channel } from '../channel/channel.entity';
 
 /**
  * @description
@@ -44,16 +44,16 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @Column({ nullable: true })
     orderPlacedAt?: Date;
 
-    @ManyToOne(type => Customer)
+    @ManyToOne((type) => Customer)
     customer?: Customer;
 
-    @OneToMany(type => OrderLine, line => line.order)
+    @OneToMany((type) => OrderLine, (line) => line.order)
     lines: OrderLine[];
 
     @Column('simple-array')
     couponCodes: string[];
 
-    @ManyToMany(type => Promotion)
+    @ManyToMany((type) => Promotion)
     @JoinTable()
     promotions: Promotion[];
 
@@ -63,7 +63,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
 
     @Column('simple-json') billingAddress: OrderAddress;
 
-    @OneToMany(type => Payment, payment => payment.order)
+    @OneToMany((type) => Payment, (payment) => payment.order)
     payments: Payment[];
 
     @Column('varchar')
@@ -81,7 +81,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @EntityId({ nullable: true })
     shippingMethodId: ID | null;
 
-    @ManyToOne(type => ShippingMethod)
+    @ManyToOne((type) => ShippingMethod)
     shippingMethod: ShippingMethod | null;
 
     @Column({ default: 0 })
@@ -90,7 +90,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @Column({ default: 0 })
     shippingWithTax: number;
 
-    @Column(type => CustomOrderFields)
+    @Column((type) => CustomOrderFields)
     customFields: CustomOrderFields;
 
     @EntityId({ nullable: true })
@@ -117,7 +117,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
 
     get promotionAdjustmentsTotal(): number {
         return this.adjustments
-            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .filter((a) => a.type === AdjustmentType.PROMOTION)
             .reduce((total, a) => total + a.amount, 0);
     }
 
@@ -129,16 +129,13 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         if (!type) {
             this.pendingAdjustments = [];
         } else {
-            this.pendingAdjustments = this.pendingAdjustments.filter(a => a.type !== type);
+            this.pendingAdjustments = this.pendingAdjustments.filter((a) => a.type !== type);
         }
     }
 
     getOrderItems(): OrderItem[] {
-        return this.lines.reduce(
-            (items, line) => {
-                return [...items, ...line.items];
-            },
-            [] as OrderItem[],
-        );
+        return this.lines.reduce((items, line) => {
+            return [...items, ...line.items];
+        }, [] as OrderItem[]);
     }
 }

+ 1 - 1
packages/core/src/entity/promotion/promotion.entity.ts

@@ -75,7 +75,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
 
     @Column() enabled: boolean;
 
-    @ManyToMany(type => Channel)
+    @ManyToMany((type) => Channel)
     @JoinTable()
     channels: Channel[];
 

+ 22 - 0
packages/core/src/event-bus/events/fulfillment-state-transition-event.ts

@@ -0,0 +1,22 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
+import { FulfillmentState } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever an {@link Fulfillment} transitions from one {@link FulfillmentState} to another.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class FulfillmentStateTransitionEvent extends VendureEvent {
+    constructor(
+        public fromState: FulfillmentState,
+        public toState: FulfillmentState,
+        public ctx: RequestContext,
+        public fulfillment: Fulfillment,
+    ) {
+        super();
+    }
+}

+ 5 - 2
packages/core/src/i18n/messages/en.json

@@ -15,11 +15,14 @@
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
+    "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "cannot-transition-unless-all-cancelled": "Cannot transition Order to the \"Cancelled\" state unless all OrderItems are cancelled",
-    "cannot-transition-unless-all-order-items-fulfilled": "Cannot transition Order to the \"Fulfilled\" state unless all OrderItems are fulfilled",
-    "cannot-transition-unless-some-order-items-fulfilled": "Cannot transition Order to the \"PartiallyFulfilled\" state unless some OrderItems are fulfilled",
+    "cannot-transition-unless-all-order-items-delivered": "Cannot transition Order to the \"Delivered\" state unless all OrderItems are delivered",
+    "cannot-transition-unless-some-order-items-delivered": "Cannot transition Order to the \"PartiallyDelivered\" state unless some OrderItems are delivered",
+    "cannot-transition-unless-some-order-items-shipped": "Cannot transition Order to the \"PartiallyShipped\" state unless some OrderItems are shipped",
+    "cannot-transition-unless-all-order-items-shipped": "Cannot transition Order to the \"Shipped\" state unless all OrderItems are shipped",
     "cannot-transition-without-authorized-payments": "Cannot transition Order to the \"PaymentAuthorized\" state when the total is not covered by authorized Payments",
     "cannot-transition-without-settled-payments": "Cannot transition Order to the \"PaymentSettled\" state when the total is not covered by settled Payments",
     "cannot-use-registered-email-address-for-guest-order":  "Cannot use a registered email address for a guest order. Please log in first",

+ 5 - 3
packages/core/src/i18n/messages/pt_BR.json

@@ -17,11 +17,13 @@
     "cannot-transition-refund-from-to": "Não é possível fazer a transição do reembolso de \"{fromState}\" para \"{toState}\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Não é possível fazer a transição do pedido para o estado \"ArrangingShipping\" quando estiver vazio",
     "cannot-transition-to-payment-without-customer": "Não é possível fazer a transição do pedido para o estado \"ArrangingPayment\" sem detalhes do cliente",
-    "cannot-transition-unless-all-cancelled": "Não é possível fazer a transição do pedido para o estado \"Cancelled\", a menos que todos os itens de ordem sejam cancelados",
-    "cannot-transition-unless-all-order-items-fulfilled": "Não é possível fazer a transição do pedido para o estado \"Fulfilled\", a menos que todos os itens de ordem sejam cumpridos",
-    "cannot-transition-unless-some-order-items-fulfilled": "Não é possível fazer a transição do pedido para o estado \"PartiallyFulfilled\", a menos que alguns itens de ordem sejam atendidos",
+    "cannot-transition-unless-all-cancelled": "Não é possível fazer a transição do pedido para o estado \"Cancelled\", a menos que todos os itens do pedido sejam cancelados",
+    "cannot-transition-unless-all-order-items-delivered": "Não é possível fazer a transição do pedido para o estado \"Delivered\", a menos que todos os itens do pedido sejam entregues",
+    "cannot-transition-unless-some-order-items-delivered": "Não é possível fazer a transição do pedido para o estado \"PartiallyDelivered\", a menos que alguns itens do pedido sejam entregues",
     "cannot-transition-without-authorized-payments": "Não é possível fazer a transição do pedido para o estado \"PaymentAuthorized\" quando o total não estiver coberto por pagamentos autorizados",
     "cannot-transition-without-settled-payments": "Não é possível fazer a transição do pedido para o estado \"PaymentSettled\" quando o total não estiver coberto por pagamentos liquidados",
+    "cannot-transition-unless-some-order-items-shipped": "Não é possível fazer a transição do pedido para o estado \"PartiallyShipped\", a menos que alguns itens do pedido sejam enviados",
+    "cannot-transition-unless-all-order-items-shipped": "Não é possível fazer a transição do pedido para o estado \"Shipped\", a menos que todos os itens do pedido sejam enviados",
     "cannot-use-registered-email-address-for-guest-order": "Não é possível usar um endereço de e-mail registrado para um pedido de hóspede. Faça login primeiro",
     "channel-not-found": "Nenhum canal com o token \"{token}\" existe",
     "collection-id-or-slug-must-be-provided": "O ID da coleção ou a lesma devem ser fornecidos",

+ 3 - 3
packages/core/src/job-queue/job-queue.service.ts

@@ -73,7 +73,7 @@ export class JobQueueService implements OnApplicationBootstrap, OnModuleDestroy
                     `jobQueueOptions.pollInterval is set to ${pollInterval}ms. It is not receommended to set this lower than 100ms`,
                 );
             }
-            await new Promise(resolve => setTimeout(resolve, 1000));
+            await new Promise((resolve) => setTimeout(resolve, 1000));
             this.hasInitialized = true;
             for (const queue of this.queues) {
                 if (!queue.started) {
@@ -85,7 +85,7 @@ export class JobQueueService implements OnApplicationBootstrap, OnModuleDestroy
 
     /** @internal */
     onModuleDestroy() {
-        return Promise.all(this.queues.map(q => q.destroy()));
+        return Promise.all(this.queues.map((q) => q.destroy()));
     }
 
     /**
@@ -135,7 +135,7 @@ export class JobQueueService implements OnApplicationBootstrap, OnModuleDestroy
      * registered JobQueue.
      */
     getJobQueues(): GraphQlJobQueue[] {
-        return this.queues.map(queue => ({
+        return this.queues.map((queue) => ({
             name: queue.name,
             running: queue.started,
         }));

+ 12 - 12
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -59,7 +59,7 @@ export class IndexerController {
     @MessagePattern(ReindexMessage.pattern)
     reindex({ ctx: rawContext }: ReindexMessage['data']): Observable<ReindexMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
-        return asyncObservable(async observer => {
+        return asyncObservable(async (observer) => {
             const timeStart = Date.now();
             const qb = this.getSearchIndexQueryBuilder(ctx.channelId);
             const count = await qb.getCount();
@@ -103,7 +103,7 @@ export class IndexerController {
     }: UpdateVariantsByIdMessage['data']): Observable<UpdateVariantsByIdMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
 
-        return asyncObservable(async observer => {
+        return asyncObservable(async (observer) => {
             const timeStart = Date.now();
             if (ids.length) {
                 const batches = Math.ceil(ids.length / BATCH_SIZE);
@@ -169,7 +169,7 @@ export class IndexerController {
                 await this.removeSearchIndexItems(
                     ctx.languageCode,
                     ctx.channelId,
-                    variants.map(v => v.id),
+                    variants.map((v) => v.id),
                 );
             }
             return true;
@@ -238,14 +238,14 @@ export class IndexerController {
         });
         if (product) {
             let updatedVariants = await this.connection.getRepository(ProductVariant).findByIds(
-                product.variants.map(v => v.id),
+                product.variants.map((v) => v.id),
                 {
                     relations: variantRelations,
                     where: { deletedAt: null },
                 },
             );
             if (product.enabled === false) {
-                updatedVariants.forEach(v => (v.enabled = false));
+                updatedVariants.forEach((v) => (v.enabled = false));
             }
             Logger.verbose(`Updating ${updatedVariants.length} variants`, workerLoggerCtx);
             updatedVariants = this.hydrateVariants(ctx, updatedVariants);
@@ -282,7 +282,7 @@ export class IndexerController {
             relations: ['variants'],
         });
         if (product) {
-            const removedVariantIds = product.variants.map(v => v.id);
+            const removedVariantIds = product.variants.map((v) => v.id);
             if (removedVariantIds.length) {
                 await this.removeSearchIndexItems(ctx.languageCode, channelId, removedVariantIds);
             }
@@ -309,8 +309,8 @@ export class IndexerController {
      */
     private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
         return variants
-            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
-            .map(v => translateDeep(v, ctx.languageCode, ['product', 'collections']));
+            .map((v) => this.productVariantService.applyChannelPriceAndTax(v, ctx))
+            .map((v) => translateDeep(v, ctx.languageCode, ['product', 'collections']));
     }
 
     private async saveVariants(languageCode: LanguageCode, channelId: ID, variants: ProductVariant[]) {
@@ -337,11 +337,11 @@ export class IndexerController {
                     productVariantAssetId: v.featuredAsset ? v.featuredAsset.id : null,
                     productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
                     productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-                    channelIds: v.product.channels.map(c => c.id as string),
+                    channelIds: v.product.channels.map((c) => c.id as string),
                     facetIds: this.getFacetIds(v),
                     facetValueIds: this.getFacetValueIds(v),
-                    collectionIds: v.collections.map(c => c.id.toString()),
-                    collectionSlugs: v.collections.map(c => c.slug),
+                    collectionIds: v.collections.map((c) => c.id.toString()),
+                    collectionSlugs: v.collections.map((c) => c.slug),
                 }),
         );
         await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items));
@@ -365,7 +365,7 @@ export class IndexerController {
      * Remove items from the search index
      */
     private async removeSearchIndexItems(languageCode: LanguageCode, channelId: ID, variantIds: ID[]) {
-        const compositeKeys = variantIds.map(id => ({
+        const compositeKeys = variantIds.map((id) => ({
             productVariantId: id,
             channelId,
             languageCode,

+ 5 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -81,7 +81,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .take(take)
             .skip(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then((res) => res.map((r) => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -101,7 +101,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .select('COUNT(*) as total')
             .from(`(${innerQb.getQuery()})`, 'inner')
             .setParameters(innerQb.getParameters());
-        return totalItemsQb.getRawOne().then(res => res.total);
+        return totalItemsQb.getRawOne().then((res) => res.total);
     }
 
     private applyTermAndFilters(
@@ -130,7 +130,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 'score',
             )
                 .andWhere(
-                    new Brackets(qb1 => {
+                    new Brackets((qb1) => {
                         qb1.where('to_tsvector(si.sku) @@ to_tsquery(:term)')
                             .orWhere('to_tsvector(si.productName) @@ to_tsquery(:term)')
                             .orWhere('to_tsvector(si.productVariantName) @@ to_tsquery(:term)')
@@ -141,7 +141,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         }
         if (facetValueIds?.length) {
             qb.andWhere(
-                new Brackets(qb1 => {
+                new Brackets((qb1) => {
                     for (const id of facetValueIds) {
                         const placeholder = '_' + id;
                         const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
@@ -178,7 +178,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
      */
     private createPostgresSelect(groupByProduct: boolean): string {
         return fieldsToSelect
-            .map(col => {
+            .map((col) => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;
                 if (groupByProduct && col !== 'productId') {

+ 4 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -78,7 +78,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
             .take(take)
             .skip(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then((res) => res.map((r) => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -97,7 +97,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
             .select('COUNT(*) as total')
             .from(`(${innerQb.getQuery()})`, 'inner')
             .setParameters(innerQb.getParameters());
-        return totalItemsQb.getRawOne().then(res => res.total);
+        return totalItemsQb.getRawOne().then((res) => res.total);
     }
 
     private applyTermAndFilters(
@@ -120,7 +120,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 'score',
             )
                 .andWhere(
-                    new Brackets(qb1 => {
+                    new Brackets((qb1) => {
                         qb1.where('sku LIKE :like_term')
                             .orWhere('productName LIKE :like_term')
                             .orWhere('productVariantName LIKE :like_term')
@@ -131,7 +131,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         }
         if (facetValueIds?.length) {
             qb.andWhere(
-                new Brackets(qb1 => {
+                new Brackets((qb1) => {
                     for (const id of facetValueIds) {
                         const placeholder = '_' + id;
                         const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;

+ 138 - 0
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts

@@ -0,0 +1,138 @@
+import { Injectable } from '@nestjs/common';
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { IllegalOperationError } from '../../../common/error/errors';
+import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
+import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
+import { StateMachineConfig, Transitions } from '../../../common/finite-state-machine/types';
+import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
+import { awaitPromiseOrObservable } from '../../../common/utils';
+import { ConfigService } from '../../../config/config.service';
+import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
+import { Order } from '../../../entity/order/order.entity';
+import { HistoryService } from '../../services/history.service';
+
+import {
+    FulfillmentState,
+    fulfillmentStateTransitions,
+    FulfillmentTransitionData,
+} from './fulfillment-state';
+
+@Injectable()
+export class FulfillmentStateMachine {
+    readonly config: StateMachineConfig<FulfillmentState, FulfillmentTransitionData>;
+    private readonly initialState: FulfillmentState = 'Pending';
+
+    constructor(private configService: ConfigService, private historyService: HistoryService) {
+        this.config = this.initConfig();
+    }
+
+    getInitialState(): FulfillmentState {
+        return this.initialState;
+    }
+
+    canTransition(currentState: FulfillmentState, newState: FulfillmentState): boolean {
+        return new FSM(this.config, currentState).canTransitionTo(newState);
+    }
+
+    getNextStates(fulfillment: Fulfillment): ReadonlyArray<FulfillmentState> {
+        const fsm = new FSM(this.config, fulfillment.state);
+        return fsm.getNextStates();
+    }
+
+    async transition(
+        ctx: RequestContext,
+        fulfillment: Fulfillment,
+        orders: Order[],
+        state: FulfillmentState,
+    ) {
+        const fsm = new FSM(this.config, fulfillment.state);
+        await fsm.transitionTo(state, { ctx, orders, fulfillment });
+        fulfillment.state = fsm.currentState;
+    }
+
+    /**
+     * Specific business logic to be executed on Fulfillment state transitions.
+     */
+    private async onTransitionStart(
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+        data: FulfillmentTransitionData,
+    ) {
+        /**/
+    }
+
+    /**
+     * Specific business logic to be executed after Fulfillment state transition completes.
+     */
+    private async onTransitionEnd(
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+        data: FulfillmentTransitionData,
+    ) {
+        const historyEntryPromises = data.orders.map(order =>
+            this.historyService.createHistoryEntryForOrder({
+                orderId: order.id,
+                type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                ctx: data.ctx,
+                data: {
+                    fulfillmentId: data.fulfillment.id,
+                    from: fromState,
+                    to: toState,
+                },
+            }),
+        );
+        await Promise.all(historyEntryPromises);
+    }
+
+    private initConfig(): StateMachineConfig<FulfillmentState, FulfillmentTransitionData> {
+        const customProcesses = this.configService.shippingOptions.customFulfillmentProcess ?? [];
+
+        const allTransitions = customProcesses.reduce(
+            (transitions, process) =>
+                mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
+            fulfillmentStateTransitions,
+        );
+
+        const validationResult = validateTransitionDefinition(allTransitions, 'Pending');
+
+        return {
+            transitions: allTransitions,
+            onTransitionStart: async (fromState, toState, data) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionStart === 'function') {
+                        const result = await awaitPromiseOrObservable(
+                            process.onTransitionStart(fromState, toState, data),
+                        );
+                        if (result === false || typeof result === 'string') {
+                            return result;
+                        }
+                    }
+                }
+                return this.onTransitionStart(fromState, toState, data);
+            },
+            onTransitionEnd: async (fromState, toState, data) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionEnd === 'function') {
+                        await awaitPromiseOrObservable(process.onTransitionEnd(fromState, toState, data));
+                    }
+                }
+                await this.onTransitionEnd(fromState, toState, data);
+            },
+            onError: async (fromState, toState, message) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionError === 'function') {
+                        await awaitPromiseOrObservable(
+                            process.onTransitionError(fromState, toState, message),
+                        );
+                    }
+                }
+                throw new IllegalOperationError(message || 'error.cannot-transition-fulfillment-from-to', {
+                    fromState,
+                    toState,
+                });
+            },
+        };
+    }
+}

+ 39 - 0
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts

@@ -0,0 +1,39 @@
+import { RequestContext } from '../../../api/common/request-context';
+import { Transitions } from '../../../common/finite-state-machine/types';
+import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
+import { Order } from '../../../entity/order/order.entity';
+
+/**
+ * @description
+ * These are the default states of the fulfillment process.
+ *
+ * @docsCategory fulfillment
+ */
+export type FulfillmentState = 'Pending' | 'Shipped' | 'Delivered' | 'Cancelled';
+
+export const fulfillmentStateTransitions: Transitions<FulfillmentState> = {
+    Pending: {
+        to: ['Shipped', 'Delivered', 'Cancelled'],
+    },
+    Shipped: {
+        to: ['Delivered', 'Cancelled'],
+    },
+    Delivered: {
+        to: ['Cancelled'],
+    },
+    Cancelled: {
+        to: [],
+    },
+};
+
+/**
+ * @description
+ * The data which is passed to the state transition handlers of the FulfillmentStateMachine.
+ *
+ * @docsCategory fulfillment
+ */
+export interface FulfillmentTransitionData {
+    ctx: RequestContext;
+    orders: Order[];
+    fulfillment: Fulfillment;
+}

+ 30 - 14
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
@@ -18,8 +19,10 @@ import { StockMovementService } from '../../services/stock-movement.service';
 import { getEntityOrThrow } from '../utils/get-entity-or-throw';
 import {
     orderItemsAreAllCancelled,
-    orderItemsAreFulfilled,
-    orderItemsArePartiallyFulfilled,
+    orderItemsAreDelivered,
+    orderItemsArePartiallyDelivered,
+    orderItemsArePartiallyShipped,
+    orderItemsAreShipped,
     orderTotalIsCovered,
 } from '../utils/order-utils';
 
@@ -58,6 +61,11 @@ export class OrderStateMachine {
         await fsm.transitionTo(state, { ctx, order });
         order.state = fsm.currentState;
     }
+    private async findOrderWithFulfillments(id: ID): Promise<Order> {
+        return await getEntityOrThrow(this.connection, Order, id, {
+            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+        });
+    }
 
     /**
      * Specific business logic to be executed on Order state transitions.
@@ -82,20 +90,28 @@ export class OrderStateMachine {
                 return `error.cannot-transition-unless-all-cancelled`;
             }
         }
-        if (toState === 'PartiallyFulfilled') {
-            const orderWithFulfillments = await getEntityOrThrow(this.connection, Order, data.order.id, {
-                relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-            });
-            if (!orderItemsArePartiallyFulfilled(orderWithFulfillments)) {
-                return `error.cannot-transition-unless-some-order-items-fulfilled`;
+        if (toState === 'PartiallyShipped') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.order.id);
+            if (!orderItemsArePartiallyShipped(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-some-order-items-shipped`;
+            }
+        }
+        if (toState === 'Shipped') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.order.id);
+            if (!orderItemsAreShipped(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-all-order-items-shipped`;
+            }
+        }
+        if (toState === 'PartiallyDelivered') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.order.id);
+            if (!orderItemsArePartiallyDelivered(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-some-order-items-delivered`;
             }
         }
-        if (toState === 'Fulfilled') {
-            const orderWithFulfillments = await getEntityOrThrow(this.connection, Order, data.order.id, {
-                relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-            });
-            if (!orderItemsAreFulfilled(orderWithFulfillments)) {
-                return `error.cannot-transition-unless-all-order-items-fulfilled`;
+        if (toState === 'Delivered') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.order.id);
+            if (!orderItemsAreDelivered(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-all-order-items-delivered`;
             }
         }
     }

+ 14 - 6
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -14,8 +14,10 @@ export type OrderState =
     | 'ArrangingPayment'
     | 'PaymentAuthorized'
     | 'PaymentSettled'
-    | 'PartiallyFulfilled'
-    | 'Fulfilled'
+    | 'PartiallyShipped'
+    | 'Shipped'
+    | 'PartiallyDelivered'
+    | 'Delivered'
     | 'Cancelled';
 
 export const orderStateTransitions: Transitions<OrderState> = {
@@ -29,12 +31,18 @@ export const orderStateTransitions: Transitions<OrderState> = {
         to: ['PaymentSettled', 'Cancelled'],
     },
     PaymentSettled: {
-        to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
+        to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled'],
     },
-    PartiallyFulfilled: {
-        to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
+    PartiallyShipped: {
+        to: ['Shipped', 'PartiallyDelivered', 'Cancelled'],
     },
-    Fulfilled: {
+    Shipped: {
+        to: ['PartiallyDelivered', 'Delivered', 'Cancelled'],
+    },
+    PartiallyDelivered: {
+        to: ['Delivered', 'Cancelled'],
+    },
+    Delivered: {
         to: ['Cancelled'],
     },
     Cancelled: {

+ 35 - 12
packages/core/src/service/helpers/utils/order-utils.ts

@@ -7,38 +7,61 @@ import { PaymentState } from '../payment-state-machine/payment-state';
  */
 export function orderTotalIsCovered(order: Order, state: PaymentState): boolean {
     return (
-        order.payments.filter((p) => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total
+        order.payments.filter(p => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total
     );
 }
 
 /**
- * Returns true if all (non-cancelled) OrderItems are fulfilled.
+ * Returns true if all (non-cancelled) OrderItems are delivered.
  */
-export function orderItemsAreFulfilled(order: Order) {
+export function orderItemsAreDelivered(order: Order) {
     return getOrderItems(order)
-        .filter((orderItem) => !orderItem.cancelled)
-        .every(isFulfilled);
+        .filter(orderItem => !orderItem.cancelled)
+        .every(isDelivered);
 }
 
 /**
- * Returns true if at least one, but not all (non-cancelled) OrderItems are fulfilled.
+ * Returns true if at least one, but not all (non-cancelled) OrderItems are delivered.
  */
-export function orderItemsArePartiallyFulfilled(order: Order) {
-    const nonCancelledItems = getOrderItems(order).filter((orderItem) => !orderItem.cancelled);
-    return nonCancelledItems.some(isFulfilled) && !nonCancelledItems.every(isFulfilled);
+export function orderItemsArePartiallyDelivered(order: Order) {
+    const nonCancelledItems = getNonCancelledItems(order);
+    return nonCancelledItems.some(isDelivered) && !nonCancelledItems.every(isDelivered);
+}
+
+/**
+ * Returns true if at least one, but not all (non-cancelled) OrderItems are shipped.
+ */
+export function orderItemsArePartiallyShipped(order: Order) {
+    const nonCancelledItems = getNonCancelledItems(order);
+    return nonCancelledItems.some(isShipped) && !nonCancelledItems.every(isShipped);
+}
+
+/**
+ * Returns true if all (non-cancelled) OrderItems are shipped.
+ */
+export function orderItemsAreShipped(order: Order) {
+    return getOrderItems(order)
+        .filter(orderItem => !orderItem.cancelled)
+        .every(isShipped);
 }
 
 /**
  * Returns true if all OrderItems in the order are cancelled
  */
 export function orderItemsAreAllCancelled(order: Order) {
-    return getOrderItems(order).every((orderItem) => orderItem.cancelled);
+    return getOrderItems(order).every(orderItem => orderItem.cancelled);
 }
 
 function getOrderItems(order: Order): OrderItem[] {
     return order.lines.reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[]);
 }
+function getNonCancelledItems(order: Order): OrderItem[] {
+    return getOrderItems(order).filter(orderItem => !orderItem.cancelled);
+}
 
-function isFulfilled(orderItem: OrderItem) {
-    return !!orderItem.fulfillment;
+function isDelivered(orderItem: OrderItem) {
+    return orderItem.fulfillment && orderItem.fulfillment.state === 'Delivered';
+}
+function isShipped(orderItem: OrderItem) {
+    return orderItem.fulfillment && orderItem.fulfillment.state === 'Shipped';
 }

+ 2 - 0
packages/core/src/service/index.ts

@@ -6,6 +6,7 @@ export * from './helpers/list-query-builder/list-query-builder';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/order-calculator/order-calculator';
 export * from './helpers/order-state-machine/order-state';
+export * from './helpers/fulfillment-state-machine/fulfillment-state';
 export * from './helpers/payment-state-machine/payment-state';
 export * from './services/administrator.service';
 export * from './services/asset.service';
@@ -16,6 +17,7 @@ export * from './services/customer.service';
 export * from './services/customer-group.service';
 export * from './services/facet.service';
 export * from './services/facet-value.service';
+export * from './services/fulfillment.service';
 export * from './services/global-settings.service';
 export * from './services/order.service';
 export * from './services/payment-method.service';

+ 4 - 0
packages/core/src/service/service.module.ts

@@ -12,6 +12,7 @@ import { WorkerServiceModule } from '../worker/worker-service.module';
 import { CollectionController } from './controllers/collection.controller';
 import { TaxRateController } from './controllers/tax-rate.controller';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
+import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
 import { OrderMerger } from './helpers/order-merger/order-merger';
@@ -35,6 +36,7 @@ import { CustomerGroupService } from './services/customer-group.service';
 import { CustomerService } from './services/customer.service';
 import { FacetValueService } from './services/facet-value.service';
 import { FacetService } from './services/facet.service';
+import { FulfillmentService } from './services/fulfillment.service';
 import { GlobalSettingsService } from './services/global-settings.service';
 import { HistoryService } from './services/history.service';
 import { OrderTestingService } from './services/order-testing.service';
@@ -66,6 +68,7 @@ const services = [
     CustomerService,
     FacetService,
     FacetValueService,
+    FulfillmentService,
     GlobalSettingsService,
     HistoryService,
     OrderService,
@@ -93,6 +96,7 @@ const helpers = [
     TaxCalculator,
     OrderCalculator,
     OrderStateMachine,
+    FulfillmentStateMachine,
     OrderMerger,
     PaymentStateMachine,
     ListQueryBuilder,

+ 6 - 6
packages/core/src/service/services/asset.service.ts

@@ -56,9 +56,9 @@ export class AssetService {
         private eventBus: EventBus,
     ) {
         this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
-            .map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
+            .map((val) => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
             .filter(notNullOrUndefined)
-            .map(val => {
+            .map((val) => {
                 const [type, subtype] = val.split('/');
                 return { type, subtype };
             });
@@ -101,7 +101,7 @@ export class AssetService {
                 });
             assets = (entityWithAssets && entityWithAssets.assets) || [];
         }
-        return assets.sort((a, b) => a.position - b.position).map(a => a.asset);
+        return assets.sort((a, b) => a.position - b.position).map((a) => a.asset);
     }
 
     async updateFeaturedAsset<T extends EntityWithAssets>(entity: T, input: EntityAssetInput): Promise<T> {
@@ -131,7 +131,7 @@ export class AssetService {
         if (assetIds && assetIds.length) {
             const assets = await this.connection.getRepository(Asset).findByIds(assetIds);
             const sortedAssets = assetIds
-                .map(id => assets.find(a => idsAreEqual(a.id, id)))
+                .map((id) => assets.find((a) => idsAreEqual(a.id, id)))
                 .filter(notNullOrUndefined);
             await this.removeExistingOrderableAssets(entity);
             entity.assets = await this.createOrderableAssets(entity, sortedAssets);
@@ -332,7 +332,7 @@ export class AssetService {
     private getOrderableAssetType(entity: EntityWithAssets): Type<OrderableAsset> {
         const assetRelation = this.connection
             .getRepository(entity.constructor)
-            .metadata.relations.find(r => r.propertyName === 'assets');
+            .metadata.relations.find((r) => r.propertyName === 'assets');
         if (!assetRelation || typeof assetRelation.type === 'string') {
             throw new InternalServerError('error.could-not-find-matching-orderable-asset');
         }
@@ -355,7 +355,7 @@ export class AssetService {
 
     private validateMimeType(mimeType: string): boolean {
         const [type, subtype] = mimeType.split('/');
-        const typeMatch = this.permittedMimeTypes.find(t => t.type === type);
+        const typeMatch = this.permittedMimeTypes.find((t) => t.type === type);
         if (typeMatch) {
             return typeMatch.subtype === subtype || typeMatch.subtype === '*';
         }

+ 6 - 6
packages/core/src/service/services/customer.service.ts

@@ -87,8 +87,8 @@ export class CustomerService {
             .leftJoinAndSelect('country.translations', 'countryTranslation')
             .where('address.customer = :id', { id: customerId })
             .getMany()
-            .then(addresses => {
-                addresses.forEach(address => {
+            .then((addresses) => {
+                addresses.forEach((address) => {
                     address.country = translateDeep(address.country, ctx.languageCode);
                 });
                 return addresses;
@@ -168,7 +168,7 @@ export class CustomerService {
         }
         let user = await this.userService.getUserByEmailAddress(input.emailAddress);
         const hasNativeAuthMethod = !!user?.authenticationMethods.find(
-            m => m instanceof NativeAuthenticationMethod,
+            (m) => m instanceof NativeAuthenticationMethod,
         );
         if (user && user.verified) {
             if (hasNativeAuthMethod) {
@@ -538,8 +538,8 @@ export class CustomerService {
             .findOne(addressId, { relations: ['customer', 'customer.addresses'] });
         if (result) {
             const customerAddressIds = result.customer.addresses
-                .map(a => a.id)
-                .filter(id => !idsAreEqual(id, addressId)) as string[];
+                .map((a) => a.id)
+                .filter((id) => !idsAreEqual(id, addressId)) as string[];
 
             if (customerAddressIds.length) {
                 if (input.defaultBillingAddress === true) {
@@ -572,7 +572,7 @@ export class CustomerService {
             const customerAddresses = result.customer.addresses;
             if (1 < customerAddresses.length) {
                 const otherAddresses = customerAddresses
-                    .filter(address => !idsAreEqual(address.id, addressToDelete.id))
+                    .filter((address) => !idsAreEqual(address.id, addressToDelete.id))
                     .sort((a, b) => (a.id < b.id ? -1 : 1));
                 if (addressToDelete.defaultShippingAddress) {
                     otherAddresses[0].defaultShippingAddress = true;

+ 72 - 0
packages/core/src/service/services/fulfillment.service.ts

@@ -0,0 +1,72 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+import { EventBus } from '../../event-bus/event-bus';
+import { FulfillmentStateTransitionEvent } from '../../event-bus/events/fulfillment-state-transition-event';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
+import { FulfillmentStateMachine } from '../helpers/fulfillment-state-machine/fulfillment-state-machine';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
+
+@Injectable()
+export class FulfillmentService {
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private fulfillmentStateMachine: FulfillmentStateMachine,
+        private eventBus: EventBus,
+    ) {}
+
+    async create(input: DeepPartial<Fulfillment>): Promise<Fulfillment> {
+        const newFulfillment = new Fulfillment({
+            ...input,
+            state: this.fulfillmentStateMachine.getInitialState(),
+        });
+        return this.connection.getRepository(Fulfillment).save(newFulfillment);
+    }
+
+    async findOneOrThrow(id: ID, relations: string[] = ['orderItems']): Promise<Fulfillment> {
+        return await getEntityOrThrow(this.connection, Fulfillment, id, {
+            relations,
+        });
+    }
+
+    async getOrderItemsByFulfillmentId(id: ID): Promise<OrderItem[]> {
+        const fulfillment = await this.findOneOrThrow(id);
+        return fulfillment.orderItems;
+    }
+
+    async transitionToState(
+        ctx: RequestContext,
+        fulfillmentId: ID,
+        state: FulfillmentState,
+    ): Promise<{
+        fulfillment: Fulfillment;
+        orders: Order[];
+        fromState: FulfillmentState;
+        toState: FulfillmentState;
+    }> {
+        const fulfillment = await this.findOneOrThrow(fulfillmentId, [
+            'orderItems',
+            'orderItems.line',
+            'orderItems.line.order',
+        ]);
+        // Find orders based on order items filtering by id, removing duplicated orders
+        const ordersInOrderItems = fulfillment.orderItems.map(oi => oi.line.order);
+        const orders = ordersInOrderItems.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i);
+        const fromState = fulfillment.state;
+        await this.fulfillmentStateMachine.transition(ctx, fulfillment, orders, state);
+        await this.connection.getRepository(Fulfillment).save(fulfillment, { reload: false });
+        this.eventBus.publish(new FulfillmentStateTransitionEvent(fromState, state, ctx, fulfillment));
+
+        return { fulfillment, orders, fromState, toState: state };
+    }
+
+    getNextStates(fulfillment: Fulfillment): ReadonlyArray<FulfillmentState> {
+        return this.fulfillmentStateMachine.getNextStates(fulfillment);
+    }
+}

+ 7 - 1
packages/core/src/service/services/history.service.ts

@@ -14,6 +14,7 @@ import { Administrator } from '../../entity/administrator/administrator.entity';
 import { CustomerHistoryEntry } from '../../entity/history-entry/customer-history-entry.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
 import { OrderHistoryEntry } from '../../entity/history-entry/order-history-entry.entity';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
@@ -74,7 +75,12 @@ export type OrderHistoryEntryData = {
         from: PaymentState;
         to: PaymentState;
     };
-    [HistoryEntryType.ORDER_FULLFILLMENT]: {
+    [HistoryEntryType.ORDER_FULFILLMENT_TRANSITION]: {
+        fulfillmentId: ID;
+        from: FulfillmentState;
+        to: FulfillmentState;
+    };
+    [HistoryEntryType.ORDER_FULFILLMENT]: {
         fulfillmentId: ID;
     };
     [HistoryEntryType.ORDER_CANCELLATION]: {

+ 1 - 1
packages/core/src/service/services/order-testing.service.ts

@@ -68,7 +68,7 @@ export class OrderTestingService {
     ): Promise<ShippingMethodQuote[]> {
         const mockOrder = await this.buildMockOrder(ctx, input.shippingAddress, input.lines);
         const eligibleMethods = await this.shippingCalculator.getEligibleShippingMethods(ctx, mockOrder);
-        return eligibleMethods.map(result => ({
+        return eligibleMethods.map((result) => ({
             id: result.method.id,
             price: result.result.price,
             priceWithTax: result.result.priceWithTax,

+ 56 - 37
packages/core/src/service/services/order.service.ts

@@ -46,6 +46,7 @@ import { EventBus } from '../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
 import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
 import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
 import { OrderMerger } from '../helpers/order-merger/order-merger';
@@ -54,11 +55,11 @@ import { OrderStateMachine } from '../helpers/order-state-machine/order-state-ma
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
-import { findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import {
     orderItemsAreAllCancelled,
-    orderItemsAreFulfilled,
+    orderItemsAreDelivered,
+    orderItemsAreShipped,
     orderTotalIsCovered,
 } from '../helpers/utils/order-utils';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -67,6 +68,7 @@ import { translateDeep } from '../helpers/utils/translate-entity';
 import { ChannelService } from './channel.service';
 import { CountryService } from './country.service';
 import { CustomerService } from './customer.service';
+import { FulfillmentService } from './fulfillment.service';
 import { HistoryService } from './history.service';
 import { PaymentMethodService } from './payment-method.service';
 import { ProductVariantService } from './product-variant.service';
@@ -86,6 +88,7 @@ export class OrderService {
         private orderMerger: OrderMerger,
         private paymentStateMachine: PaymentStateMachine,
         private paymentMethodService: PaymentMethodService,
+        private fulfillmentService: FulfillmentService,
         private listQueryBuilder: ListQueryBuilder,
         private stockMovementService: StockMovementService,
         private refundStateMachine: RefundStateMachine,
@@ -457,6 +460,44 @@ export class OrderService {
         this.eventBus.publish(new OrderStateTransitionEvent(fromState, state, ctx, order));
         return order;
     }
+    async transitionFulfillmentToState(
+        ctx: RequestContext,
+        fulfillmentId: ID,
+        state: FulfillmentState,
+    ): Promise<Fulfillment> {
+        const { fulfillment, fromState, toState, orders } = await this.fulfillmentService.transitionToState(
+            ctx,
+            fulfillmentId,
+            state,
+        );
+        await Promise.all(
+            orders.map(order => this.handleFulfillmentStateTransitByOrder(ctx, order.id, fromState, toState)),
+        );
+        return fulfillment;
+    }
+    private async handleFulfillmentStateTransitByOrder(
+        ctx: RequestContext,
+        orderId: ID,
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+    ): Promise<void> {
+        if (fromState === 'Pending' && toState === 'Shipped') {
+            const orderWithFulfillment = await this.getOrderWithFulfillments(orderId);
+            if (orderItemsAreShipped(orderWithFulfillment)) {
+                await this.transitionToState(ctx, orderId, 'Shipped');
+            } else {
+                await this.transitionToState(ctx, orderId, 'PartiallyShipped');
+            }
+        }
+        if (fromState === 'Shipped' && toState === 'Delivered') {
+            const orderWithFulfillment = await this.getOrderWithFulfillments(orderId);
+            if (orderItemsAreDelivered(orderWithFulfillment)) {
+                await this.transitionToState(ctx, orderId, 'Delivered');
+            } else {
+                await this.transitionToState(ctx, orderId, 'PartiallyDelivered');
+            }
+        }
+    }
 
     async addPaymentToOrder(ctx: RequestContext, orderId: ID, input: PaymentInput): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
@@ -522,45 +563,25 @@ export class OrderService {
         );
 
         for (const order of orders) {
-            if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled') {
+            if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyDelivered') {
                 throw new IllegalOperationError('error.create-fulfillment-orders-must-be-settled');
             }
         }
-
-        const fulfillment = await this.connection.getRepository(Fulfillment).save(
-            new Fulfillment({
-                trackingCode: input.trackingCode,
-                method: input.method,
-                orderItems: items,
-            }),
-        );
+        const fulfillment = await this.fulfillmentService.create({
+            trackingCode: input.trackingCode,
+            method: input.method,
+            orderItems: items,
+        });
 
         for (const order of orders) {
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
                 orderId: order.id,
-                type: HistoryEntryType.ORDER_FULLFILLMENT,
+                type: HistoryEntryType.ORDER_FULFILLMENT,
                 data: {
                     fulfillmentId: fulfillment.id,
                 },
             });
-            const orderWithFulfillments = await findOneInChannel(
-                this.connection,
-                Order,
-                order.id,
-                ctx.channelId,
-                {
-                    relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-                },
-            );
-            if (!orderWithFulfillments) {
-                throw new InternalServerError('error.could-not-find-order');
-            }
-            if (orderItemsAreFulfilled(orderWithFulfillments)) {
-                await this.transitionToState(ctx, order.id, 'Fulfilled');
-            } else {
-                await this.transitionToState(ctx, order.id, 'PartiallyFulfilled');
-            }
         }
         return fulfillment;
     }
@@ -585,14 +606,6 @@ export class OrderService {
         const items = lines.reduce((acc, l) => [...acc, ...l.items], [] as OrderItem[]);
         return unique(items.map(i => i.fulfillment).filter(notNullOrUndefined), 'id');
     }
-
-    async getFulfillmentOrderItems(id: ID): Promise<OrderItem[]> {
-        const fulfillment = await getEntityOrThrow(this.connection, Fulfillment, id, {
-            relations: ['orderItems'],
-        });
-        return fulfillment.orderItems;
-    }
-
     async cancelOrder(ctx: RequestContext, input: CancelOrderInput): Promise<Order> {
         let allOrderItemsCancelled = false;
         if (input.lines != null) {
@@ -914,6 +927,12 @@ export class OrderService {
         return order;
     }
 
+    private async getOrderWithFulfillments(orderId: ID): Promise<Order> {
+        return await getEntityOrThrow(this.connection, Order, orderId, {
+            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+        });
+    }
+
     private async getOrdersAndItemsFromLines(
         ctx: RequestContext,
         orderLinesInput: OrderLineInput[],

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików