From 18f3aacee6752854d377faa806f4cc67bc71456b Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 23 Apr 2024 10:35:44 +0200 Subject: [PATCH] feat(core-flows,medusa,types): fulfillment API: create (#7101) what: - adds fulfillment create API RESOLVES CORE-1962 --- .changeset/new-dingos-impress.md | 7 + .changeset/strong-crabs-leave.md | 7 + .../__tests__/fixtures/fulfillment/index.ts | 3 +- .../fulfillment/fulfillment.workflows.spec.ts | 261 ++++++++++++++++++ .../__tests__/fulfillment/index.spec.ts | 187 ++++++++++++- .../fulfillment/steps/create-fulfillment.ts | 28 ++ .../core-flows/src/fulfillment/steps/index.ts | 3 + .../steps/update-fulfillment-workflow.ts | 35 +++ .../fulfillment/steps/update-fulfillment.ts | 42 +++ .../fulfillment/steps/validate-shipment.ts | 40 +++ .../workflows/create-fulfillment.ts | 13 + .../fulfillment/workflows/create-shipment.ts | 25 ++ .../src/fulfillment/workflows/index.ts | 3 + .../workflows/update-fulfillment.ts | 13 + .../fulfillment/src/models/fulfillment.ts | 4 +- .../admin/fulfillments/[id]/shipment/route.ts | 31 +++ .../api-v2/admin/fulfillments/middlewares.ts | 29 +- .../api-v2/admin/fulfillments/query-config.ts | 5 + .../src/api-v2/admin/fulfillments/route.ts | 31 +++ .../api-v2/admin/fulfillments/validators.ts | 34 ++- .../src/api-v2/utils/common-validators.ts | 2 +- .../src/fulfillment/mutations/fulfillment.ts | 5 + .../fulfillment/create-fulfillment.ts | 183 ++++++++++++ .../workflow/fulfillment/create-shipment.ts | 16 ++ .../types/src/workflow/fulfillment/index.ts | 5 +- .../fulfillment/update-fulfillment.ts | 39 +++ 26 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 .changeset/new-dingos-impress.md create mode 100644 .changeset/strong-crabs-leave.md create mode 100644 integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts create mode 100644 packages/core-flows/src/fulfillment/steps/create-fulfillment.ts create mode 100644 packages/core-flows/src/fulfillment/steps/update-fulfillment-workflow.ts create mode 100644 packages/core-flows/src/fulfillment/steps/update-fulfillment.ts create mode 100644 packages/core-flows/src/fulfillment/steps/validate-shipment.ts create mode 100644 packages/core-flows/src/fulfillment/workflows/create-fulfillment.ts create mode 100644 packages/core-flows/src/fulfillment/workflows/create-shipment.ts create mode 100644 packages/core-flows/src/fulfillment/workflows/update-fulfillment.ts create mode 100644 packages/medusa/src/api-v2/admin/fulfillments/[id]/shipment/route.ts create mode 100644 packages/medusa/src/api-v2/admin/fulfillments/route.ts create mode 100644 packages/types/src/workflow/fulfillment/create-fulfillment.ts create mode 100644 packages/types/src/workflow/fulfillment/create-shipment.ts create mode 100644 packages/types/src/workflow/fulfillment/update-fulfillment.ts diff --git a/.changeset/new-dingos-impress.md b/.changeset/new-dingos-impress.md new file mode 100644 index 0000000000..c9433de3f2 --- /dev/null +++ b/.changeset/new-dingos-impress.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(core-flows,medusa,types): add create shipment api for fulfillments diff --git a/.changeset/strong-crabs-leave.md b/.changeset/strong-crabs-leave.md new file mode 100644 index 0000000000..c2c959ae23 --- /dev/null +++ b/.changeset/strong-crabs-leave.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(core-flows,medusa,types): fulfillment API: create diff --git a/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts b/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts index 0dc846217e..5e993db0ce 100644 --- a/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts +++ b/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts @@ -30,7 +30,8 @@ export function generateCreateFulfillmentData( country_code: "test-country-code_" + randomString, province: "test-province_" + randomString, phone: "test-phone_" + randomString, - full_name: "test-full-name_" + randomString, + first_name: "test-first-name_" + randomString, + last_name: "test-last-name_" + randomString, }, items: data.items ?? [ { diff --git a/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts b/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts new file mode 100644 index 0000000000..0fd778c651 --- /dev/null +++ b/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts @@ -0,0 +1,261 @@ +import { + createFulfillmentWorkflow, + createFulfillmentWorkflowId, + createShipmentWorkflow, + createShipmentWorkflowId, + updateFulfillmentWorkflow, + updateFulfillmentWorkflowId, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IFulfillmentModuleService } from "@medusajs/types" +import { medusaIntegrationTestRunner } from "medusa-test-utils/dist" +import { + generateCreateFulfillmentData, + generateCreateShippingOptionsData, +} from "../fixtures" + +jest.setTimeout(50000) + +const providerId = "manual_test-provider" + +medusaIntegrationTestRunner({ + env: { MEDUSA_FF_MEDUSA_V2: true }, + testSuite: ({ getContainer }) => { + describe("Workflows: Fulfillment", () => { + let appContainer + let service: IFulfillmentModuleService + + beforeAll(async () => { + appContainer = getContainer() + service = appContainer.resolve(ModuleRegistrationName.FULFILLMENT) + }) + + describe("createFulfillmentWorkflow", () => { + describe("compensation", () => { + it("should cancel created fulfillment if step following step throws error", async () => { + const workflow = createFulfillmentWorkflow(appContainer) + + workflow.appendAction("throw", createFulfillmentWorkflowId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after creating fulfillment` + ) + }, + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + const fulfillmentSet = await service.create({ + name: "test", + type: "test-type", + }) + + const serviceZone = await service.createServiceZones({ + name: "test", + fulfillment_set_id: fulfillmentSet.id, + }) + + const shippingOption = await service.createShippingOptions( + generateCreateShippingOptionsData({ + provider_id: providerId, + service_zone_id: serviceZone.id, + shipping_profile_id: shippingProfile.id, + }) + ) + + const data = generateCreateFulfillmentData({ + provider_id: providerId, + shipping_option_id: shippingOption.id, + }) + const { errors } = await workflow.run({ + input: data, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: expect.objectContaining({ + message: `Failed to do something after creating fulfillment`, + }), + }, + ]) + + const fulfillments = await service.listFulfillments() + + expect(fulfillments.filter((f) => !!f.canceled_at)).toHaveLength(1) + }) + }) + }) + + describe("updateFulfillmentWorkflow", () => { + describe("compensation", () => { + it("should rollback updated fulfillment if step following step throws error", async () => { + const workflow = updateFulfillmentWorkflow(appContainer) + + workflow.appendAction("throw", updateFulfillmentWorkflowId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after updating fulfillment` + ) + }, + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + const fulfillmentSet = await service.create({ + name: "test", + type: "test-type", + }) + + const serviceZone = await service.createServiceZones({ + name: "test", + fulfillment_set_id: fulfillmentSet.id, + }) + + const shippingOption = await service.createShippingOptions( + generateCreateShippingOptionsData({ + provider_id: providerId, + service_zone_id: serviceZone.id, + shipping_profile_id: shippingProfile.id, + }) + ) + + const data = generateCreateFulfillmentData({ + provider_id: providerId, + shipping_option_id: shippingOption.id, + }) + + const fulfillment = await service.createFulfillment(data) + + const date = new Date() + const { errors } = await workflow.run({ + input: { + id: fulfillment.id, + shipped_at: date, + packed_at: date, + location_id: "new location", + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: expect.objectContaining({ + message: `Failed to do something after updating fulfillment`, + }), + }, + ]) + + const fulfillmentAfterRollback = await service.retrieveFulfillment( + fulfillment.id + ) + + expect(fulfillmentAfterRollback).toEqual( + expect.objectContaining({ + location_id: data.location_id, + shipped_at: data.shipped_at, + packed_at: data.packed_at, + }) + ) + }) + }) + }) + + describe("createShipmentWorkflow", () => { + describe("compensation", () => { + it("should rollback shipment workflow if following step throws error", async () => { + const workflow = createShipmentWorkflow(appContainer) + + workflow.appendAction("throw", createShipmentWorkflowId, { + invoke: async function failStep() { + throw new Error( + `Failed to do something after creating shipment` + ) + }, + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + const fulfillmentSet = await service.create({ + name: "test", + type: "test-type", + }) + + const serviceZone = await service.createServiceZones({ + name: "test", + fulfillment_set_id: fulfillmentSet.id, + }) + + const shippingOption = await service.createShippingOptions( + generateCreateShippingOptionsData({ + provider_id: providerId, + service_zone_id: serviceZone.id, + shipping_profile_id: shippingProfile.id, + }) + ) + + const data = generateCreateFulfillmentData({ + provider_id: providerId, + shipping_option_id: shippingOption.id, + }) + + const fulfillment = await service.createFulfillment({ + ...data, + labels: [], + }) + + const { errors } = await workflow.run({ + input: { + id: fulfillment.id, + labels: [ + { + tracking_number: "test-tracking-number", + tracking_url: "test-tracking-url", + label_url: "test-label-url", + }, + ], + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: expect.objectContaining({ + message: `Failed to do something after creating shipment`, + }), + }, + ]) + + const fulfillmentAfterRollback = await service.retrieveFulfillment( + fulfillment.id, + { select: ["shipped_at"], relations: ["labels"] } + ) + + expect(fulfillmentAfterRollback).toEqual( + expect.objectContaining({ + shipped_at: null, + // TODO: the revert isn't handling deleting the labels. This needs to be handled uniformly across. + // labels: [], + }) + ) + }) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/fulfillment/index.spec.ts b/integration-tests/modules/__tests__/fulfillment/index.spec.ts index 144661647f..87a937072b 100644 --- a/integration-tests/modules/__tests__/fulfillment/index.spec.ts +++ b/integration-tests/modules/__tests__/fulfillment/index.spec.ts @@ -2,7 +2,11 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IFulfillmentModuleService } from "@medusajs/types" import { medusaIntegrationTestRunner } from "medusa-test-utils/dist" import { createAdminUser } from "../../../helpers/create-admin-user" -import { setupFullDataFulfillmentStructure } from "../fixtures" +import { + generateCreateFulfillmentData, + generateCreateShippingOptionsData, + setupFullDataFulfillmentStructure, +} from "../fixtures" jest.setTimeout(100000) @@ -10,6 +14,7 @@ const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, } +const providerId = "manual_test-provider" medusaIntegrationTestRunner({ env, @@ -33,9 +38,7 @@ medusaIntegrationTestRunner({ */ describe("Fulfillment module migrations backward compatibility", () => { it("should allow to create a full data structure after the backward compatible migration have run on top of the medusa v1 database", async () => { - await setupFullDataFulfillmentStructure(service, { - providerId: `manual_test-provider`, - }) + await setupFullDataFulfillmentStructure(service, { providerId }) const fulfillmentSets = await service.list( {}, @@ -89,9 +92,7 @@ medusaIntegrationTestRunner({ }) it("should cancel a fulfillment", async () => { - await setupFullDataFulfillmentStructure(service, { - providerId: `manual_test-provider`, - }) + await setupFullDataFulfillmentStructure(service, { providerId }) const [fulfillment] = await service.listFulfillments() @@ -110,5 +111,177 @@ medusaIntegrationTestRunner({ expect(canceledFulfillment.canceled_at).toBeTruthy() }) }) + + describe("POST /admin/fulfillments", () => { + it("should create a fulfillment", async () => { + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + const fulfillmentSet = await service.create({ + name: "test", + type: "test-type", + }) + + const serviceZone = await service.createServiceZones({ + name: "test", + fulfillment_set_id: fulfillmentSet.id, + }) + + const shippingOption = await service.createShippingOptions( + generateCreateShippingOptionsData({ + provider_id: providerId, + service_zone_id: serviceZone.id, + shipping_profile_id: shippingProfile.id, + }) + ) + + const data = generateCreateFulfillmentData({ + provider_id: providerId, + shipping_option_id: shippingOption.id, + }) + + const response = await api + .post(`/admin/fulfillments`, data, adminHeaders) + .catch((e) => e) + + expect(response.status).toEqual(200) + expect(response.data.fulfillment).toEqual( + expect.objectContaining({ + id: expect.any(String), + location_id: "test-location", + packed_at: null, + shipped_at: null, + delivered_at: null, + canceled_at: null, + provider_id: "manual_test-provider", + delivery_address: expect.objectContaining({ + address_1: expect.any(String), + address_2: expect.any(String), + city: expect.any(String), + country_code: expect.any(String), + province: expect.any(String), + postal_code: expect.any(String), + }), + items: [ + expect.objectContaining({ + id: expect.any(String), + title: expect.any(String), + sku: expect.any(String), + barcode: expect.any(String), + raw_quantity: { + value: "1", + precision: 20, + }, + quantity: 1, + }), + ], + labels: [ + expect.objectContaining({ + id: expect.any(String), + tracking_number: expect.any(String), + tracking_url: expect.any(String), + label_url: expect.any(String), + }), + ], + }) + ) + }) + }) + + describe("POST /admin/fulfillments/:id/shipment", () => { + it("should throw an error when id is not found", async () => { + const error = await api + .post( + `/admin/fulfillments/does-not-exist/shipment`, + { + labels: [ + { + tracking_number: "test-tracking-number", + tracking_url: "test-tracking-url", + label_url: "test-label-url", + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + type: "not_found", + message: "Fulfillment with id: does-not-exist was not found", + }) + }) + + it("should update a fulfillment to be shipped", async () => { + await setupFullDataFulfillmentStructure(service, { providerId }) + + const [fulfillment] = await service.listFulfillments() + + const response = await api.post( + `/admin/fulfillments/${fulfillment.id}/shipment`, + { + labels: [ + { + tracking_number: "test-tracking-number", + tracking_url: "test-tracking-url", + label_url: "test-label-url", + }, + ], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.fulfillment).toEqual( + expect.objectContaining({ + id: fulfillment.id, + shipped_at: expect.any(String), + labels: [ + expect.objectContaining({ + id: expect.any(String), + tracking_number: "test-tracking-number", + tracking_url: "test-tracking-url", + label_url: "test-label-url", + }), + ], + }) + ) + }) + + it("should throw error when already shipped", async () => { + await setupFullDataFulfillmentStructure(service, { providerId }) + + const [fulfillment] = await service.listFulfillments() + + await service.updateFulfillment(fulfillment.id, { + shipped_at: new Date(), + }) + + const error = await api + .post( + `/admin/fulfillments/${fulfillment.id}/shipment`, + { + labels: [ + { + tracking_number: "test-tracking-number", + tracking_url: "test-tracking-url", + label_url: "test-label-url", + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + type: "not_allowed", + message: "Shipment has already been created", + }) + }) + }) }, }) diff --git a/packages/core-flows/src/fulfillment/steps/create-fulfillment.ts b/packages/core-flows/src/fulfillment/steps/create-fulfillment.ts new file mode 100644 index 0000000000..331cdd5628 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/create-fulfillment.ts @@ -0,0 +1,28 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { FulfillmentTypes, IFulfillmentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createFulfillmentStepId = "create-fulfillment" +export const createFulfillmentStep = createStep( + createFulfillmentStepId, + async (data: FulfillmentTypes.CreateFulfillmentDTO, { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const fulfillment = await service.createFulfillment(data) + + return new StepResponse(fulfillment, fulfillment.id) + }, + async (id, { container }) => { + if (!id) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.cancelFulfillment(id) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/index.ts b/packages/core-flows/src/fulfillment/steps/index.ts index 77f4f72609..443c1e765c 100644 --- a/packages/core-flows/src/fulfillment/steps/index.ts +++ b/packages/core-flows/src/fulfillment/steps/index.ts @@ -1,6 +1,7 @@ export * from "./add-rules-to-fulfillment-shipping-option" export * from "./add-shipping-options-prices" export * from "./cancel-fulfillment" +export * from "./create-fulfillment" export * from "./create-fulfillment-set" export * from "./create-service-zones" export * from "./create-shipping-profiles" @@ -9,4 +10,6 @@ export * from "./delete-service-zones" export * from "./delete-shipping-options" export * from "./remove-rules-from-fulfillment-shipping-option" export * from "./set-shipping-options-prices" +export * from "./update-fulfillment" export * from "./upsert-shipping-options" +export * from "./validate-shipment" diff --git a/packages/core-flows/src/fulfillment/steps/update-fulfillment-workflow.ts b/packages/core-flows/src/fulfillment/steps/update-fulfillment-workflow.ts new file mode 100644 index 0000000000..f261fc3c89 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/update-fulfillment-workflow.ts @@ -0,0 +1,35 @@ +import { FulfillmentWorkflow } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { updateFulfillmentWorkflow } from "../workflows/update-fulfillment" + +export const updateFulfillmentWorkflowStepId = "update-fulfillment-workflow" +export const updateFulfillmentWorkflowStep = createStep( + updateFulfillmentWorkflowStepId, + async ( + data: FulfillmentWorkflow.UpdateFulfillmentWorkflowInput, + { container } + ) => { + const { + transaction, + result: updated, + errors, + } = await updateFulfillmentWorkflow(container).run({ + input: data, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + return new StepResponse(updated, transaction) + }, + + async (transaction, { container }) => { + if (!transaction) { + return + } + + await updateFulfillmentWorkflow(container).cancel({ transaction }) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/update-fulfillment.ts b/packages/core-flows/src/fulfillment/steps/update-fulfillment.ts new file mode 100644 index 0000000000..f0bb8f8fa7 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/update-fulfillment.ts @@ -0,0 +1,42 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { FulfillmentWorkflow, IFulfillmentModuleService } from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const updateFulfillmentStepId = "update-fulfillment" +export const updateFulfillmentStep = createStep( + updateFulfillmentStepId, + async ( + input: FulfillmentWorkflow.UpdateFulfillmentWorkflowInput, + { container } + ) => { + const { id, ...data } = input + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([data]) + const fulfillment = await service.retrieveFulfillment(id, { + select: selects, + relations, + }) + + await service.updateFulfillment(id, data) + + return new StepResponse(void 0, fulfillment) + }, + async (fulfillment, { container }) => { + if (!fulfillment) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + const { id, ...data } = fulfillment + + // TODO: this does not revert the relationships that are created in invoke step + // There should be a consistent way to handle across workflows + await service.updateFulfillment(id, data) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/validate-shipment.ts b/packages/core-flows/src/fulfillment/steps/validate-shipment.ts new file mode 100644 index 0000000000..bda7d5f802 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/validate-shipment.ts @@ -0,0 +1,40 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IFulfillmentModuleService } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +export const validateShipmentStepId = "validate-shipment" +export const validateShipmentStep = createStep( + validateShipmentStepId, + async (id: string, { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + const fulfillment = await service.retrieveFulfillment(id, { + select: ["shipped_at", "canceled_at", "shipping_option_id"], + }) + + if (fulfillment.shipped_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipment has already been created" + ) + } + + if (fulfillment.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot create shipment for a canceled fulfillment" + ) + } + + if (!fulfillment.shipping_option_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot create shipment without a Shipping Option" + ) + } + + return new StepResponse(void 0) + } +) diff --git a/packages/core-flows/src/fulfillment/workflows/create-fulfillment.ts b/packages/core-flows/src/fulfillment/workflows/create-fulfillment.ts new file mode 100644 index 0000000000..7e58448404 --- /dev/null +++ b/packages/core-flows/src/fulfillment/workflows/create-fulfillment.ts @@ -0,0 +1,13 @@ +import { FulfillmentDTO, FulfillmentWorkflow } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createFulfillmentStep } from "../steps" + +export const createFulfillmentWorkflowId = "create-fulfillment-workflow" +export const createFulfillmentWorkflow = createWorkflow( + createFulfillmentWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return createFulfillmentStep(input) + } +) diff --git a/packages/core-flows/src/fulfillment/workflows/create-shipment.ts b/packages/core-flows/src/fulfillment/workflows/create-shipment.ts new file mode 100644 index 0000000000..79d980f5df --- /dev/null +++ b/packages/core-flows/src/fulfillment/workflows/create-shipment.ts @@ -0,0 +1,25 @@ +import { FulfillmentWorkflow } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { validateShipmentStep } from "../steps" +import { updateFulfillmentWorkflowStep } from "../steps/update-fulfillment-workflow" + +export const createShipmentWorkflowId = "create-shipment-workflow" +export const createShipmentWorkflow = createWorkflow( + createShipmentWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + validateShipmentStep(input.id) + + const update = transform({ input }, (data) => ({ + ...data.input, + shipped_at: new Date(), + })) + + updateFulfillmentWorkflowStep(update) + } +) diff --git a/packages/core-flows/src/fulfillment/workflows/index.ts b/packages/core-flows/src/fulfillment/workflows/index.ts index 3da92f0658..b344a9d1ec 100644 --- a/packages/core-flows/src/fulfillment/workflows/index.ts +++ b/packages/core-flows/src/fulfillment/workflows/index.ts @@ -1,11 +1,14 @@ export * from "./add-rules-to-fulfillment-shipping-option" export * from "./cancel-fulfillment" +export * from "./create-fulfillment" export * from "./create-service-zones" +export * from "./create-shipment" export * from "./create-shipping-options" export * from "./create-shipping-profiles" export * from "./delete-fulfillment-sets" export * from "./delete-service-zones" export * from "./delete-shipping-options" export * from "./remove-rules-from-fulfillment-shipping-option" +export * from "./update-fulfillment" export * from "./update-service-zones" export * from "./update-shipping-options" diff --git a/packages/core-flows/src/fulfillment/workflows/update-fulfillment.ts b/packages/core-flows/src/fulfillment/workflows/update-fulfillment.ts new file mode 100644 index 0000000000..b6cb926bec --- /dev/null +++ b/packages/core-flows/src/fulfillment/workflows/update-fulfillment.ts @@ -0,0 +1,13 @@ +import { FulfillmentWorkflow } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateFulfillmentStep } from "../steps" + +export const updateFulfillmentWorkflowId = "update-fulfillment-workflow" +export const updateFulfillmentWorkflow = createWorkflow( + updateFulfillmentWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + updateFulfillmentStep(input) + } +) diff --git a/packages/fulfillment/src/models/fulfillment.ts b/packages/fulfillment/src/models/fulfillment.ts index eed4cdb8fe..15c047805c 100644 --- a/packages/fulfillment/src/models/fulfillment.ts +++ b/packages/fulfillment/src/models/fulfillment.ts @@ -162,12 +162,12 @@ export default class Fulfillment { @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "ful") - this.provider_id ??= this.provider.id + this.provider_id ??= this.provider_id ?? this.provider?.id } @OnInit() onInit() { this.id = generateEntityId(this.id, "ful") - this.provider_id ??= this.provider.id + this.provider_id ??= this.provider_id ?? this.provider?.id } } diff --git a/packages/medusa/src/api-v2/admin/fulfillments/[id]/shipment/route.ts b/packages/medusa/src/api-v2/admin/fulfillments/[id]/shipment/route.ts new file mode 100644 index 0000000000..6917eef5bb --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillments/[id]/shipment/route.ts @@ -0,0 +1,31 @@ +import { createShipmentWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { refetchFulfillment } from "../../helpers" +import { AdminCreateShipmentType } from "../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + const { errors } = await createShipmentWorkflow(req.scope).run({ + input: { id, ...req.validatedBody }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const fulfillment = await refetchFulfillment( + id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ fulfillment }) +} diff --git a/packages/medusa/src/api-v2/admin/fulfillments/middlewares.ts b/packages/medusa/src/api-v2/admin/fulfillments/middlewares.ts index 5a00d82742..f1545f0e2c 100644 --- a/packages/medusa/src/api-v2/admin/fulfillments/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/fulfillments/middlewares.ts @@ -3,7 +3,12 @@ import { authenticate } from "../../../utils/authenticate-middleware" import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" import * as QueryConfig from "./query-config" -import { AdminCancelFulfillment, AdminFulfillmentParams } from "./validators" +import { + AdminCancelFulfillment, + AdminCreateFulfillment, + AdminCreateShipment, + AdminFulfillmentParams, +} from "./validators" export const adminFulfillmentsRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -22,4 +27,26 @@ export const adminFulfillmentsRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/fulfillments", + middlewares: [ + validateAndTransformBody(AdminCreateFulfillment), + validateAndTransformQuery( + AdminFulfillmentParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/fulfillments/:id/shipment", + middlewares: [ + validateAndTransformBody(AdminCreateShipment), + validateAndTransformQuery( + AdminFulfillmentParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/fulfillments/query-config.ts b/packages/medusa/src/api-v2/admin/fulfillments/query-config.ts index 777f99bd72..4fb6232135 100644 --- a/packages/medusa/src/api-v2/admin/fulfillments/query-config.ts +++ b/packages/medusa/src/api-v2/admin/fulfillments/query-config.ts @@ -9,8 +9,13 @@ export const defaultAdminFulfillmentsFields = [ "provider_id", "shipping_option_id", "metadata", + "order", "created_at", "updated_at", + "deleted_at", + "*delivery_address", + "*items", + "*labels", ] export const retrieveTransformQueryConfig = { diff --git a/packages/medusa/src/api-v2/admin/fulfillments/route.ts b/packages/medusa/src/api-v2/admin/fulfillments/route.ts new file mode 100644 index 0000000000..78c2f3f3bd --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillments/route.ts @@ -0,0 +1,31 @@ +import { createFulfillmentWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" +import { refetchFulfillment } from "./helpers" +import { AdminCreateFulfillmentType } from "./validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result: fullfillment, errors } = await createFulfillmentWorkflow( + req.scope + ).run({ + input: req.validatedBody, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const fulfillment = await refetchFulfillment( + fullfillment.id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ fulfillment }) +} diff --git a/packages/medusa/src/api-v2/admin/fulfillments/validators.ts b/packages/medusa/src/api-v2/admin/fulfillments/validators.ts index ba7afae8c9..05350675b4 100644 --- a/packages/medusa/src/api-v2/admin/fulfillments/validators.ts +++ b/packages/medusa/src/api-v2/admin/fulfillments/validators.ts @@ -1,7 +1,39 @@ import { z } from "zod" +import { AddressPayload } from "../../utils/common-validators" import { createSelectParams } from "../../utils/validators" export const AdminFulfillmentParams = createSelectParams() -export const AdminCancelFulfillment = z.object({}) +const AdminCreateFulfillmentItem = z.object({ + title: z.string(), + sku: z.string(), + quantity: z.number(), + barcode: z.string(), + line_item_id: z.string().optional(), + inventory_item_id: z.string().optional(), +}) + +const AdminCreateFulfillmentLabel = z.object({ + tracking_number: z.string(), + tracking_url: z.string(), + label_url: z.string(), +}) + export type AdminCancelFulfillmentType = z.infer +export const AdminCancelFulfillment = z.object({}) + +export type AdminCreateFulfillmentType = z.infer +export const AdminCreateFulfillment = z.object({ + location_id: z.string(), + provider_id: z.string(), + delivery_address: AddressPayload, + items: z.array(AdminCreateFulfillmentItem), + labels: z.array(AdminCreateFulfillmentLabel), + order: z.object({}), + metadata: z.record(z.unknown()).optional().nullable(), +}) + +export type AdminCreateShipmentType = z.infer +export const AdminCreateShipment = z.object({ + labels: z.array(AdminCreateFulfillmentLabel), +}) diff --git a/packages/medusa/src/api-v2/utils/common-validators.ts b/packages/medusa/src/api-v2/utils/common-validators.ts index 3f97110cd1..ff3414a15b 100644 --- a/packages/medusa/src/api-v2/utils/common-validators.ts +++ b/packages/medusa/src/api-v2/utils/common-validators.ts @@ -12,7 +12,7 @@ export const AddressPayload = z country_code: z.string().optional(), province: z.string().optional(), postal_code: z.string().optional(), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string()).optional().nullable(), }) .strict() diff --git a/packages/types/src/fulfillment/mutations/fulfillment.ts b/packages/types/src/fulfillment/mutations/fulfillment.ts index 5ae15f8295..1dfcea6cba 100644 --- a/packages/types/src/fulfillment/mutations/fulfillment.ts +++ b/packages/types/src/fulfillment/mutations/fulfillment.ts @@ -110,4 +110,9 @@ export interface UpdateFulfillmentDTO { * Holds custom data in key-value pairs. */ metadata?: Record | null + + /** + * The labels associated with the fulfillment. + */ + labels?: Omit[] } diff --git a/packages/types/src/workflow/fulfillment/create-fulfillment.ts b/packages/types/src/workflow/fulfillment/create-fulfillment.ts new file mode 100644 index 0000000000..44c15deb82 --- /dev/null +++ b/packages/types/src/workflow/fulfillment/create-fulfillment.ts @@ -0,0 +1,183 @@ +/** + * The fulfillment address to be created. + */ +export type CreateFulfillmentAddressWorkflowDTO = { + /** + * The company of the fulfillment address. + */ + company?: string | null + + /** + * The first name of the fulfillment address. + */ + first_name?: string | null + + /** + * The last name of the fulfillment address. + */ + last_name?: string | null + + /** + * The first line of the fulfillment address. + */ + address_1?: string | null + + /** + * The second line of the fulfillment address. + */ + address_2?: string | null + + /** + * The city of the fulfillment address. + */ + city?: string | null + + /** + * The ISO 2 character country code of the fulfillment address. + */ + country_code?: string | null + + /** + * The province of the fulfillment address. + */ + province?: string | null + + /** + * The postal code of the fulfillment address. + */ + postal_code?: string | null + + /** + * The phone of the fulfillment address. + */ + phone?: string | null + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null +} + +/** + * The fulfillment item to be created. + */ +export type CreateFulfillmentItemWorkflowDTO = { + /** + * The title of the fulfillment item. + */ + title: string + + /** + * The SKU of the fulfillment item. + */ + sku: string + + /** + * The quantity of the fulfillment item. + */ + quantity: number + + /** + * The barcode of the fulfillment item. + */ + barcode: string + + /** + * The associated line item's ID. + */ + line_item_id?: string | null + + /** + * The associated inventory item's ID. + */ + inventory_item_id?: string | null +} + +/** + * The fulfillment label to be created. + */ +export type CreateFulfillmentLabelWorkflowDTO = { + /** + * The tracking number of the fulfillment label. + */ + tracking_number: string + + /** + * The tracking URL of the fulfillment label. + */ + tracking_url: string + + /** + * The URL of the label. + */ + label_url: string +} + +export type CreateFulfillmentOrderWorkflowDTO = Record + +export type CreateFulfillmentWorkflowInput = { + /** + * The associated location's ID. + */ + location_id: string + + /** + * The date the fulfillment was packed. + */ + packed_at?: Date | null + + /** + * The date the fulfillment was shipped. + */ + shipped_at?: Date | null + + /** + * The date the fulfillment was delivered. + */ + delivered_at?: Date | null + + /** + * The date the fulfillment was canceled. + */ + canceled_at?: Date | null + + /** + * The data necessary for the associated fulfillment provider to process the fulfillment. + */ + data?: Record | null + + /** + * The associated fulfillment provider's ID. + */ + provider_id: string + + /** + * The associated shipping option's ID. + */ + shipping_option_id?: string | null + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null + + /** + * The address associated with the fulfillment. It's used for delivery. + */ + delivery_address: CreateFulfillmentAddressWorkflowDTO + + /** + * The items associated with the fulfillment. + */ + items: CreateFulfillmentItemWorkflowDTO[] + + /** + * The labels associated with the fulfillment. + */ + labels: CreateFulfillmentLabelWorkflowDTO[] + + /** + * The associated fulfillment order. + */ + order: CreateFulfillmentOrderWorkflowDTO +} diff --git a/packages/types/src/workflow/fulfillment/create-shipment.ts b/packages/types/src/workflow/fulfillment/create-shipment.ts new file mode 100644 index 0000000000..a571002421 --- /dev/null +++ b/packages/types/src/workflow/fulfillment/create-shipment.ts @@ -0,0 +1,16 @@ +import { CreateFulfillmentLabelWorkflowDTO } from "./create-fulfillment" + +/** + * The attributes to update in the fulfillment. + */ +export interface CreateShipmentWorkflowInput { + /** + * The ID of the fulfillment + */ + id: string + + /** + * The labels associated with the fulfillment. + */ + labels?: CreateFulfillmentLabelWorkflowDTO[] +} diff --git a/packages/types/src/workflow/fulfillment/index.ts b/packages/types/src/workflow/fulfillment/index.ts index 97932e5585..73eff83f37 100644 --- a/packages/types/src/workflow/fulfillment/index.ts +++ b/packages/types/src/workflow/fulfillment/index.ts @@ -1,5 +1,8 @@ +export * from "./create-fulfillment" +export * from "./create-shipment" export * from "./create-shipping-options" -export * from "./service-zones" export * from "./delete-shipping-options" +export * from "./service-zones" export * from "./shipping-profiles" +export * from "./update-fulfillment" export * from "./update-shipping-options" diff --git a/packages/types/src/workflow/fulfillment/update-fulfillment.ts b/packages/types/src/workflow/fulfillment/update-fulfillment.ts new file mode 100644 index 0000000000..1a3eb09047 --- /dev/null +++ b/packages/types/src/workflow/fulfillment/update-fulfillment.ts @@ -0,0 +1,39 @@ +/** + * The attributes to update in the fulfillment. + */ +export interface UpdateFulfillmentWorkflowInput { + /** + * The ID of the fulfillment + */ + id: string + + /** + * The associated location's ID. + */ + location_id?: string + + /** + * The date the fulfillment was packed. + */ + packed_at?: Date | null + + /** + * The date the fulfillment was shipped. + */ + shipped_at?: Date | null + + /** + * The date the fulfillment was delivered. + */ + delivered_at?: Date | null + + /** + * The data necessary for the associated fulfillment provider to process the fulfillment. + */ + data?: Record | null + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null +}