stock-control-multi-location.e2e-spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { manualFulfillmentHandler, mergeConfig } from '@vendure/core';
  3. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  4. import gql from 'graphql-tag';
  5. import path from 'path';
  6. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  7. import { initialData } from '../../../e2e-common/e2e-initial-data';
  8. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  9. import { testSuccessfulPaymentMethod, twoStagePaymentMethod } from './fixtures/test-payment-methods';
  10. import {
  11. TestMultiLocationStockPlugin,
  12. TestStockDisplayStrategy,
  13. TestStockLocationStrategy,
  14. } from './fixtures/test-plugins/multi-location-stock-plugin';
  15. import * as Codegen from './graphql/generated-e2e-admin-types';
  16. import { CreateAddressInput, FulfillmentFragment } from './graphql/generated-e2e-admin-types';
  17. import { PaymentInput } from './graphql/generated-e2e-shop-types';
  18. import * as CodegenShop from './graphql/generated-e2e-shop-types';
  19. import {
  20. CANCEL_ORDER,
  21. CREATE_FULFILLMENT,
  22. GET_ORDER,
  23. GET_STOCK_MOVEMENT,
  24. UPDATE_GLOBAL_SETTINGS,
  25. UPDATE_PRODUCT_VARIANTS,
  26. } from './graphql/shared-definitions';
  27. import {
  28. ADD_ITEM_TO_ORDER,
  29. ADD_PAYMENT,
  30. GET_ELIGIBLE_SHIPPING_METHODS,
  31. GET_PRODUCT_WITH_STOCK_LEVEL,
  32. SET_SHIPPING_ADDRESS,
  33. SET_SHIPPING_METHOD,
  34. TRANSITION_TO_STATE,
  35. } from './graphql/shop-definitions';
  36. describe('Stock control (multi-location)', () => {
  37. let defaultStockLocationId: string;
  38. let secondStockLocationId: string;
  39. const { server, adminClient, shopClient } = createTestEnvironment(
  40. mergeConfig(testConfig(), {
  41. paymentOptions: {
  42. paymentMethodHandlers: [testSuccessfulPaymentMethod],
  43. },
  44. plugins: [TestMultiLocationStockPlugin],
  45. }),
  46. );
  47. const orderGuard: ErrorResultGuard<
  48. CodegenShop.TestOrderFragmentFragment | CodegenShop.UpdatedOrderFragment
  49. > = createErrorResultGuard(input => !!input.lines);
  50. const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
  51. input => !!input.state,
  52. );
  53. async function getProductWithStockMovement(productId: string) {
  54. const { product } = await adminClient.query<
  55. Codegen.GetStockMovementQuery,
  56. Codegen.GetStockMovementQueryVariables
  57. >(GET_STOCK_MOVEMENT, { id: productId });
  58. return product;
  59. }
  60. async function setFirstEligibleShippingMethod() {
  61. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  62. GET_ELIGIBLE_SHIPPING_METHODS,
  63. );
  64. await shopClient.query<
  65. CodegenShop.SetShippingMethodMutation,
  66. CodegenShop.SetShippingMethodMutationVariables
  67. >(SET_SHIPPING_METHOD, {
  68. id: eligibleShippingMethods[0].id,
  69. });
  70. }
  71. beforeAll(async () => {
  72. await server.init({
  73. initialData: {
  74. ...initialData,
  75. paymentMethods: [
  76. {
  77. name: testSuccessfulPaymentMethod.code,
  78. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  79. },
  80. {
  81. name: twoStagePaymentMethod.code,
  82. handler: { code: twoStagePaymentMethod.code, arguments: [] },
  83. },
  84. ],
  85. },
  86. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control-multi.csv'),
  87. customerCount: 3,
  88. });
  89. await adminClient.asSuperAdmin();
  90. await adminClient.query<
  91. Codegen.UpdateGlobalSettingsMutation,
  92. Codegen.UpdateGlobalSettingsMutationVariables
  93. >(UPDATE_GLOBAL_SETTINGS, {
  94. input: {
  95. trackInventory: false,
  96. },
  97. });
  98. }, TEST_SETUP_TIMEOUT_MS);
  99. afterAll(async () => {
  100. await server.destroy();
  101. });
  102. it('default StockLocation exists', async () => {
  103. const { stockLocations } =
  104. await adminClient.query<Codegen.GetStockLocationsQuery>(GET_STOCK_LOCATIONS);
  105. expect(stockLocations.items.length).toBe(1);
  106. expect(stockLocations.items[0].name).toBe('Default Stock Location');
  107. defaultStockLocationId = stockLocations.items[0].id;
  108. });
  109. it('variant stock is all at default StockLocation', async () => {
  110. const { productVariants } = await adminClient.query<
  111. Codegen.GetVariantStockLevelsQuery,
  112. Codegen.GetVariantStockLevelsQueryVariables
  113. >(GET_VARIANT_STOCK_LEVELS, {});
  114. expect(productVariants.items.every(variant => variant.stockLevels.length === 1)).toBe(true);
  115. expect(
  116. productVariants.items.every(
  117. variant => variant.stockLevels[0].stockLocationId === defaultStockLocationId,
  118. ),
  119. ).toBe(true);
  120. });
  121. it('create StockLocation', async () => {
  122. const { createStockLocation } = await adminClient.query<
  123. Codegen.CreateStockLocationMutation,
  124. Codegen.CreateStockLocationMutationVariables
  125. >(CREATE_STOCK_LOCATION, {
  126. input: {
  127. name: 'StockLocation1',
  128. description: 'StockLocation1',
  129. },
  130. });
  131. expect(createStockLocation).toEqual({
  132. id: 'T_2',
  133. name: 'StockLocation1',
  134. description: 'StockLocation1',
  135. });
  136. secondStockLocationId = createStockLocation.id;
  137. });
  138. it('update StockLocation', async () => {
  139. const { updateStockLocation } = await adminClient.query<
  140. Codegen.UpdateStockLocationMutation,
  141. Codegen.UpdateStockLocationMutationVariables
  142. >(UPDATE_STOCK_LOCATION, {
  143. input: {
  144. id: 'T_2',
  145. name: 'Warehouse 2',
  146. description: 'The secondary warehouse',
  147. },
  148. });
  149. expect(updateStockLocation).toEqual({
  150. id: 'T_2',
  151. name: 'Warehouse 2',
  152. description: 'The secondary warehouse',
  153. });
  154. });
  155. it('update ProductVariants with stock levels in second location', async () => {
  156. const { productVariants } = await adminClient.query<
  157. Codegen.GetVariantStockLevelsQuery,
  158. Codegen.GetVariantStockLevelsQueryVariables
  159. >(GET_VARIANT_STOCK_LEVELS, {});
  160. const { updateProductVariants } = await adminClient.query<
  161. Codegen.UpdateProductVariantsMutation,
  162. Codegen.UpdateProductVariantsMutationVariables
  163. >(UPDATE_PRODUCT_VARIANTS, {
  164. input: productVariants.items.map(variant => ({
  165. id: variant.id,
  166. stockLevels: [{ stockLocationId: secondStockLocationId, stockOnHand: 120 }],
  167. })),
  168. });
  169. const {
  170. productVariants: { items },
  171. } = await adminClient.query<
  172. Codegen.GetVariantStockLevelsQuery,
  173. Codegen.GetVariantStockLevelsQueryVariables
  174. >(GET_VARIANT_STOCK_LEVELS, {});
  175. expect(items.every(variant => variant.stockLevels.length === 2)).toBe(true);
  176. expect(
  177. items.every(variant => {
  178. return (
  179. variant.stockLevels[0].stockLocationId === defaultStockLocationId &&
  180. variant.stockLevels[1].stockLocationId === secondStockLocationId
  181. );
  182. }),
  183. ).toBe(true);
  184. });
  185. it('StockLocationStrategy.getAvailableStock() is used to calculate saleable stock level', async () => {
  186. const result1 = await shopClient.query<
  187. CodegenShop.GetProductStockLevelQuery,
  188. CodegenShop.GetProductStockLevelQueryVariables
  189. >(GET_PRODUCT_WITH_STOCK_LEVEL, {
  190. id: 'T_1',
  191. });
  192. expect(result1.product?.variants[0].stockLevel).toBe('220');
  193. const result2 = await shopClient.query<
  194. CodegenShop.GetProductStockLevelQuery,
  195. CodegenShop.GetProductStockLevelQueryVariables
  196. >(
  197. GET_PRODUCT_WITH_STOCK_LEVEL,
  198. {
  199. id: 'T_1',
  200. },
  201. { fromLocation: 1 },
  202. );
  203. expect(result2.product?.variants[0].stockLevel).toBe('100');
  204. const result3 = await shopClient.query<
  205. CodegenShop.GetProductStockLevelQuery,
  206. CodegenShop.GetProductStockLevelQueryVariables
  207. >(
  208. GET_PRODUCT_WITH_STOCK_LEVEL,
  209. {
  210. id: 'T_1',
  211. },
  212. { fromLocation: 2 },
  213. );
  214. expect(result3.product?.variants[0].stockLevel).toBe('120');
  215. });
  216. describe('stock movements', () => {
  217. const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = `
  218. mutation AddItemToOrderWithCustomFields(
  219. $productVariantId: ID!
  220. $quantity: Int!
  221. $customFields: OrderLineCustomFieldsInput
  222. ) {
  223. addItemToOrder(
  224. productVariantId: $productVariantId
  225. quantity: $quantity
  226. customFields: $customFields
  227. ) {
  228. ... on Order {
  229. id
  230. lines { id }
  231. }
  232. ... on ErrorResult {
  233. errorCode
  234. message
  235. }
  236. }
  237. }
  238. `;
  239. let orderId: string;
  240. it('creates Allocations according to StockLocationStrategy', async () => {
  241. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  242. await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
  243. productVariantId: 'T_1',
  244. quantity: 2,
  245. customFields: {
  246. stockLocationId: '1',
  247. },
  248. });
  249. const { addItemToOrder } = await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
  250. productVariantId: 'T_2',
  251. quantity: 2,
  252. customFields: {
  253. stockLocationId: '2',
  254. },
  255. });
  256. orderId = addItemToOrder.id;
  257. expect(addItemToOrder.lines.length).toBe(2);
  258. // Do all steps to check out
  259. await shopClient.query<
  260. CodegenShop.SetShippingAddressMutation,
  261. CodegenShop.SetShippingAddressMutationVariables
  262. >(SET_SHIPPING_ADDRESS, {
  263. input: {
  264. streetLine1: '1 Test Street',
  265. countryCode: 'GB',
  266. } as CreateAddressInput,
  267. });
  268. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  269. GET_ELIGIBLE_SHIPPING_METHODS,
  270. );
  271. await shopClient.query<
  272. CodegenShop.SetShippingMethodMutation,
  273. CodegenShop.SetShippingMethodMutationVariables
  274. >(SET_SHIPPING_METHOD, {
  275. id: eligibleShippingMethods[0].id,
  276. });
  277. await shopClient.query<
  278. CodegenShop.TransitionToStateMutation,
  279. CodegenShop.TransitionToStateMutationVariables
  280. >(TRANSITION_TO_STATE, {
  281. state: 'ArrangingPayment',
  282. });
  283. const { addPaymentToOrder: order } = await shopClient.query<
  284. CodegenShop.AddPaymentToOrderMutation,
  285. CodegenShop.AddPaymentToOrderMutationVariables
  286. >(ADD_PAYMENT, {
  287. input: {
  288. method: testSuccessfulPaymentMethod.code,
  289. metadata: {},
  290. } as PaymentInput,
  291. });
  292. orderGuard.assertSuccess(order);
  293. const { productVariants } = await adminClient.query<
  294. Codegen.GetVariantStockLevelsQuery,
  295. Codegen.GetVariantStockLevelsQueryVariables
  296. >(GET_VARIANT_STOCK_LEVELS, {
  297. options: {
  298. filter: {
  299. id: { in: ['T_1', 'T_2'] },
  300. },
  301. },
  302. });
  303. // First variant gets stock allocated from location 1
  304. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  305. {
  306. stockLocationId: 'T_1',
  307. stockOnHand: 100,
  308. stockAllocated: 2,
  309. },
  310. {
  311. stockLocationId: 'T_2',
  312. stockOnHand: 120,
  313. stockAllocated: 0,
  314. },
  315. ]);
  316. // Second variant gets stock allocated from location 2
  317. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  318. {
  319. stockLocationId: 'T_1',
  320. stockOnHand: 100,
  321. stockAllocated: 0,
  322. },
  323. {
  324. stockLocationId: 'T_2',
  325. stockOnHand: 120,
  326. stockAllocated: 2,
  327. },
  328. ]);
  329. });
  330. it('creates Releases according to StockLocationStrategy', async () => {
  331. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  332. GET_ORDER,
  333. { id: orderId },
  334. );
  335. const { cancelOrder } = await adminClient.query<
  336. Codegen.CancelOrderMutation,
  337. Codegen.CancelOrderMutationVariables
  338. >(CANCEL_ORDER, {
  339. input: {
  340. orderId,
  341. lines: order?.lines
  342. .filter(l => l.productVariant.id === 'T_2')
  343. .map(l => ({
  344. orderLineId: l.id,
  345. quantity: 1,
  346. })),
  347. },
  348. });
  349. const { productVariants } = await adminClient.query<
  350. Codegen.GetVariantStockLevelsQuery,
  351. Codegen.GetVariantStockLevelsQueryVariables
  352. >(GET_VARIANT_STOCK_LEVELS, {
  353. options: {
  354. filter: {
  355. id: { eq: 'T_2' },
  356. },
  357. },
  358. });
  359. // Second variant gets stock allocated from location 2
  360. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  361. {
  362. stockLocationId: 'T_1',
  363. stockOnHand: 100,
  364. stockAllocated: 0,
  365. },
  366. {
  367. stockLocationId: 'T_2',
  368. stockOnHand: 120,
  369. stockAllocated: 1,
  370. },
  371. ]);
  372. });
  373. it('creates Sales according to StockLocationStrategy', async () => {
  374. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  375. GET_ORDER,
  376. { id: orderId },
  377. );
  378. await adminClient.query<
  379. Codegen.CreateFulfillmentMutation,
  380. Codegen.CreateFulfillmentMutationVariables
  381. >(CREATE_FULFILLMENT, {
  382. input: {
  383. handler: {
  384. code: manualFulfillmentHandler.code,
  385. arguments: [{ name: 'method', value: 'Test1' }],
  386. },
  387. lines: order!.lines.map(l => ({
  388. orderLineId: l.id,
  389. quantity: l.quantity,
  390. })),
  391. },
  392. });
  393. const { productVariants } = await adminClient.query<
  394. Codegen.GetVariantStockLevelsQuery,
  395. Codegen.GetVariantStockLevelsQueryVariables
  396. >(GET_VARIANT_STOCK_LEVELS, {
  397. options: {
  398. filter: {
  399. id: { in: ['T_1', 'T_2'] },
  400. },
  401. },
  402. });
  403. // Second variant gets stock allocated from location 2
  404. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  405. {
  406. stockLocationId: 'T_1',
  407. stockOnHand: 98,
  408. stockAllocated: 0,
  409. },
  410. {
  411. stockLocationId: 'T_2',
  412. stockOnHand: 120,
  413. stockAllocated: 0,
  414. },
  415. ]);
  416. // Second variant gets stock allocated from location 2
  417. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  418. {
  419. stockLocationId: 'T_1',
  420. stockOnHand: 100,
  421. stockAllocated: 0,
  422. },
  423. {
  424. stockLocationId: 'T_2',
  425. stockOnHand: 119,
  426. stockAllocated: 0,
  427. },
  428. ]);
  429. });
  430. it('creates Cancellations according to StockLocationStrategy', async () => {
  431. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  432. GET_ORDER,
  433. { id: orderId },
  434. );
  435. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  436. CANCEL_ORDER,
  437. {
  438. input: {
  439. orderId,
  440. cancelShipping: true,
  441. reason: 'No longer needed',
  442. },
  443. },
  444. );
  445. const { productVariants } = await adminClient.query<
  446. Codegen.GetVariantStockLevelsQuery,
  447. Codegen.GetVariantStockLevelsQueryVariables
  448. >(GET_VARIANT_STOCK_LEVELS, {
  449. options: {
  450. filter: {
  451. id: { in: ['T_1', 'T_2'] },
  452. },
  453. },
  454. });
  455. // Second variant gets stock allocated from location 2
  456. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  457. {
  458. stockLocationId: 'T_1',
  459. stockOnHand: 100,
  460. stockAllocated: 0,
  461. },
  462. {
  463. stockLocationId: 'T_2',
  464. stockOnHand: 120,
  465. stockAllocated: 0,
  466. },
  467. ]);
  468. // Second variant gets stock allocated from location 2
  469. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  470. {
  471. stockLocationId: 'T_1',
  472. stockOnHand: 100,
  473. stockAllocated: 0,
  474. },
  475. {
  476. stockLocationId: 'T_2',
  477. stockOnHand: 120,
  478. stockAllocated: 0,
  479. },
  480. ]);
  481. });
  482. });
  483. });
  484. const STOCK_LOCATION_FRAGMENT = gql`
  485. fragment StockLocation on StockLocation {
  486. id
  487. name
  488. description
  489. }
  490. `;
  491. const GET_STOCK_LOCATION = gql`
  492. query GetStockLocation($id: ID!) {
  493. stockLocation(id: $id) {
  494. ...StockLocation
  495. }
  496. }
  497. ${STOCK_LOCATION_FRAGMENT}
  498. `;
  499. const GET_STOCK_LOCATIONS = gql`
  500. query GetStockLocations($options: StockLocationListOptions) {
  501. stockLocations(options: $options) {
  502. items {
  503. ...StockLocation
  504. }
  505. totalItems
  506. }
  507. }
  508. ${STOCK_LOCATION_FRAGMENT}
  509. `;
  510. const CREATE_STOCK_LOCATION = gql`
  511. mutation CreateStockLocation($input: CreateStockLocationInput!) {
  512. createStockLocation(input: $input) {
  513. ...StockLocation
  514. }
  515. }
  516. ${STOCK_LOCATION_FRAGMENT}
  517. `;
  518. const UPDATE_STOCK_LOCATION = gql`
  519. mutation UpdateStockLocation($input: UpdateStockLocationInput!) {
  520. updateStockLocation(input: $input) {
  521. ...StockLocation
  522. }
  523. }
  524. ${STOCK_LOCATION_FRAGMENT}
  525. `;
  526. const GET_VARIANT_STOCK_LEVELS = gql`
  527. query GetVariantStockLevels($options: ProductVariantListOptions) {
  528. productVariants(options: $options) {
  529. items {
  530. id
  531. name
  532. stockOnHand
  533. stockAllocated
  534. stockLevels {
  535. stockLocationId
  536. stockOnHand
  537. stockAllocated
  538. }
  539. }
  540. }
  541. }
  542. `;