feat(medusa): Stock location module (#2907)

* feat: stock location module
This commit is contained in:
Carlos R. L. Rodrigues
2023-01-04 13:11:59 -03:00
committed by GitHub
parent cc10c20f35
commit c07ffb6165
50 changed files with 2040 additions and 198 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/medusa-js": patch
"@medusajs/stock-location": major
---
Stock locations module added
@@ -24,6 +24,7 @@ import AdminReturnsResource from "./returns"
import AdminSalesChannelsResource from "./sales-channels"
import AdminShippingOptionsResource from "./shipping-options"
import AdminShippingProfilesResource from "./shipping-profiles"
import AdminStockLocationsResource from "./stock-locations"
import AdminStoresResource from "./store"
import AdminSwapsResource from "./swaps"
import AdminTaxRatesResource from "./tax-rates"
@@ -59,6 +60,7 @@ class Admin extends BaseResource {
public salesChannels = new AdminSalesChannelsResource(this.client)
public swaps = new AdminSwapsResource(this.client)
public shippingProfiles = new AdminShippingProfilesResource(this.client)
public stockLocations = new AdminStockLocationsResource(this.client)
public store = new AdminStoresResource(this.client)
public shippingOptions = new AdminShippingOptionsResource(this.client)
public regions = new AdminRegionsResource(this.client)
@@ -7,6 +7,8 @@ import {
AdminSalesChannelsListRes,
AdminDeleteSalesChannelsChannelProductsBatchReq,
AdminPostSalesChannelsChannelProductsBatchReq,
AdminPostSalesChannelsChannelStockLocationsReq,
AdminDeleteSalesChannelsChannelStockLocationsReq,
} from "@medusajs/medusa"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
@@ -121,6 +123,38 @@ class AdminSalesChannelsResource extends BaseResource {
const path = `/admin/sales-channels/${salesChannelId}/products/batch`
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* Add a location to a sales channel
* @experimental This feature is under development and may change in the future.
* To use this feature please enable featureflag `sales_channels` in your medusa backend project.
* @description Add a stock location to a SalesChannel
* @returns the Medusa SalesChannel
*/
addLocation(
salesChannelId: string,
payload: AdminPostSalesChannelsChannelStockLocationsReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminSalesChannelsRes> {
const path = `/admin/sales-channels/${salesChannelId}/stock-locations`
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* remove a location from a sales channel
* @experimental This feature is under development and may change in the future.
* To use this feature please enable featureflag `sales_channels` in your medusa backend project.
* @description Remove a stock location from a SalesChannel
* @returns an deletion result
*/
removeLocation(
salesChannelId: string,
payload: AdminDeleteSalesChannelsChannelStockLocationsReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminSalesChannelsRes> {
const path = `/admin/sales-channels/${salesChannelId}/stock-locations`
return this.client.request("DELETE", path, payload, {}, customHeaders)
}
}
export default AdminSalesChannelsResource
@@ -0,0 +1,78 @@
import {
AdminGetStockLocationsParams,
AdminStockLocationsRes,
AdminPostStockLocationsLocationReq,
AdminPostStockLocationsReq,
AdminStockLocationsListRes,
} from "@medusajs/medusa"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
import qs from "qs"
class AdminStockLocationsResource extends BaseResource {
/** retrieve an stock location
* @experimental This feature is under development and may change in the future.
* To use this feature please install @medusajs/stock-location
* @description gets a medusa stock location
* @returns a medusa stock location
*/
create(
payload: AdminPostStockLocationsReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminStockLocationsRes> {
const path = `/admin/stock-locations`
return this.client.request("POST", path, payload, {}, customHeaders)
}
/** retrieve an stock location
* @experimental This feature is under development and may change in the future.
* To use this feature please install @medusajs/stock-location
* @description gets a medusa stock location
* @returns a medusa stock location
*/
retrieve(
itemId: string,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminStockLocationsRes> {
const path = `/admin/stock-locations/${itemId}`
return this.client.request("GET", path, undefined, {}, customHeaders)
}
/** update an stock location
* @experimental This feature is under development and may change in the future.
* To use this feature please install @medusajs/stock-location
* @description updates an stock location
* @returns the updated medusa stock location
*/
update(
stockLocationId: string,
payload: AdminPostStockLocationsLocationReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminStockLocationsRes> {
const path = `/admin/stock-locations/${stockLocationId}`
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* Retrieve a list of stock locations
* @experimental This feature is under development and may change in the future.
* To use this feature please install @medusajs/stock-location
* @description Retrieve a list of stock locations
* @returns the list of stock locations as well as the pagination properties
*/
list(
query?: AdminGetStockLocationsParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminStockLocationsListRes> {
let path = `/admin/stock-locations`
if (query) {
const queryString = qs.stringify(query)
path += `?${queryString}`
}
return this.client.request("GET", path, undefined, {}, customHeaders)
}
}
export default AdminStockLocationsResource
+1
View File
@@ -44,6 +44,7 @@ export * from "./routes/admin/returns"
export * from "./routes/admin/sales-channels"
export * from "./routes/admin/shipping-options"
export * from "./routes/admin/shipping-profiles"
export * from "./routes/admin/stock-locations"
export * from "./routes/admin/store"
export * from "./routes/admin/swaps"
export * from "./routes/admin/tax-rates"
@@ -0,0 +1,15 @@
import { NextFunction, Request, Response } from "express"
export function checkRegisteredModules(services: {
[serviceName: string]: string
}): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
for (const service of Object.keys(services)) {
if (!req.scope.resolve(service, { allowUnregistered: true })) {
return next(new Error(services[service]))
}
}
next()
}
}
@@ -28,6 +28,7 @@ import returnRoutes from "./returns"
import salesChannelRoutes from "./sales-channels"
import shippingOptionRoutes from "./shipping-options"
import shippingProfileRoutes from "./shipping-profiles"
import stockLocationRoutes from "./stock-locations"
import storeRoutes from "./store"
import swapRoutes from "./swaps"
import taxRateRoutes from "./tax-rates"
@@ -98,6 +99,7 @@ export default (app, container, config) => {
salesChannelRoutes(route)
shippingOptionRoutes(route, featureFlagRouter)
shippingProfileRoutes(route)
stockLocationRoutes(route)
storeRoutes(route)
swapRoutes(route)
taxRateRoutes(route)
@@ -75,7 +75,7 @@ export default async (req, res) => {
result = await claimService.retrieve(result.claim_order_id)
}
const order = await orderService.retrieve(result.order_id, {
const order = await orderService.retrieve(result.order_id!, {
select: defaultAdminOrdersFields,
relations: defaultAdminOrdersRelations,
})
@@ -0,0 +1,112 @@
import { IsString } from "class-validator"
import { Request, Response } from "express"
import { EntityManager } from "typeorm"
import {
SalesChannelService,
SalesChannelLocationService,
} from "../../../../services"
/**
* @oas [post] /sales-channels/{id}/stock-locations
* operationId: "PostSalesChannelsSalesChannelStockLocation"
* summary: "Associate a stock location to a Sales Channel"
* description: "Associates a stock location to a Sales Channel."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Sales Channel.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostSalesChannelsChannelStockLocationsReq"
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.salesChannels.addLocation(sales_channel_id, {
* location_id: 'App'
* })
* .then(({ sales_channel }) => {
* console.log(sales_channel.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/sales-channels/{id}/stock-locations' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "locaton_id": "stock_location_id"
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Sales Channel
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* sales_channel:
* $ref: "#/components/schemas/SalesChannel"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const { validatedBody } = req as {
validatedBody: AdminPostSalesChannelsChannelStockLocationsReq
}
const salesChannelService: SalesChannelService = req.scope.resolve(
"salesChannelService"
)
const channelLocationService: SalesChannelLocationService = req.scope.resolve(
"salesChannelLocationService"
)
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
return await channelLocationService
.withTransaction(transactionManager)
.associateLocation(id, validatedBody.location_id)
})
const channel = await salesChannelService.retrieve(id)
res.status(200).json({ sales_channel: channel })
}
/**
* @schema AdminPostSalesChannelsChannelStockLocationsReq
* type: object
* required:
* - location_id
* properties:
* location_id:
* description: The ID of the stock location
* type: string
*/
export class AdminPostSalesChannelsChannelStockLocationsReq {
@IsString()
location_id: string
}
@@ -13,6 +13,8 @@ import { AdminPostSalesChannelsReq } from "./create-sales-channel"
import { AdminDeleteSalesChannelsChannelProductsBatchReq } from "./delete-products-batch"
import { AdminGetSalesChannelsParams } from "./list-sales-channels"
import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel"
import { AdminPostSalesChannelsChannelStockLocationsReq } from "./associate-stock-location"
import { AdminDeleteSalesChannelsChannelStockLocationsReq } from "./remove-stock-location"
const route = Router()
@@ -43,6 +45,16 @@ export default (app) => {
transformBody(AdminPostSalesChannelsSalesChannelReq),
middlewares.wrap(require("./update-sales-channel").default)
)
salesChannelRouter.post(
"/stock-locations",
transformBody(AdminPostSalesChannelsChannelStockLocationsReq),
middlewares.wrap(require("./associate-stock-location").default)
)
salesChannelRouter.delete(
"/stock-locations",
transformBody(AdminDeleteSalesChannelsChannelStockLocationsReq),
middlewares.wrap(require("./remove-stock-location").default)
)
salesChannelRouter.delete(
"/products/batch",
transformBody(AdminDeleteSalesChannelsChannelProductsBatchReq),
@@ -81,3 +93,5 @@ export * from "./delete-sales-channel"
export * from "./get-sales-channel"
export * from "./list-sales-channels"
export * from "./update-sales-channel"
export * from "./associate-stock-location"
export * from "./remove-stock-location"
@@ -0,0 +1,116 @@
import { IsString } from "class-validator"
import { Request, Response } from "express"
import { EntityManager } from "typeorm"
import { SalesChannelLocationService } from "../../../../services"
/**
* @oas [delete] /sales-channels/{id}/stock-locations
* operationId: "DeleteSalesChannelsSalesChannelStockLocation"
* summary: "Remove a stock location from a Sales Channel"
* description: "Removes a stock location from a Sales Channel."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Sales Channel.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminDeleteSalesChannelsChannelStockLocationsReq"
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.salesChannels.removeLocation(sales_channel_id, {
* location_id: 'App'
* })
* .then(({ sales_channel }) => {
* console.log(sales_channel.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request DELETE 'https://medusa-url.com/admin/sales-channels/{id}/stock-locations' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "locaton_id": "stock_location_id"
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Sales Channel
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* description: The ID of the removed stock location from a sales channel
* object:
* type: string
* description: The type of the object that was removed.
* default: stock-location
* deleted:
* type: boolean
* description: Whether or not the items were deleted.
* default: true
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const { validatedBody } = req as {
validatedBody: AdminDeleteSalesChannelsChannelStockLocationsReq
}
const channelLocationService: SalesChannelLocationService = req.scope.resolve(
"salesChannelLocationService"
)
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
await channelLocationService
.withTransaction(transactionManager)
.removeLocation(id, validatedBody.location_id)
})
res.json({
id,
object: "stock-location",
deleted: true,
})
}
/**
* @schema AdminDeleteSalesChannelsChannelStockLocationsReq
* type: object
* required:
* - location_id
* properties:
* location_id:
* description: The ID of the stock location
* type: string
*/
export class AdminDeleteSalesChannelsChannelStockLocationsReq {
@IsString()
location_id: string
}
@@ -0,0 +1,155 @@
import { Request, Response } from "express"
import { Type } from "class-transformer"
import { ValidateNested, IsOptional, IsString, IsObject } from "class-validator"
import { IStockLocationService } from "../../../../interfaces"
import { FindParams } from "../../../../types/common"
/**
* @oas [post] /stock-locations
* operationId: "PostStockLocations"
* summary: "Create a Stock Location"
* description: "Creates a Stock Location."
* x-authenticated: true
* parameters:
* - (query) expand {string} Comma separated list of relations to include in the results.
* - (query) fields {string} Comma separated list of fields to include in the results.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostStockLocationsReq"
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.stockLocations.create({
* name: 'Main Warehouse',
* location_id: 'sloc'
* })
* .then(({ stock_location }) => {
* console.log(stock_location.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/stock-locations' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "name": "App"
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Stock Location
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* stock_location:
* $ref: "#/components/schemas/StockLocationDTO"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const locationService: IStockLocationService = req.scope.resolve(
"stockLocationService"
)
const createdStockLocation = await locationService.create(
req.validatedBody as AdminPostStockLocationsReq
)
const stockLocation = await locationService.retrieve(
createdStockLocation.id,
req.retrieveConfig
)
res.status(200).json({ stock_location: stockLocation })
}
class StockLocationAddress {
@IsString()
address_1: string
@IsOptional()
@IsString()
address_2?: string
@IsOptional()
@IsString()
city?: string
@IsString()
country_code: string
@IsOptional()
@IsString()
phone?: string
@IsOptional()
@IsString()
postal_code?: string
@IsOptional()
@IsString()
province?: string
}
/**
* @schema AdminPostStockLocationsReq
* type: object
* required:
* - name
* properties:
* name:
* description: the name of the stock location
* type: string
* address_id:
* description: the stock location address ID
* type: string
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
* address:
* $ref: "#/components/schemas/StockLocationAddressInput"
*/
export class AdminPostStockLocationsReq {
@IsString()
name: string
@IsOptional()
@ValidateNested()
@Type(() => StockLocationAddress)
address?: StockLocationAddress
@IsOptional()
@IsString()
address_id?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostStockLocationsParams extends FindParams {}
@@ -0,0 +1,58 @@
import { IStockLocationService } from "../../../../interfaces"
import { Request, Response } from "express"
import { FindParams } from "../../../../types/common"
/**
* @oas [get] /stock-locations/{id}
* operationId: "GetStockLocationsStockLocation"
* summary: "Get a Stock Location"
* description: "Retrieves the Stock Location."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Stock Location.
* - (query) expand {string} Comma separated list of relations to include in the results.
* - (query) fields {string} Comma separated list of fields to include in the results.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.stockLocations.retrieve(stock_location_id)
* .then(({ stock_location }) => {
* console.log(stock_location.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/admin/stock-locations/{id}' \
* --header 'Authorization: Bearer {api_token}' \
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Stock Location
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* stock_location:
* $ref: "#/components/schemas/StockLocationDTO"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const locationService: IStockLocationService = req.scope.resolve(
"stockLocationService"
)
const stockLocation = await locationService.retrieve(id, req.retrieveConfig)
res.status(200).json({ stock_location: stockLocation })
}
export class AdminGetStockLocationsLocationParams extends FindParams {}
@@ -0,0 +1,101 @@
import { Router } from "express"
import "reflect-metadata"
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import { StockLocationDTO } from "../../../../types/stock-location"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { AdminGetStockLocationsParams } from "./list-stock-locations"
import { AdminGetStockLocationsLocationParams } from "./get-stock-location"
import {
AdminPostStockLocationsLocationParams,
AdminPostStockLocationsLocationReq,
} from "./update-stock-location"
import {
AdminPostStockLocationsParams,
AdminPostStockLocationsReq,
} from "./create-stock-location"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
const route = Router()
export default (app) => {
app.use(
"/stock-locations",
checkRegisteredModules({
stockLocationService:
"Stock Locations are not enabled. Please add a Stock Location module to enable this functionality.",
}),
route
)
route.get(
"/",
transformQuery(AdminGetStockLocationsParams, {
defaultFields: defaultAdminStockLocationFields,
defaultRelations: defaultAdminStockLocationRelations,
isList: true,
}),
middlewares.wrap(require("./list-stock-locations").default)
)
route.post(
"/",
transformQuery(AdminPostStockLocationsParams, {
defaultFields: defaultAdminStockLocationFields,
defaultRelations: defaultAdminStockLocationRelations,
isList: false,
}),
transformBody(AdminPostStockLocationsReq),
middlewares.wrap(require("./create-stock-location").default)
)
route.get(
"/:id",
transformQuery(AdminGetStockLocationsLocationParams, {
defaultFields: defaultAdminStockLocationFields,
defaultRelations: defaultAdminStockLocationRelations,
isList: false,
}),
middlewares.wrap(require("./get-stock-location").default)
)
route.post(
"/:id",
transformQuery(AdminPostStockLocationsLocationParams, {
defaultFields: defaultAdminStockLocationFields,
defaultRelations: defaultAdminStockLocationRelations,
isList: false,
}),
transformBody(AdminPostStockLocationsLocationReq),
middlewares.wrap(require("./update-stock-location").default)
)
return app
}
export const defaultAdminStockLocationFields: (keyof StockLocationDTO)[] = [
"id",
"name",
"address_id",
"metadata",
"created_at",
"updated_at",
]
export const defaultAdminStockLocationRelations = []
export type AdminStockLocationsRes = {
stock_location: StockLocationDTO
}
export type AdminStockLocationsDeleteRes = DeleteResponse
export type AdminStockLocationsListRes = PaginatedResponse & {
stock_locations: StockLocationDTO[]
}
export * from "./list-stock-locations"
export * from "./get-stock-location"
export * from "./create-stock-location"
export * from "./update-stock-location"
@@ -0,0 +1,179 @@
import { IsOptional } from "class-validator"
import { IsType } from "../../../../utils/validators/is-type"
import { IStockLocationService } from "../../../../interfaces"
import { extendedFindParamsMixin } from "../../../../types/common"
import { Request, Response } from "express"
/**
* @oas [get] /stock-locations
* operationId: "GetStockLocations"
* summary: "List Stock Locations"
* description: "Retrieves a list of stock locations"
* x-authenticated: true
* parameters:
* - (query) id {string} ID of the stock location
* - (query) name {string} Name of the stock location
* - (query) order {string} The field to order the results by.
* - in: query
* name: created_at
* description: Date comparison for when resulting collections were created.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: updated_at
* description: Date comparison for when resulting collections were updated.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: deleted_at
* description: Date comparison for when resulting collections were deleted.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - (query) offset=0 {integer} How many stock locations to skip in the result.
* - (query) limit=20 {integer} Limit the number of stock locations returned.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each stock location of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each stock location of the result.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.stockLocations.list()
* .then(({ stock_locations, limit, offset, count }) => {
* console.log(stock_locations.length);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/admin/stock-locations' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Sales Channel
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* stock_locations:
* type: array
* items:
* $ref: "#/components/schemas/StockLocationDTO"
* count:
* type: integer
* description: The total number of items available
* offset:
* type: integer
* description: The number of items skipped before these items
* limit:
* type: integer
* description: The number of items per page
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const stockLocationService: IStockLocationService = req.scope.resolve(
"stockLocationService"
)
const { filterableFields, listConfig } = req
const { skip, take } = listConfig
const [locations, count] = await stockLocationService.listAndCount(
filterableFields,
listConfig
)
res.status(200).json({
stock_locations: locations,
count,
offset: skip,
limit: take,
})
}
export class AdminGetStockLocationsParams extends extendedFindParamsMixin({
limit: 20,
offset: 0,
}) {
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
@IsOptional()
@IsType([String, [String]])
name?: string | string[]
@IsOptional()
@IsType([String, [String]])
address_id?: string | string[]
}
@@ -0,0 +1,154 @@
import { Request, Response } from "express"
import { Type } from "class-transformer"
import { ValidateNested, IsOptional, IsString, IsObject } from "class-validator"
import { IStockLocationService } from "../../../../interfaces"
import { FindParams } from "../../../../types/common"
/**
* @oas [post] /stock-locations/{id}
* operationId: "PostStockLocationsStockLocation"
* summary: "Update a Stock Location"
* description: "Updates a Stock Location."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Stock Location.
* - (query) expand {string} Comma separated list of relations to include in the results.
* - (query) fields {string} Comma separated list of fields to include in the results.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostStockLocationsLocationReq"
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.stockLocations.update(stock_location_id, {
* name: 'App'
* })
* .then(({ stock_location }) => {
* console.log(stock_location.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/stock-locations/{id}' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "name": "App"
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Stock Location
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* stock_location:
* $ref: "#/components/schemas/StockLocationDTO"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const locationService: IStockLocationService = req.scope.resolve(
"stockLocationService"
)
await locationService.update(
id,
req.validatedBody as AdminPostStockLocationsLocationReq
)
const stockLocation = await locationService.retrieve(id, req.retrieveConfig)
res.status(200).json({ stock_location: stockLocation })
}
class StockLocationAddress {
@IsString()
address_1: string
@IsOptional()
@IsString()
address_2?: string
@IsOptional()
@IsString()
city?: string
@IsString()
country_code: string
@IsOptional()
@IsString()
phone?: string
@IsOptional()
@IsString()
postal_code?: string
@IsOptional()
@IsString()
province?: string
}
/**
* @schema AdminPostStockLocationsLocationReq
* type: object
* properties:
* name:
* description: the name of the stock location
* type: string
* address_id:
* description: the stock location address ID
* type: string
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
* address:
* $ref: "#/components/schemas/StockLocationAddressInput"
*/
export class AdminPostStockLocationsLocationReq {
@IsOptional()
@IsString()
name?: string
@IsOptional()
@ValidateNested()
@Type(() => StockLocationAddress)
address?: StockLocationAddress
@IsOptional()
@IsString()
address_id?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostStockLocationsLocationParams extends FindParams {}
+3
View File
@@ -9,3 +9,6 @@ export * from "./types/global"
export * from "./models"
export * from "./services"
export * from "./utils"
export * from "./types/global"
export * from "./types/stock-location"
export * from "./types/common"
@@ -0,0 +1,3 @@
export interface IEventBusService {
emit(event: string, data: any): Promise<void>
}
@@ -1,3 +1,4 @@
export * from "./cache"
export * from "./event-bus"
export * from "./stock-location"
export * from "./inventory"
@@ -18,7 +18,10 @@ export interface IStockLocationService {
config?: FindConfig<StockLocationDTO>
): Promise<[StockLocationDTO[], number]>
retrieve(id: string): Promise<StockLocationDTO>
retrieve(
id: string,
config?: FindConfig<StockLocationDTO>
): Promise<StockLocationDTO>
create(input: CreateStockLocationInput): Promise<StockLocationDTO>
+1
View File
@@ -60,6 +60,7 @@ export * from "./region"
export * from "./return"
export * from "./return-item"
export * from "./return-reason"
export * from "./sales-channel-location"
export * from "./sales-channel"
export * from "./sales-channel-location"
export * from "./shipping-method"
+8 -8
View File
@@ -108,8 +108,8 @@ export class LineItem extends BaseEntity {
@Column()
title: string
@Column({ nullable: true })
description: string
@Column({ nullable: true, type: "text" })
description: string | null
@Column({ type: "text", nullable: true })
thumbnail: string | null
@@ -126,14 +126,14 @@ export class LineItem extends BaseEntity {
@Column({ default: true })
allow_discounts: boolean
@Column({ nullable: true })
has_shipping: boolean
@Column({ nullable: true, type: "boolean" })
has_shipping: boolean | null
@Column({ type: "int" })
unit_price: number
@Index()
@Column({ nullable: true })
@Column({ nullable: true, type: "text" })
variant_id: string | null
@ManyToOne(() => ProductVariant, { eager: true })
@@ -144,13 +144,13 @@ export class LineItem extends BaseEntity {
quantity: number
@Column({ nullable: true, type: "int" })
fulfilled_quantity: number
fulfilled_quantity: number | null
@Column({ nullable: true, type: "int" })
returned_quantity: number
returned_quantity: number | null
@Column({ nullable: true, type: "int" })
shipped_quantity: number
shipped_quantity: number | null
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
+14 -14
View File
@@ -41,24 +41,24 @@ export class Return extends BaseEntity {
items: ReturnItem[]
@Index()
@Column({ nullable: true })
swap_id: string
@Column({ nullable: true, type: "text" })
swap_id: string | null
@OneToOne(() => Swap, (swap) => swap.return_order)
@JoinColumn({ name: "swap_id" })
swap: Swap
@Index()
@Column({ nullable: true })
claim_order_id: string
@Column({ nullable: true, type: "text" })
claim_order_id: string | null
@OneToOne(() => ClaimOrder, (co) => co.return_order)
@JoinColumn({ name: "claim_order_id" })
claim_order: ClaimOrder
@Index()
@Column({ nullable: true })
order_id: string
@Column({ nullable: true, type: "text" })
order_id: string | null
@ManyToOne(() => Order, (o) => o.returns)
@JoinColumn({ name: "order_id" })
@@ -69,13 +69,13 @@ export class Return extends BaseEntity {
})
shipping_method: ShippingMethod
@Index()
@Column({ nullable: true, type: "text" })
location_id: string | null
@DbAwareColumn({ type: "jsonb", nullable: true })
shipping_data: Record<string, unknown>
@Index()
@Column({ nullable: true })
location_id: string
@Column({ type: "int" })
refund_amount: number
@@ -83,13 +83,13 @@ export class Return extends BaseEntity {
received_at: Date
@Column({ type: "boolean", nullable: true })
no_notification: boolean
no_notification: boolean | null
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
metadata: Record<string, unknown> | null
@Column({ nullable: true })
idempotency_key: string
@Column({ nullable: true, type: "text" })
idempotency_key: string | null
@BeforeInsert()
private beforeInsert(): void {
@@ -4,6 +4,7 @@ import { FeatureFlagEntity } from "../utils/feature-flag-decorators"
import { BaseEntity } from "../interfaces"
import { generateEntityId } from "../utils"
@FeatureFlagEntity("sales_channels")
export class SalesChannelLocation extends BaseEntity {
@Index()
@Column({ type: "text" })
+9 -9
View File
@@ -45,23 +45,23 @@ export class Store extends BaseEntity {
})
currencies: Currency[]
@Column({ nullable: true })
swap_link_template: string
@Column({ nullable: true, type: "text" })
swap_link_template: string | null
@Column({ nullable: true })
payment_link_template: string
@Column({ nullable: true, type: "text" })
payment_link_template: string | null
@Column({ nullable: true })
invite_link_template: string
@Column({ nullable: true, type: "text" })
invite_link_template: string | null
@Column({ nullable: true })
default_location_id: string
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
metadata: Record<string, unknown> | null
@FeatureFlagColumn("sales_channels", { nullable: true })
default_sales_channel_id: string
@FeatureFlagColumn("sales_channels", { nullable: true, type: "text" })
default_sales_channel_id: string | null
@FeatureFlagDecorators("sales_channels", [
OneToOne(() => SalesChannel),
@@ -2,7 +2,6 @@ import {
EntityRepository,
FindConditions,
FindManyOptions,
FindOperator,
OrderByCondition,
Repository,
} from "typeorm"
+21 -25
View File
@@ -37,27 +37,27 @@ import {
import { buildQuery, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import { validateEmail } from "../utils/is-email"
import CustomShippingOptionService from "./custom-shipping-option"
import CustomerService from "./customer"
import DiscountService from "./discount"
import EventBusService from "./event-bus"
import GiftCardService from "./gift-card"
import {
NewTotalsService,
ProductVariantInventoryService,
SalesChannelService,
} from "./index"
import LineItemService from "./line-item"
import LineItemAdjustmentService from "./line-item-adjustment"
import PaymentProviderService from "./payment-provider"
import ProductService from "./product"
import ProductVariantService from "./product-variant"
import RegionService from "./region"
import ShippingOptionService from "./shipping-option"
import StoreService from "./store"
import TaxProviderService from "./tax-provider"
import TotalsService from "./totals"
import { PaymentSessionInput } from "../types/payment"
import {
CustomShippingOptionService,
CustomerService,
DiscountService,
EventBusService,
GiftCardService,
LineItemService,
LineItemAdjustmentService,
NewTotalsService,
PaymentProviderService,
ProductService,
ProductVariantService,
ProductVariantInventoryService,
RegionService,
ShippingOptionService,
StoreService,
TaxProviderService,
TotalsService,
SalesChannelService,
} from "."
type InjectedDependencies = {
manager: EntityManager
@@ -584,11 +584,7 @@ class CartService extends TransactionBaseService {
{ sales_channel_id }: { sales_channel_id: string | null },
lineItem: LineItemValidateData
): Promise<boolean> {
if (!sales_channel_id) {
return true
}
if (!lineItem.variant_id) {
if (!sales_channel_id || !lineItem.variant_id) {
return true
}
+1 -1
View File
@@ -77,7 +77,7 @@ class ClaimItemService extends TransactionBaseService {
)
}
if (lineItem.fulfilled_quantity < quantity) {
if (lineItem.fulfilled_quantity! < quantity) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot claim more of an item than has been fulfilled."
+1 -1
View File
@@ -332,8 +332,8 @@ export default class ClaimService extends TransactionBaseService {
lines as LineItem[]
)
}
let newItems: LineItem[] = []
if (isDefined(additional_items)) {
newItems = await Promise.all(
additional_items.map(async (i) =>
+2 -2
View File
@@ -145,7 +145,7 @@ class FulfillmentService extends TransactionBaseService {
return null
}
if (quantity > item.quantity - item.fulfilled_quantity) {
if (quantity > item.quantity - item.fulfilled_quantity!) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot fulfill more items than have been purchased"
@@ -285,7 +285,7 @@ class FulfillmentService extends TransactionBaseService {
for (const fItem of fulfillment.items) {
const item = await lineItemServiceTx.retrieve(fItem.item_id)
const fulfilledQuantity = item.fulfilled_quantity - fItem.quantity
const fulfilledQuantity = item.fulfilled_quantity! - fItem.quantity
await lineItemServiceTx.update(item.id, {
fulfilled_quantity: fulfilledQuantity,
})
+21 -17
View File
@@ -30,22 +30,26 @@ import { UpdateOrderInput } from "../types/orders"
import { CreateShippingMethodDto } from "../types/shipping-options"
import { buildQuery, isString, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import CartService from "./cart"
import CustomerService from "./customer"
import DiscountService from "./discount"
import DraftOrderService from "./draft-order"
import EventBusService from "./event-bus"
import FulfillmentService from "./fulfillment"
import FulfillmentProviderService from "./fulfillment-provider"
import GiftCardService from "./gift-card"
import LineItemService from "./line-item"
import PaymentProviderService from "./payment-provider"
import RegionService from "./region"
import ShippingOptionService from "./shipping-option"
import ShippingProfileService from "./shipping-profile"
import TotalsService from "./totals"
import ProductVariantInventoryService from "./product-variant-inventory"
import { NewTotalsService, TaxProviderService } from "./index"
import {
CartService,
CustomerService,
DiscountService,
DraftOrderService,
EventBusService,
FulfillmentService,
FulfillmentProviderService,
GiftCardService,
ProductVariantInventoryService,
LineItemService,
PaymentProviderService,
RegionService,
ShippingOptionService,
ShippingProfileService,
TotalsService,
NewTotalsService,
TaxProviderService,
} from "."
export const ORDER_CART_ALREADY_EXISTS_ERROR = "Order from cart already exists"
@@ -1264,7 +1268,7 @@ class OrderService extends TransactionBaseService {
return null
}
if (quantity > item.quantity - item.fulfilled_quantity) {
if (quantity > item.quantity - item.fulfilled_quantity!) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot fulfill more items than have been purchased"
+18 -14
View File
@@ -1,6 +1,5 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import { DeepPartial, EntityManager } from "typeorm"
import { ProductVariantInventoryService } from "."
import { TransactionBaseService } from "../interfaces"
import {
FulfillmentStatus,
@@ -17,13 +16,17 @@ import { FindConfig, Selector } from "../types/common"
import { OrdersReturnItem } from "../types/orders"
import { CreateReturnInput, UpdateReturnInput } from "../types/return"
import { buildQuery, setMetadata } from "../utils"
import FulfillmentProviderService from "./fulfillment-provider"
import LineItemService from "./line-item"
import OrderService from "./order"
import ReturnReasonService from "./return-reason"
import ShippingOptionService from "./shipping-option"
import TaxProviderService from "./tax-provider"
import TotalsService from "./totals"
import {
FulfillmentProviderService,
ProductVariantInventoryService,
LineItemService,
OrderService,
ReturnReasonService,
ShippingOptionService,
TaxProviderService,
TotalsService,
} from "."
type InjectedDependencies = {
manager: EntityManager
@@ -226,7 +229,7 @@ class ReturnService extends TransactionBaseService {
)
}
const returnable = item.quantity - item.returned_quantity
const returnable = item.quantity - item.returned_quantity!
if (quantity > returnable) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
@@ -590,7 +593,7 @@ class ReturnService extends TransactionBaseService {
const order = await this.orderService_
.withTransaction(manager)
.retrieve(orderId, {
.retrieve(orderId!, {
relations: [
"items",
"returns",
@@ -671,14 +674,15 @@ class ReturnService extends TransactionBaseService {
})
}
const inventoryServiceTx =
const productVarInventoryTx =
this.productVariantInventoryService_.withTransaction(manager)
for (const line of newLines) {
const orderItem = order.items.find((i) => i.id === line.item_id)
if (orderItem && orderItem?.variant_id) {
await inventoryServiceTx.adjustInventory(
if (orderItem && orderItem.variant_id) {
await productVarInventoryTx.adjustInventory(
orderItem.variant_id,
returnObj.location_id,
returnObj.location_id!,
line.received_quantity
)
}
@@ -11,13 +11,17 @@ type InjectedDependencies = {
manager: EntityManager
}
/**
* Service for managing the stock locations of sales channels
*/
class SalesChannelLocationService extends TransactionBaseService {
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly salesChannelService_: SalesChannelService
protected readonly eventBusService_: EventBusService
protected readonly stockLocationService_: IStockLocationService
protected readonly eventBusService: EventBusService
protected readonly stockLocationService: IStockLocationService
constructor({
salesChannelService,
@@ -30,14 +34,15 @@ class SalesChannelLocationService extends TransactionBaseService {
this.manager_ = manager
this.salesChannelService_ = salesChannelService
this.eventBusService_ = eventBusService
this.stockLocationService_ = stockLocationService
this.eventBusService = eventBusService
this.stockLocationService = stockLocationService
}
/**
* Removes location from sales channel
* @param salesChannelId sales channel id
* @param locationId location id
* Removes an association between a sales channel and a stock location.
* @param {string} salesChannelId - The ID of the sales channel.
* @param {string} locationId - The ID of the stock location.
* @returns {Promise<void>} A promise that resolves when the association has been removed.
*/
async removeLocation(
salesChannelId: string,
@@ -51,9 +56,10 @@ class SalesChannelLocationService extends TransactionBaseService {
}
/**
* Links location to sales channel
* @param salesChannelId sales channel id
* @param locationId location id
* Associates a sales channel with a stock location.
* @param {string} salesChannelId - The ID of the sales channel.
* @param {string} locationId - The ID of the stock location.
* @returns {Promise<void>} A promise that resolves when the association has been created.
*/
async associateLocation(
salesChannelId: string,
@@ -63,7 +69,7 @@ class SalesChannelLocationService extends TransactionBaseService {
const salesChannel = await this.salesChannelService_
.withTransaction(manager)
.retrieve(salesChannelId)
const stockLocation = await this.stockLocationService_.retrieve(locationId)
const stockLocation = await this.stockLocationService.retrieve(locationId)
const salesChannelLocation = manager.create(SalesChannelLocation, {
sales_channel_id: salesChannel.id,
@@ -74,9 +80,9 @@ class SalesChannelLocationService extends TransactionBaseService {
}
/**
* Lists all locations associated with sales channel by id
* @param salesChannelId sales channel id
* @returns list of location ids associated with sales channel
* Lists the stock locations associated with a sales channel.
* @param {string} salesChannelId - The ID of the sales channel.
* @returns {Promise<string[]>} A promise that resolves with an array of location IDs.
*/
async listLocations(salesChannelId: string): Promise<string[]> {
const manager = this.transactionManager_ || this.manager_
+210 -7
View File
@@ -1,21 +1,132 @@
import { StringComparisonOperator } from "./common"
/**
* @schema StockLocationAddressDTO
* title: "Stock Location Address"
* description: "Represents a Stock Location Address"
* type: object
* required:
* - address_1
* - country_code
* - created_at
* - updated_at
* properties:
* id:
* type: string
* description: The stock location address' ID
* example: laddr_51G4ZW853Y6TFXWPG5ENJ81X42
* address_1:
* type: string
* description: Stock location address
* example: 35, Jhon Doe Ave
* address_2:
* type: string
* description: Stock location address' complement
* example: apartment 4432
* city:
* type: string
* description: Stock location address' city
* example: Mexico city
* country_code:
* type: string
* description: Stock location address' country
* example: MX
* phone:
* type: string
* description: Stock location address' phone number
* example: +1 555 61646
* postal_code:
* type: string
* description: Stock location address' postal code
* example: HD3-1G8
* province:
* type: string
* description: Stock location address' province
* example: Sinaloa
* created_at:
* type: string
* description: "The date with timezone at which the resource was created."
* format: date-time
* updated_at:
* type: string
* description: "The date with timezone at which the resource was updated."
* format: date-time
* deleted_at:
* type: string
* description: "The date with timezone at which the resource was deleted."
* format: date-time
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
*/
export type StockLocationAddressDTO = {
id?: string
address_1: string
address_2?: string
city?: string
country_code?: string
phone?: string
postal_code?: string
province?: string
address_2?: string | null
country_code: string
city?: string | null
phone?: string | null
postal_code?: string | null
province?: string | null
metadata?: Record<string, unknown> | null
created_at: string | Date
updated_at: string | Date
deleted_at: string | Date | null
}
/**
* @schema StockLocationDTO
* title: "Stock Location"
* description: "Represents a Stock Location"
* type: object
* required:
* - id
* - name
* - address_id
* - created_at
* - updated_at
* properties:
* id:
* type: string
* description: The stock location's ID
* example: sloc_51G4ZW853Y6TFXWPG5ENJ81X42
* address_id:
* type: string
* description: Stock location address' ID
* example: laddr_05B2ZE853Y6FTXWPW85NJ81A44
* name:
* type: string
* description: The name of the stock location
* example: Main Warehouse
* address:
* description: "The Address of the Stock Location"
* allOf:
* - $ref: "#/components/schemas/StockLocationAddressDTO"
* - type: object
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
* created_at:
* type: string
* description: "The date with timezone at which the resource was created."
* format: date-time
* updated_at:
* type: string
* description: "The date with timezone at which the resource was updated."
* format: date-time
* deleted_at:
* type: string
* description: "The date with timezone at which the resource was deleted."
* format: date-time
*/
export type StockLocationDTO = {
id: string
name: string
metadata: Record<string, unknown> | null
address_id: string
address?: StockLocationAddressDTO
created_at: string | Date
updated_at: string | Date
deleted_at: string | Date | null
@@ -26,23 +137,115 @@ export type FilterableStockLocationProps = {
name?: string | string[] | StringComparisonOperator
}
/**
* @schema StockLocationAddressInput
* title: "Stock Location Address Input"
* description: "Represents a Stock Location Address Input"
* type: object
* required:
* - address_1
* - country_code
* properties:
* address_1:
* type: string
* description: Stock location address
* example: 35, Jhon Doe Ave
* address_2:
* type: string
* description: Stock location address' complement
* example: apartment 4432
* city:
* type: string
* description: Stock location address' city
* example: Mexico city
* country_code:
* type: string
* description: Stock location address' country
* example: MX
* phone:
* type: string
* description: Stock location address' phone number
* example: +1 555 61646
* postal_code:
* type: string
* description: Stock location address' postal code
* example: HD3-1G8
* province:
* type: string
* description: Stock location address' province
* example: Sinaloa
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
*/
export type StockLocationAddressInput = {
address_1: string
address_2?: string
country_code: string
city?: string
country_code?: string
phone?: string
province?: string
postal_code?: string
metadata?: Record<string, unknown>
}
/**
* @schema CreateStockLocationInput
* title: "Create Stock Location Input"
* description: "Represents the Input to create a Stock Location"
* type: object
* required:
* - name
* properties:
* name:
* type: string
* description: The stock location name
* address_id:
* type: string
* description: The Stock location address ID
* address:
* description: Stock location address object
* allOf:
* - $ref: "#/components/schemas/StockLocationAddressInput"
* - type: object
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
*/
export type CreateStockLocationInput = {
name: string
address_id?: string
address?: string | StockLocationAddressInput
metadata?: Record<string, unknown>
}
/**
* @schema UpdateStockLocationInput
* title: "Update Stock Location Input"
* description: "Represents the Input to update a Stock Location"
* type: object
* properties:
* name:
* type: string
* description: The stock location name
* address_id:
* type: string
* description: The Stock location address ID
* address:
* description: Stock location address object
* allOf:
* - $ref: "#/components/schemas/StockLocationAddressInput"
* - type: object
* metadata:
* type: object
* description: An optional key-value map with additional details
* example: {car: "white"}
*/
export type UpdateStockLocationInput = {
name?: string
address_id?: string
address?: StockLocationAddressInput
metadata?: Record<string, unknown>
}
+6
View File
@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql
+10
View File
@@ -0,0 +1,10 @@
src
.turbo
.prettierrc
.env
.babelrc.js
.eslintrc
.gitignore
ormconfig.json
tsconfig.json
jest.config.md
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsConfig: "tsconfig.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@medusajs/stock-location",
"version": "1.0.0",
"description": "Stock Location Module for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/stock-location"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/medusa": "^1.7.1",
"cross-env": "^5.2.1",
"jest": "^25.5.2",
"ts-jest": "^25.5.1",
"typescript": "^4.4.4"
},
"scripts": {
"watch": "tsc --build --watch",
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"test": "jest --passWithNoTests",
"test:unit": "jest --passWithNoTests"
},
"peerDependencies": {
"@medusajs/medusa": "^1.7.1",
"medusa-interfaces": "1.3.3",
"typeorm": "^0.2.31"
}
}
+1
View File
@@ -0,0 +1 @@
export const CONNECTION_NAME = "stock_location_connection"
+7
View File
@@ -0,0 +1,7 @@
import ConnectionLoader from "./loaders/connection"
import StockLocationService from "./services/stock-location"
import * as SchemaMigration from "./migrations/schema-migrations/1665749860179-setup"
export const service = StockLocationService
export const migrations = [SchemaMigration]
export const loaders = [ConnectionLoader]
@@ -0,0 +1,22 @@
import { ConfigModule } from "@medusajs/medusa"
import { ConnectionOptions, createConnection } from "typeorm"
import { CONNECTION_NAME } from "../config"
import { StockLocation, StockLocationAddress } from "../models"
export default async ({
configModule,
}: {
configModule: ConfigModule
}): Promise<void> => {
await createConnection({
name: CONNECTION_NAME,
type: configModule.projectConfig.database_type,
url: configModule.projectConfig.database_url,
database: configModule.projectConfig.database_database,
schema: configModule.projectConfig.database_schema,
extra: configModule.projectConfig.database_extra || {},
entities: [StockLocation, StockLocationAddress],
logging: configModule.projectConfig.database_logging || false,
} as ConnectionOptions)
}
@@ -0,0 +1,94 @@
import { ConfigModule } from "@medusajs/medusa"
import {
createConnection,
ConnectionOptions,
MigrationInterface,
QueryRunner,
} from "typeorm"
import { CONNECTION_NAME } from "../../config"
export const up = async ({ configModule }: { configModule: ConfigModule }) => {
const connection = await createConnection({
name: CONNECTION_NAME,
type: configModule.projectConfig.database_type,
url: configModule.projectConfig.database_url,
database: configModule.projectConfig.database_database,
schema: configModule.projectConfig.database_schema,
extra: configModule.projectConfig.database_extra || {},
migrations: [setup1665749860179],
logging: true,
} as ConnectionOptions)
await connection.runMigrations()
}
export const down = async ({
configModule,
}: {
configModule: ConfigModule
}) => {
const connection = await createConnection({
name: CONNECTION_NAME,
type: configModule.projectConfig.database_type,
url: configModule.projectConfig.database_url,
database: configModule.projectConfig.database_database,
schema: configModule.projectConfig.database_schema,
extra: configModule.projectConfig.database_extra || {},
migrations: [setup1665749860179],
logging: true,
} as ConnectionOptions)
await connection.undoLastMigration({ transaction: "all" })
}
export class setup1665749860179 implements MigrationInterface {
name = "setup1665749860179"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "stock_location_address"
(
"id" CHARACTER VARYING NOT NULL,
"created_at" TIMESTAMP WITH TIME zone NOT NULL DEFAULT Now(),
"updated_at" TIMESTAMP WITH TIME zone NOT NULL DEFAULT Now(),
"deleted_at" TIMESTAMP WITH TIME zone,
"address_1" TEXT NOT NULL,
"address_2" TEXT,
"city" TEXT,
"country_code" TEXT NOT NULL,
"phone" TEXT,
"province" TEXT,
"postal_code" TEXT,
"metadata" JSONB,
CONSTRAINT "PK_b79bc27285bede680501b7b81a5" PRIMARY KEY ("id")
);
CREATE INDEX "IDX_stock_location_address_country_code" ON "stock_location_address" ("country_code");
CREATE TABLE "stock_location"
(
"id" CHARACTER VARYING NOT NULL,
"created_at" TIMESTAMP WITH time zone NOT NULL DEFAULT Now(),
"updated_at" TIMESTAMP WITH time zone NOT NULL DEFAULT Now(),
"deleted_at" TIMESTAMP WITH time zone,
"name" TEXT NOT NULL,
"address_id" TEXT NOT NULL,
"metadata" JSONB,
CONSTRAINT "PK_adf770067d0df1421f525fa25cc" PRIMARY KEY ("id")
);
CREATE INDEX "IDX_stock_location_address_id" ON "stock_location" ("address_id");
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX "IDX_stock_location_address_id";
DROP TABLE "stock_location";
DROP INDEX "IDX_stock_location_address_country_code";
DROP TABLE "stock_location_address";
`)
}
}
@@ -0,0 +1,2 @@
export * from "./stock-location"
export * from "./stock-location-address"
@@ -0,0 +1,35 @@
import { BeforeInsert, Column, Entity, Index } from "typeorm"
import { SoftDeletableEntity, generateEntityId } from "@medusajs/medusa"
@Entity()
export class StockLocationAddress extends SoftDeletableEntity {
@Column({ type: "text" })
address_1: string
@Column({ type: "text", nullable: true })
address_2: string | null
@Column({ type: "text", nullable: true })
city: string | null
@Index()
@Column({ type: "text" })
country_code: string
@Column({ type: "text", nullable: true })
phone: string | null
@Column({ type: "text", nullable: true })
province: string | null
@Column({ type: "text", nullable: true })
postal_code: string | null
@Column({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> | null
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "laddr")
}
}
@@ -0,0 +1,33 @@
import {
BeforeInsert,
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
} from "typeorm"
import { SoftDeletableEntity, generateEntityId } from "@medusajs/medusa"
import { StockLocationAddress } from "."
@Entity()
export class StockLocation extends SoftDeletableEntity {
@Column({ type: "text" })
name: string
@Index()
@Column({ type: "text" })
address_id: string
@ManyToOne(() => StockLocationAddress)
@JoinColumn({ name: "address_id" })
address: StockLocationAddress | null
@Column({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> | null
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "sloc")
}
}
@@ -0,0 +1 @@
export { default as StockLocationService } from "./stock-location"
@@ -0,0 +1,253 @@
import { getConnection, EntityManager } from "typeorm"
import { isDefined, MedusaError } from "medusa-core-utils"
import {
FindConfig,
buildQuery,
FilterableStockLocationProps,
CreateStockLocationInput,
UpdateStockLocationInput,
StockLocationAddressInput,
IEventBusService,
setMetadata,
} from "@medusajs/medusa"
import { StockLocation, StockLocationAddress } from "../models"
import { CONNECTION_NAME } from "../config"
type InjectedDependencies = {
eventBusService: IEventBusService
}
/**
* Service for managing stock locations.
*/
export default class StockLocationService {
static Events = {
CREATED: "stock-location.created",
UPDATED: "stock-location.updated",
DELETED: "stock-location.deleted",
}
protected readonly manager_: EntityManager
protected readonly eventBusService_: IEventBusService
constructor({ eventBusService }: InjectedDependencies) {
this.eventBusService_ = eventBusService
}
private getManager(): EntityManager {
const connection = getConnection(CONNECTION_NAME)
return connection.manager
}
/**
* Lists all stock locations that match the given selector.
* @param {FilterableStockLocationProps} [selector={}] - Properties to filter by.
* @param {FindConfig} [config={ relations: [], skip: 0, take: 10 }] - Additional configuration for the query.
* @return {Promise<StockLocation[]>} A list of stock locations.
*/
async list(
selector: FilterableStockLocationProps = {},
config: FindConfig<StockLocation> = { relations: [], skip: 0, take: 10 }
): Promise<StockLocation[]> {
const manager = this.getManager()
const locationRepo = manager.getRepository(StockLocation)
const query = buildQuery(selector, config)
return await locationRepo.find(query)
}
/**
* Lists all stock locations that match the given selector and returns the count of matching stock locations.
* @param {FilterableStockLocationProps} [selector={}] - Properties to filter by.
* @param {FindConfig} [config={ relations: [], skip: 0, take: 10 }] - Additional configuration for the query.
* @return {Promise<[StockLocation[], number]>} A list of stock locations and the count of matching stock locations.
*/
async listAndCount(
selector: FilterableStockLocationProps = {},
config: FindConfig<StockLocation> = { relations: [], skip: 0, take: 10 }
): Promise<[StockLocation[], number]> {
const manager = this.getManager()
const locationRepo = manager.getRepository(StockLocation)
const query = buildQuery(selector, config)
return await locationRepo.findAndCount(query)
}
/**
* Retrieves a stock location by its ID.
* @param {string} stockLocationId - The ID of the stock location.
* @param {FindConfig} [config={}] - Additional configuration for the query.
* @return {Promise<StockLocation>} The stock location.
* @throws {MedusaError} If the stock location ID is not defined.
* @throws {MedusaError} If the stock location with the given ID was not found.
*/
async retrieve(
stockLocationId: string,
config: FindConfig<StockLocation> = {}
): Promise<StockLocation> {
if (!isDefined(stockLocationId)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`"stockLocationId" must be defined`
)
}
const manager = this.getManager()
const locationRepo = manager.getRepository(StockLocation)
const query = buildQuery({ id: stockLocationId }, config)
const loc = await locationRepo.findOne(query)
if (!loc) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`StockLocation with id ${stockLocationId} was not found`
)
}
return loc
}
/**
* Creates a new stock location.
* @param {CreateStockLocationInput} data - The input data for creating a stock location.
* @returns {Promise<StockLocation>} - The created stock location.
*/
async create(data: CreateStockLocationInput): Promise<StockLocation> {
const defaultManager = this.getManager()
return await defaultManager.transaction(async (manager) => {
const locationRepo = manager.getRepository(StockLocation)
const loc = locationRepo.create({
name: data.name,
})
if (isDefined(data.address) || isDefined(data.address_id)) {
if (typeof data.address === "string" || data.address_id) {
const addrId = (data.address ?? data.address_id) as string
const address = await this.retrieve(addrId, {
select: ["id"],
})
loc.address_id = address.id
} else {
const locAddressRepo = manager.getRepository(StockLocationAddress)
const locAddress = locAddressRepo.create(data.address!)
const addressResult = await locAddressRepo.save(locAddress)
loc.address_id = addressResult.id
}
}
const { metadata } = data
if (metadata) {
loc.metadata = setMetadata(loc, metadata)
}
const result = await locationRepo.save(loc)
await this.eventBusService_.emit(StockLocationService.Events.CREATED, {
id: result.id,
})
return result
})
}
/**
* Updates an existing stock location.
* @param {string} stockLocationId - The ID of the stock location to update.
* @param {UpdateStockLocationInput} updateData - The update data for the stock location.
* @returns {Promise<StockLocation>} - The updated stock location.
*/
async update(
stockLocationId: string,
updateData: UpdateStockLocationInput
): Promise<StockLocation> {
const defaultManager = this.getManager()
return await defaultManager.transaction(async (manager) => {
const locationRepo = manager.getRepository(StockLocation)
const item = await this.retrieve(stockLocationId)
const { address, metadata, ...data } = updateData
if (address) {
if (item.address_id) {
await this.updateAddress(item.address_id, address, { manager })
} else {
const locAddressRepo = manager.getRepository(StockLocationAddress)
const locAddress = locAddressRepo.create(address)
const addressResult = await locAddressRepo.save(locAddress)
data.address_id = addressResult.id
}
}
if (metadata) {
item.metadata = setMetadata(item, metadata)
}
const toSave = locationRepo.merge(item, data)
await locationRepo.save(toSave)
await this.eventBusService_.emit(StockLocationService.Events.UPDATED, {
id: stockLocationId,
})
return item
})
}
/**
* Updates an address for a stock location.
* @param {string} addressId - The ID of the address to update.
* @param {StockLocationAddressInput} address - The update data for the address.
* @param {Object} context - Context for the update.
* @param {EntityManager} context.manager - The entity manager to use for the update.
* @returns {Promise<StockLocationAddress>} - The updated stock location address.
*/
protected async updateAddress(
addressId: string,
address: StockLocationAddressInput,
context: { manager?: EntityManager } = {}
): Promise<StockLocationAddress> {
const manager = context.manager || this.getManager()
const locationAddressRepo = manager.getRepository(StockLocationAddress)
const existingAddress = await locationAddressRepo.findOne(addressId)
if (!existingAddress) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`StockLocation address with id ${addressId} was not found`
)
}
const toSave = locationAddressRepo.merge(existingAddress, address)
const { metadata } = address
if (metadata) {
toSave.metadata = setMetadata(toSave, metadata)
}
return await locationAddressRepo.save(toSave)
}
/**
* Deletes a stock location.
* @param {string} id - The ID of the stock location to delete.
* @returns {Promise<void>} - An empty promise.
*/
async delete(id: string): Promise<void> {
const manager = this.getManager()
const locationRepo = manager.getRepository(StockLocation)
await locationRepo.softRemove({ id })
await this.eventBusService_.emit(StockLocationService.Events.DELETED, {
id,
})
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": ["./src/**/*", "index.d.ts"],
"exclude": [
"./dist/**/*",
"./src/**/__tests__",
"./src/**/__mocks__",
"node_modules"
]
}
+113 -83
View File
@@ -3,111 +3,141 @@
const fs = require("fs")
const OAS = require("oas-normalize")
const swaggerInline = require("swagger-inline")
const { exec } = require("child_process");
const { exec } = require("child_process")
const isDryRun = process.argv.indexOf('--dry-run') !== -1;
const isDryRun = process.argv.indexOf("--dry-run") !== -1
// Storefront API
swaggerInline(
["./packages/medusa/src/models", "./packages/medusa/src/api/middlewares" , "./packages/medusa/src/api/routes/store"],
[
"./packages/medusa/src/models",
"./packages/medusa/src/types",
"./packages/medusa/src/api/middlewares",
"./packages/medusa/src/api/routes/store",
],
{
base: "./docs/api/store-spec3-base.yaml",
}
).then((gen) => {
const oas = new OAS(gen)
oas
.validate(true)
.then(() => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/store-spec3.json", gen)
}
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
})
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
});
)
.then((gen) => {
const oas = new OAS(gen)
oas
.validate(true)
.then(() => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/store-spec3.json", gen)
}
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
})
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
})
swaggerInline(
["./packages/medusa/src/models", "./packages/medusa/src/api/middlewares" , "./packages/medusa/src/api/routes/store"],
[
"./packages/medusa/src/models",
"./packages/medusa/src/types",
"./packages/medusa/src/api/middlewares",
"./packages/medusa/src/api/routes/store",
],
{
base: "./docs/api/store-spec3-base.yaml",
format: "yaml",
}
).then((gen) => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/store-spec3.yaml", gen)
exec("rm -rf docs/api/store/ && yarn run -- redocly split docs/api/store-spec3.yaml --outDir=docs/api/store/", (error, stdout, stderr) => {
if (error) {
throw new Error(`error: ${error.message}`)
}
console.log(`${stderr || stdout}`);
});
} else {
console.log('No errors occurred while generating Store API Reference');
}
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
})
)
.then((gen) => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/store-spec3.yaml", gen)
exec(
"rm -rf docs/api/store/ && yarn run -- redocly split docs/api/store-spec3.yaml --outDir=docs/api/store/",
(error, stdout, stderr) => {
if (error) {
throw new Error(`error: ${error.message}`)
}
console.log(`${stderr || stdout}`)
}
)
} else {
console.log("No errors occurred while generating Store API Reference")
}
})
.catch((err) => {
console.log("Error in store")
console.error(err)
process.exit(1)
})
// Admin API
swaggerInline(
["./packages/medusa/src/models", "./packages/medusa/src/api/middlewares" , "./packages/medusa/src/api/routes/admin"],
[
"./packages/medusa/src/models",
"./packages/medusa/src/types",
"./packages/medusa/src/api/middlewares",
"./packages/medusa/src/api/routes/admin",
],
{
base: "./docs/api/admin-spec3-base.yaml",
}
).then((gen) => {
const oas = new OAS(gen)
oas
.validate(true)
.then(() => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/admin-spec3.json", gen)
}
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
)
.then((gen) => {
const oas = new OAS(gen)
oas
.validate(true)
.then(() => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/admin-spec3.json", gen)
}
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
swaggerInline(
["./packages/medusa/src/models", "./packages/medusa/src/api/middlewares" , "./packages/medusa/src/api/routes/admin"],
[
"./packages/medusa/src/models",
"./packages/medusa/src/types",
"./packages/medusa/src/api/middlewares",
"./packages/medusa/src/api/routes/admin",
],
{
base: "./docs/api/admin-spec3-base.yaml",
format: "yaml",
}
).then((gen) => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/admin-spec3.yaml", gen)
exec("rm -rf docs/api/admin/ && yarn run -- redocly split docs/api/admin-spec3.yaml --outDir=docs/api/admin/", (error, stdout, stderr) => {
if (error) {
throw new Error(`error: ${error.message}`)
}
console.log(`${stderr || stdout}`);
return;
});
} else {
console.log('No errors occurred while generating Admin API Reference');
}
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
)
.then((gen) => {
if (!isDryRun) {
fs.writeFileSync("./docs/api/admin-spec3.yaml", gen)
exec(
"rm -rf docs/api/admin/ && yarn run -- redocly split docs/api/admin-spec3.yaml --outDir=docs/api/admin/",
(error, stdout, stderr) => {
if (error) {
throw new Error(`error: ${error.message}`)
}
console.log(`${stderr || stdout}`)
return
}
)
} else {
console.log("No errors occurred while generating Admin API Reference")
}
})
.catch((err) => {
console.log("Error in admin")
console.error(err)
process.exit(1)
})
+16
View File
@@ -4466,6 +4466,22 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/stock-location@workspace:packages/stock-location":
version: 0.0.0-use.local
resolution: "@medusajs/stock-location@workspace:packages/stock-location"
dependencies:
"@medusajs/medusa": ^1.7.1
cross-env: ^5.2.1
jest: ^25.5.2
ts-jest: ^25.5.1
typescript: ^4.4.4
peerDependencies:
"@medusajs/medusa": ^1.7.1
medusa-interfaces: 1.3.3
typeorm: ^0.2.31
languageName: unknown
linkType: soft
"@microsoft/fetch-event-source@npm:2.0.1":
version: 2.0.1
resolution: "@microsoft/fetch-event-source@npm:2.0.1"