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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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', () => {
  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 } = await adminClient.query<Codegen.GetStockLocationsQuery>(
  104. GET_STOCK_LOCATIONS,
  105. );
  106. expect(stockLocations.items.length).toBe(1);
  107. expect(stockLocations.items[0].name).toBe('Default Stock Location');
  108. defaultStockLocationId = stockLocations.items[0].id;
  109. });
  110. it('variant stock is all at default StockLocation', async () => {
  111. const { productVariants } = await adminClient.query<
  112. Codegen.GetVariantStockLevelsQuery,
  113. Codegen.GetVariantStockLevelsQueryVariables
  114. >(GET_VARIANT_STOCK_LEVELS, {});
  115. expect(productVariants.items.every(variant => variant.stockLevels.length === 1)).toBe(true);
  116. expect(
  117. productVariants.items.every(
  118. variant => variant.stockLevels[0].stockLocationId === defaultStockLocationId,
  119. ),
  120. ).toBe(true);
  121. });
  122. it('create StockLocation', async () => {
  123. const { createStockLocation } = await adminClient.query<
  124. Codegen.CreateStockLocationMutation,
  125. Codegen.CreateStockLocationMutationVariables
  126. >(CREATE_STOCK_LOCATION, {
  127. input: {
  128. name: 'StockLocation1',
  129. description: 'StockLocation1',
  130. },
  131. });
  132. expect(createStockLocation).toEqual({
  133. id: 'T_2',
  134. name: 'StockLocation1',
  135. description: 'StockLocation1',
  136. });
  137. secondStockLocationId = createStockLocation.id;
  138. });
  139. it('update StockLocation', async () => {
  140. const { updateStockLocation } = await adminClient.query<
  141. Codegen.UpdateStockLocationMutation,
  142. Codegen.UpdateStockLocationMutationVariables
  143. >(UPDATE_STOCK_LOCATION, {
  144. input: {
  145. id: 'T_2',
  146. name: 'Warehouse 2',
  147. description: 'The secondary warehouse',
  148. },
  149. });
  150. expect(updateStockLocation).toEqual({
  151. id: 'T_2',
  152. name: 'Warehouse 2',
  153. description: 'The secondary warehouse',
  154. });
  155. });
  156. it('update ProductVariants with stock levels in second location', async () => {
  157. const { productVariants } = await adminClient.query<
  158. Codegen.GetVariantStockLevelsQuery,
  159. Codegen.GetVariantStockLevelsQueryVariables
  160. >(GET_VARIANT_STOCK_LEVELS, {});
  161. const { updateProductVariants } = await adminClient.query<
  162. Codegen.UpdateProductVariantsMutation,
  163. Codegen.UpdateProductVariantsMutationVariables
  164. >(UPDATE_PRODUCT_VARIANTS, {
  165. input: productVariants.items.map(variant => ({
  166. id: variant.id,
  167. stockLevels: [{ stockLocationId: secondStockLocationId, stockOnHand: 120 }],
  168. })),
  169. });
  170. const {
  171. productVariants: { items },
  172. } = await adminClient.query<
  173. Codegen.GetVariantStockLevelsQuery,
  174. Codegen.GetVariantStockLevelsQueryVariables
  175. >(GET_VARIANT_STOCK_LEVELS, {});
  176. expect(items.every(variant => variant.stockLevels.length === 2)).toBe(true);
  177. expect(
  178. items.every(variant => {
  179. return (
  180. variant.stockLevels[0].stockLocationId === defaultStockLocationId &&
  181. variant.stockLevels[1].stockLocationId === secondStockLocationId
  182. );
  183. }),
  184. ).toBe(true);
  185. });
  186. it('StockLocationStrategy.getAvailableStock() is used to calculate saleable stock level', async () => {
  187. const result1 = await shopClient.query<
  188. CodegenShop.GetProductStockLevelQuery,
  189. CodegenShop.GetProductStockLevelQueryVariables
  190. >(GET_PRODUCT_WITH_STOCK_LEVEL, {
  191. id: 'T_1',
  192. });
  193. expect(result1.product?.variants[0].stockLevel).toBe('220');
  194. const result2 = await shopClient.query<
  195. CodegenShop.GetProductStockLevelQuery,
  196. CodegenShop.GetProductStockLevelQueryVariables
  197. >(
  198. GET_PRODUCT_WITH_STOCK_LEVEL,
  199. {
  200. id: 'T_1',
  201. },
  202. { fromLocation: 1 },
  203. );
  204. expect(result2.product?.variants[0].stockLevel).toBe('100');
  205. const result3 = await shopClient.query<
  206. CodegenShop.GetProductStockLevelQuery,
  207. CodegenShop.GetProductStockLevelQueryVariables
  208. >(
  209. GET_PRODUCT_WITH_STOCK_LEVEL,
  210. {
  211. id: 'T_1',
  212. },
  213. { fromLocation: 2 },
  214. );
  215. expect(result3.product?.variants[0].stockLevel).toBe('120');
  216. });
  217. describe('stock movements', () => {
  218. const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = `
  219. mutation AddItemToOrderWithCustomFields(
  220. $productVariantId: ID!
  221. $quantity: Int!
  222. $customFields: OrderLineCustomFieldsInput
  223. ) {
  224. addItemToOrder(
  225. productVariantId: $productVariantId
  226. quantity: $quantity
  227. customFields: $customFields
  228. ) {
  229. ... on Order {
  230. id
  231. lines { id }
  232. }
  233. ... on ErrorResult {
  234. errorCode
  235. message
  236. }
  237. }
  238. }
  239. `;
  240. let orderId: string;
  241. it('creates Allocations according to StockLocationStrategy', async () => {
  242. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  243. await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
  244. productVariantId: 'T_1',
  245. quantity: 2,
  246. customFields: {
  247. stockLocationId: '1',
  248. },
  249. });
  250. const { addItemToOrder } = await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
  251. productVariantId: 'T_2',
  252. quantity: 2,
  253. customFields: {
  254. stockLocationId: '2',
  255. },
  256. });
  257. orderId = addItemToOrder.id;
  258. expect(addItemToOrder.lines.length).toBe(2);
  259. // Do all steps to check out
  260. await shopClient.query<
  261. CodegenShop.SetShippingAddressMutation,
  262. CodegenShop.SetShippingAddressMutationVariables
  263. >(SET_SHIPPING_ADDRESS, {
  264. input: {
  265. streetLine1: '1 Test Street',
  266. countryCode: 'GB',
  267. } as CreateAddressInput,
  268. });
  269. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  270. GET_ELIGIBLE_SHIPPING_METHODS,
  271. );
  272. await shopClient.query<
  273. CodegenShop.SetShippingMethodMutation,
  274. CodegenShop.SetShippingMethodMutationVariables
  275. >(SET_SHIPPING_METHOD, {
  276. id: eligibleShippingMethods[0].id,
  277. });
  278. await shopClient.query<
  279. CodegenShop.TransitionToStateMutation,
  280. CodegenShop.TransitionToStateMutationVariables
  281. >(TRANSITION_TO_STATE, {
  282. state: 'ArrangingPayment',
  283. });
  284. const { addPaymentToOrder: order } = await shopClient.query<
  285. CodegenShop.AddPaymentToOrderMutation,
  286. CodegenShop.AddPaymentToOrderMutationVariables
  287. >(ADD_PAYMENT, {
  288. input: {
  289. method: testSuccessfulPaymentMethod.code,
  290. metadata: {},
  291. } as PaymentInput,
  292. });
  293. orderGuard.assertSuccess(order);
  294. const { productVariants } = await adminClient.query<
  295. Codegen.GetVariantStockLevelsQuery,
  296. Codegen.GetVariantStockLevelsQueryVariables
  297. >(GET_VARIANT_STOCK_LEVELS, {
  298. options: {
  299. filter: {
  300. id: { in: ['T_1', 'T_2'] },
  301. },
  302. },
  303. });
  304. // First variant gets stock allocated from location 1
  305. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  306. {
  307. stockLocationId: 'T_1',
  308. stockOnHand: 100,
  309. stockAllocated: 2,
  310. },
  311. {
  312. stockLocationId: 'T_2',
  313. stockOnHand: 120,
  314. stockAllocated: 0,
  315. },
  316. ]);
  317. // Second variant gets stock allocated from location 2
  318. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  319. {
  320. stockLocationId: 'T_1',
  321. stockOnHand: 100,
  322. stockAllocated: 0,
  323. },
  324. {
  325. stockLocationId: 'T_2',
  326. stockOnHand: 120,
  327. stockAllocated: 2,
  328. },
  329. ]);
  330. });
  331. it('creates Releases according to StockLocationStrategy', async () => {
  332. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  333. GET_ORDER,
  334. { id: orderId },
  335. );
  336. const { cancelOrder } = await adminClient.query<
  337. Codegen.CancelOrderMutation,
  338. Codegen.CancelOrderMutationVariables
  339. >(CANCEL_ORDER, {
  340. input: {
  341. orderId,
  342. lines: order?.lines
  343. .filter(l => l.productVariant.id === 'T_2')
  344. .map(l => ({
  345. orderLineId: l.id,
  346. quantity: 1,
  347. })),
  348. },
  349. });
  350. const { productVariants } = await adminClient.query<
  351. Codegen.GetVariantStockLevelsQuery,
  352. Codegen.GetVariantStockLevelsQueryVariables
  353. >(GET_VARIANT_STOCK_LEVELS, {
  354. options: {
  355. filter: {
  356. id: { eq: 'T_2' },
  357. },
  358. },
  359. });
  360. // Second variant gets stock allocated from location 2
  361. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  362. {
  363. stockLocationId: 'T_1',
  364. stockOnHand: 100,
  365. stockAllocated: 0,
  366. },
  367. {
  368. stockLocationId: 'T_2',
  369. stockOnHand: 120,
  370. stockAllocated: 1,
  371. },
  372. ]);
  373. });
  374. it('creates Sales according to StockLocationStrategy', async () => {
  375. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  376. GET_ORDER,
  377. { id: orderId },
  378. );
  379. await adminClient.query<
  380. Codegen.CreateFulfillmentMutation,
  381. Codegen.CreateFulfillmentMutationVariables
  382. >(CREATE_FULFILLMENT, {
  383. input: {
  384. handler: {
  385. code: manualFulfillmentHandler.code,
  386. arguments: [{ name: 'method', value: 'Test1' }],
  387. },
  388. lines: order!.lines.map(l => ({
  389. orderLineId: l.id,
  390. quantity: l.quantity,
  391. })),
  392. },
  393. });
  394. const { productVariants } = await adminClient.query<
  395. Codegen.GetVariantStockLevelsQuery,
  396. Codegen.GetVariantStockLevelsQueryVariables
  397. >(GET_VARIANT_STOCK_LEVELS, {
  398. options: {
  399. filter: {
  400. id: { in: ['T_1', 'T_2'] },
  401. },
  402. },
  403. });
  404. // Second variant gets stock allocated from location 2
  405. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  406. {
  407. stockLocationId: 'T_1',
  408. stockOnHand: 98,
  409. stockAllocated: 0,
  410. },
  411. {
  412. stockLocationId: 'T_2',
  413. stockOnHand: 120,
  414. stockAllocated: 0,
  415. },
  416. ]);
  417. // Second variant gets stock allocated from location 2
  418. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  419. {
  420. stockLocationId: 'T_1',
  421. stockOnHand: 100,
  422. stockAllocated: 0,
  423. },
  424. {
  425. stockLocationId: 'T_2',
  426. stockOnHand: 119,
  427. stockAllocated: 0,
  428. },
  429. ]);
  430. });
  431. it('creates Cancellations according to StockLocationStrategy', async () => {
  432. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  433. GET_ORDER,
  434. { id: orderId },
  435. );
  436. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  437. CANCEL_ORDER,
  438. {
  439. input: {
  440. orderId,
  441. cancelShipping: true,
  442. reason: 'No longer needed',
  443. },
  444. },
  445. );
  446. const { productVariants } = await adminClient.query<
  447. Codegen.GetVariantStockLevelsQuery,
  448. Codegen.GetVariantStockLevelsQueryVariables
  449. >(GET_VARIANT_STOCK_LEVELS, {
  450. options: {
  451. filter: {
  452. id: { in: ['T_1', 'T_2'] },
  453. },
  454. },
  455. });
  456. // Second variant gets stock allocated from location 2
  457. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  458. {
  459. stockLocationId: 'T_1',
  460. stockOnHand: 100,
  461. stockAllocated: 0,
  462. },
  463. {
  464. stockLocationId: 'T_2',
  465. stockOnHand: 120,
  466. stockAllocated: 0,
  467. },
  468. ]);
  469. // Second variant gets stock allocated from location 2
  470. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  471. {
  472. stockLocationId: 'T_1',
  473. stockOnHand: 100,
  474. stockAllocated: 0,
  475. },
  476. {
  477. stockLocationId: 'T_2',
  478. stockOnHand: 120,
  479. stockAllocated: 0,
  480. },
  481. ]);
  482. });
  483. });
  484. });
  485. const STOCK_LOCATION_FRAGMENT = gql`
  486. fragment StockLocation on StockLocation {
  487. id
  488. name
  489. description
  490. }
  491. `;
  492. const GET_STOCK_LOCATION = gql`
  493. query GetStockLocation($id: ID!) {
  494. stockLocation(id: $id) {
  495. ...StockLocation
  496. }
  497. }
  498. ${STOCK_LOCATION_FRAGMENT}
  499. `;
  500. const GET_STOCK_LOCATIONS = gql`
  501. query GetStockLocations($options: StockLocationListOptions) {
  502. stockLocations(options: $options) {
  503. items {
  504. ...StockLocation
  505. }
  506. totalItems
  507. }
  508. }
  509. ${STOCK_LOCATION_FRAGMENT}
  510. `;
  511. const CREATE_STOCK_LOCATION = gql`
  512. mutation CreateStockLocation($input: CreateStockLocationInput!) {
  513. createStockLocation(input: $input) {
  514. ...StockLocation
  515. }
  516. }
  517. ${STOCK_LOCATION_FRAGMENT}
  518. `;
  519. const UPDATE_STOCK_LOCATION = gql`
  520. mutation UpdateStockLocation($input: UpdateStockLocationInput!) {
  521. updateStockLocation(input: $input) {
  522. ...StockLocation
  523. }
  524. }
  525. ${STOCK_LOCATION_FRAGMENT}
  526. `;
  527. const GET_VARIANT_STOCK_LEVELS = gql`
  528. query GetVariantStockLevels($options: ProductVariantListOptions) {
  529. productVariants(options: $options) {
  530. items {
  531. id
  532. name
  533. stockOnHand
  534. stockAllocated
  535. stockLevels {
  536. stockLocationId
  537. stockOnHand
  538. stockAllocated
  539. }
  540. }
  541. }
  542. }
  543. `;