cabloy 5.1.112 → 5.1.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/SKILL.md +2 -0
  3. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +13 -0
  4. package/.github/workflows/playwright-e2e.yml +34 -2
  5. package/.github/workflows/vona-test-mysql.yml +12 -0
  6. package/.github/workflows/vona-test-pg.yml +12 -0
  7. package/CHANGELOG.md +27 -0
  8. package/CLAUDE.md +6 -0
  9. package/cabloy-docs/backend/internal-aop-guide.md +35 -0
  10. package/cabloy-docs/backend/migration-and-changes.md +13 -9
  11. package/cabloy-docs/backend/unit-testing.md +97 -1
  12. package/e2e/scripts/startE2eVona.ts +51 -13
  13. package/e2e/specs/a-commerce/commerce.spec.ts +1 -0
  14. package/e2e/specs/cabloy-basic/basic.spec.ts +12 -7
  15. package/package.json +1 -1
  16. package/vona/packages-cli/cli/package.json +1 -1
  17. package/vona/packages-cli/cli-set-api/package.json +1 -1
  18. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +6 -9
  19. package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +74 -32
  20. package/vona/packages-utils/compose/package.json +1 -1
  21. package/vona/packages-utils/compose/src/index.ts +19 -11
  22. package/vona/packages-vona/vona/package.json +1 -1
  23. package/vona/packages-vona/vona-core/package.json +1 -1
  24. package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
  25. package/vona/packages-vona/vona-mock/package.json +1 -1
  26. package/vona/pnpm-lock.yaml +82 -67
  27. package/vona/src/backend/config/config/config.test.ts +1 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
  29. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
  34. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +234 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
  36. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  37. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
  38. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
  39. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
  40. package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
  46. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
  47. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
  50. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
  56. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
  57. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
  59. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
  60. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
  61. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
  62. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
  63. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
  66. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
  67. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
  68. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
  72. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
  73. package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +11 -13
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
  75. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
  76. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
  77. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
  78. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +79 -0
  79. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +14 -1
  80. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +14 -1
  81. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
  82. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
  83. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
  84. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
  85. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
  86. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
  87. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
  88. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
  89. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -0
  90. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockAudit.tsx +39 -7
  91. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockReservation.tsx +4 -1
  92. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
  93. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
  94. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
  95. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
  96. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +32 -0
  97. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
  98. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
  99. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
  100. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
  101. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +274 -66
  102. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +282 -82
  103. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  104. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
  105. package/vona/src/suite-vendor/a-image/package.json +1 -1
  106. package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
  108. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
  109. package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
  110. package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
  111. package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
  112. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
  113. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
  114. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
  116. package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
  117. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
  119. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
  120. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
  121. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
  122. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
  123. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  124. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +2 -2
  125. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  126. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/version.ts +12 -12
  127. package/vona/src/suite-vendor/a-vona/modules/a-version/src/types/version.ts +4 -4
  128. package/vona/src/suite-vendor/a-vona/modules/a-vona/package.json +2 -1
  129. package/vona/src/suite-vendor/a-vona/package.json +2 -1
  130. package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
  131. package/zova/packages-utils/zova-jsx/package.json +3 -3
  132. package/zova/packages-zova/zova/package.json +3 -3
  133. package/zova/packages-zova/zova-core/package.json +2 -2
  134. package/zova/pnpm-lock.yaml +14 -14
  135. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/page/product/controller.tsx +0 -1
  136. package/zova/src/suite/a-commerce/modules/commerce-promotion/cli/openapi.config.ts +9 -0
  137. package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
  138. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
  139. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
  140. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
  141. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
  142. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
  143. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
  144. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
  145. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
  146. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  147. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
  148. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
  149. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
  150. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
  151. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
  152. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +14 -0
  153. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +17 -7
  154. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +1 -1
  155. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +17 -7
  156. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
  157. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -2,35 +2,78 @@ import type { EntityStockReservation } from 'vona-module-commerce-trade';
2
2
 
3
3
  import { catchError } from '@cabloy/utils';
4
4
  import assert from 'node:assert';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { describe, it } from 'node:test';
6
7
  import { app } from 'vona-mock';
7
8
 
8
- async function createSku(suffix: string): Promise<number> {
9
- const categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
9
+ interface IStockFixture {
10
+ categoryId: number;
11
+ productId: number;
12
+ skuId: number;
13
+ }
14
+
15
+ type IStockFixturePartial = Partial<IStockFixture>;
16
+
17
+ function createTestId() {
18
+ return randomUUID().slice(0, 12);
19
+ }
20
+
21
+ async function createSku(suffix: string, fixtures: IStockFixturePartial[]): Promise<IStockFixture> {
22
+ const fixture: IStockFixturePartial = {};
23
+ fixtures.push(fixture);
24
+ fixture.categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
10
25
  body: { name: `reservation-category-${suffix}`, published: true },
11
26
  });
12
- const productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
13
- body: { categoryId, title: `reservation-product-${suffix}`, published: true },
27
+ fixture.productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
28
+ body: {
29
+ categoryId: fixture.categoryId,
30
+ title: `reservation-product-${suffix}`,
31
+ published: true,
32
+ },
14
33
  });
15
- return await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
34
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
16
35
  body: {
17
- productId,
36
+ productId: fixture.productId,
18
37
  code: `reservation-sku-${suffix}`,
19
38
  priceCents: 100,
20
39
  lifecycle: 'active',
21
40
  },
22
41
  });
42
+ return fixture as IStockFixture;
23
43
  }
24
44
 
25
- async function prepareStock(suffix: string, quantity = 3): Promise<number> {
26
- const skuId = await createSku(suffix);
45
+ async function prepareStock(
46
+ suffix: string,
47
+ fixtures: IStockFixturePartial[],
48
+ quantity = 3,
49
+ ): Promise<IStockFixture> {
50
+ const fixture = await createSku(suffix, fixtures);
27
51
  await app.scope('commerce-trade').service.stockBalance.adjustStock({
28
- skuId,
52
+ skuId: fixture.skuId,
29
53
  delta: quantity,
30
54
  reason: 'reservation test setup',
31
55
  correlationId: `reservation-setup-${suffix}`,
32
56
  });
33
- return skuId;
57
+ return fixture;
58
+ }
59
+
60
+ async function dropStockFixtures(fixtures: IStockFixturePartial[]) {
61
+ const scopeTrade = app.scope('commerce-trade');
62
+ const scopeCatalog = app.scope('commerce-catalog');
63
+ for (const fixture of fixtures.toReversed()) {
64
+ if (fixture.skuId !== undefined) {
65
+ await scopeTrade.model.stockAudit.delete({ skuId: fixture.skuId });
66
+ await scopeTrade.model.stockReservation.delete({ skuId: fixture.skuId });
67
+ await scopeTrade.model.stockBalance.delete({ skuId: fixture.skuId });
68
+ await scopeCatalog.model.sku.delete({ id: fixture.skuId });
69
+ }
70
+ if (fixture.productId !== undefined) {
71
+ await scopeCatalog.model.product.delete({ id: fixture.productId });
72
+ }
73
+ if (fixture.categoryId !== undefined) {
74
+ await scopeCatalog.model.category.delete({ id: fixture.categoryId });
75
+ }
76
+ }
34
77
  }
35
78
 
36
79
  async function reserve(
@@ -59,17 +102,18 @@ async function assertReservationRejected(skuId: number, suffix: string, expected
59
102
  );
60
103
  }
61
104
 
62
- describe('stockReservation.test.ts', () => {
105
+ describe('stockReservation.test.ts', { concurrency: false }, () => {
63
106
  it('reserves and consumes stock exactly once with traceable audits', async () => {
64
107
  await app.bean.executor.mockCtx(async () => {
65
- const suffix = `${Date.now()}`;
108
+ const fixtures: IStockFixturePartial[] = [];
66
109
  await app.bean.passport.signinMock();
67
110
  try {
68
- const skuId = await prepareStock(suffix);
69
- const reservation = await reserve(skuId, suffix);
111
+ const suffix = createTestId();
112
+ const fixture = await prepareStock(suffix, fixtures);
113
+ const reservation = await reserve(fixture.skuId, suffix);
70
114
  const balanceAfterReserve = await app
71
115
  .scope('commerce-trade')
72
- .model.stockBalance.get({ skuId });
116
+ .model.stockBalance.get({ skuId: fixture.skuId });
73
117
  assert.deepEqual(
74
118
  [
75
119
  balanceAfterReserve?.onHand,
@@ -79,7 +123,7 @@ describe('stockReservation.test.ts', () => {
79
123
  [3, 2, 1],
80
124
  );
81
125
 
82
- const duplicate = await reserve(skuId, suffix);
126
+ const duplicate = await reserve(fixture.skuId, suffix);
83
127
  assert.equal(duplicate.id, reservation.id);
84
128
  const consumed = await app.scope('commerce-trade').service.stockBalance.consume({
85
129
  reservationId: reservation.id,
@@ -94,7 +138,7 @@ describe('stockReservation.test.ts', () => {
94
138
 
95
139
  const balanceAfterConsume = await app
96
140
  .scope('commerce-trade')
97
- .model.stockBalance.get({ skuId });
141
+ .model.stockBalance.get({ skuId: fixture.skuId });
98
142
  assert.deepEqual(
99
143
  [
100
144
  balanceAfterConsume?.onHand,
@@ -103,15 +147,16 @@ describe('stockReservation.test.ts', () => {
103
147
  ],
104
148
  [1, 0, 1],
105
149
  );
106
- const audits = await app
107
- .scope('commerce-trade')
108
- .model.stockAudit.select({ where: { skuId } });
150
+ const audits = await app.scope('commerce-trade').model.stockAudit.select({
151
+ where: { skuId: fixture.skuId },
152
+ });
109
153
  assert.deepEqual(
110
154
  audits.map(audit => audit.operation),
111
155
  ['adjust', 'reserve', 'consume'],
112
156
  );
113
157
  assert.equal(audits[1].stockReservationId, reservation.id);
114
158
  } finally {
159
+ await dropStockFixtures(fixtures);
115
160
  await app.bean.passport.signout();
116
161
  }
117
162
  });
@@ -119,36 +164,29 @@ describe('stockReservation.test.ts', () => {
119
164
 
120
165
  it('rejects reservations for inactive or unpublished catalog data without writes', async () => {
121
166
  await app.bean.executor.mockCtx(async () => {
122
- const suffix = `${Date.now()}`;
167
+ const fixtures: IStockFixturePartial[] = [];
123
168
  await app.bean.passport.signinMock();
124
169
  try {
125
- const inactiveSkuId = await prepareStock(`${suffix}-inactive`);
126
- await app.scope('commerce-catalog').model.sku.updateById(inactiveSkuId, {
170
+ const suffix = createTestId();
171
+ const inactiveSku = await prepareStock(`${suffix}-inactive`, fixtures);
172
+ await app.scope('commerce-catalog').model.sku.updateById(inactiveSku.skuId, {
127
173
  lifecycle: 'inactive',
128
174
  });
129
- await assertReservationRejected(inactiveSkuId, `${suffix}-inactive`, 404);
175
+ await assertReservationRejected(inactiveSku.skuId, `${suffix}-inactive`, 404);
130
176
 
131
- const unpublishedProductSkuId = await prepareStock(`${suffix}-product`);
132
- const unpublishedProductSku = await app
133
- .scope('commerce-catalog')
134
- .model.sku.getById(unpublishedProductSkuId);
177
+ const unpublishedProductSku = await prepareStock(`${suffix}-product`, fixtures);
135
178
  await app
136
179
  .scope('commerce-catalog')
137
- .model.product.updateById(unpublishedProductSku!.productId, { published: false });
138
- await assertReservationRejected(unpublishedProductSkuId, `${suffix}-product`, 409);
180
+ .model.product.updateById(unpublishedProductSku.productId, { published: false });
181
+ await assertReservationRejected(unpublishedProductSku.skuId, `${suffix}-product`, 409);
139
182
 
140
- const unpublishedCategorySkuId = await prepareStock(`${suffix}-category`);
141
- const unpublishedCategorySku = await app
142
- .scope('commerce-catalog')
143
- .model.sku.getById(unpublishedCategorySkuId);
144
- const unpublishedCategoryProduct = await app
145
- .scope('commerce-catalog')
146
- .model.product.getById(unpublishedCategorySku!.productId);
183
+ const unpublishedCategorySku = await prepareStock(`${suffix}-category`, fixtures);
147
184
  await app
148
185
  .scope('commerce-catalog')
149
- .model.category.updateById(unpublishedCategoryProduct!.categoryId, { published: false });
150
- await assertReservationRejected(unpublishedCategorySkuId, `${suffix}-category`, 409);
186
+ .model.category.updateById(unpublishedCategorySku.categoryId, { published: false });
187
+ await assertReservationRejected(unpublishedCategorySku.skuId, `${suffix}-category`, 409);
151
188
  } finally {
189
+ await dropStockFixtures(fixtures);
152
190
  await app.bean.passport.signout();
153
191
  }
154
192
  });
@@ -156,11 +194,12 @@ describe('stockReservation.test.ts', () => {
156
194
 
157
195
  it('releases and restores stock only from legal reservation states', async () => {
158
196
  await app.bean.executor.mockCtx(async () => {
159
- const suffix = `${Date.now()}`;
197
+ const fixtures: IStockFixturePartial[] = [];
160
198
  await app.bean.passport.signinMock();
161
199
  try {
162
- const skuId = await prepareStock(suffix);
163
- const releasedReservation = await reserve(skuId, `${suffix}-release`);
200
+ const suffix = createTestId();
201
+ const fixture = await prepareStock(suffix, fixtures);
202
+ const releasedReservation = await reserve(fixture.skuId, `${suffix}-release`);
164
203
  const released = await app.scope('commerce-trade').service.stockBalance.release({
165
204
  reservationId: releasedReservation.id,
166
205
  reason: 'payment failed',
@@ -172,7 +211,7 @@ describe('stockReservation.test.ts', () => {
172
211
  });
173
212
  assert.equal(duplicateRelease.state, 'released');
174
213
 
175
- const consumedReservation = await reserve(skuId, `${suffix}-restore`, 1);
214
+ const consumedReservation = await reserve(fixture.skuId, `${suffix}-restore`, 1);
176
215
  await app.scope('commerce-trade').service.stockBalance.consume({
177
216
  reservationId: consumedReservation.id,
178
217
  reason: 'payment success',
@@ -182,6 +221,11 @@ describe('stockReservation.test.ts', () => {
182
221
  reason: 'refund success',
183
222
  });
184
223
  assert.equal(restored.state, 'restored');
224
+ const duplicateRestore = await app.scope('commerce-trade').service.stockBalance.restore({
225
+ reservationId: consumedReservation.id,
226
+ reason: 'refund success retry',
227
+ });
228
+ assert.equal(duplicateRestore.state, 'restored');
185
229
 
186
230
  const [_, err] = await catchError(() =>
187
231
  app.scope('commerce-trade').service.stockBalance.consume({
@@ -190,71 +234,227 @@ describe('stockReservation.test.ts', () => {
190
234
  }),
191
235
  );
192
236
  assert.equal(err?.code, 409);
193
- const balance = await app.scope('commerce-trade').model.stockBalance.get({ skuId });
237
+ const balance = await app
238
+ .scope('commerce-trade')
239
+ .model.stockBalance.get({ skuId: fixture.skuId });
194
240
  assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [3, 0, 3]);
241
+ const audits = await app.scope('commerce-trade').model.stockAudit.select({
242
+ where: { skuId: fixture.skuId },
243
+ });
244
+ assert.equal(audits.length, 6);
245
+ assert.deepEqual(
246
+ audits.map(audit => audit.operation),
247
+ ['adjust', 'reserve', 'release', 'reserve', 'consume', 'restore'],
248
+ );
249
+ assert.deepEqual(
250
+ audits.map(audit => audit.stockBalanceId),
251
+ audits.map(() => balance?.id),
252
+ );
253
+ assert.deepEqual(
254
+ audits.map(audit => audit.stockReservationId),
255
+ [
256
+ null,
257
+ releasedReservation.id,
258
+ releasedReservation.id,
259
+ consumedReservation.id,
260
+ consumedReservation.id,
261
+ consumedReservation.id,
262
+ ],
263
+ );
264
+ assert.deepEqual(
265
+ audits.map(audit => audit.correlationId),
266
+ [
267
+ `reservation-setup-${suffix}`,
268
+ `reservation-${suffix}-release`,
269
+ `reservation-${suffix}-release`,
270
+ `reservation-${suffix}-restore`,
271
+ `reservation-${suffix}-restore`,
272
+ `reservation-${suffix}-restore`,
273
+ ],
274
+ );
275
+ assert.deepEqual(
276
+ audits.map(audit => audit.reason),
277
+ [
278
+ 'reservation test setup',
279
+ 'reservation test',
280
+ 'payment failed',
281
+ 'reservation test',
282
+ 'payment success',
283
+ 'refund success',
284
+ ],
285
+ );
286
+ assert.deepEqual(
287
+ audits.map(audit => [
288
+ audit.delta,
289
+ audit.priorOnHand,
290
+ audit.priorReserved,
291
+ audit.priorAvailable,
292
+ audit.onHand,
293
+ audit.reserved,
294
+ audit.available,
295
+ ]),
296
+ [
297
+ [3, 0, 0, 0, 3, 0, 3],
298
+ [-2, 3, 0, 3, 3, 2, 1],
299
+ [0, 3, 2, 1, 3, 0, 3],
300
+ [-1, 3, 0, 3, 3, 1, 2],
301
+ [-1, 3, 1, 2, 2, 0, 2],
302
+ [1, 2, 0, 2, 3, 0, 3],
303
+ ],
304
+ );
195
305
  } finally {
306
+ await dropStockFixtures(fixtures);
196
307
  await app.bean.passport.signout();
197
308
  }
198
309
  });
199
310
  });
200
311
 
201
- it('allows exactly one competing reservation for the final unit', async () => {
202
- await app.bean.executor.mockCtx(async () => {
203
- const suffix = `${Date.now()}`;
204
- await app.bean.passport.signinMock();
205
- try {
206
- const skuId = await prepareStock(suffix, 1);
207
- const results = await Promise.allSettled([
208
- reserve(skuId, `${suffix}-first`, 1),
209
- reserve(skuId, `${suffix}-second`, 1),
210
- ]);
211
- assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
212
- assert.equal(results.filter(result => result.status === 'rejected').length, 1);
312
+ it('allows exactly one independent reservation for the final unit', async t => {
313
+ if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
314
+ t.skip('requires PostgreSQL row-lock contention');
315
+ return;
316
+ }
317
+ const fixtures: IStockFixturePartial[] = [];
318
+ const suffix = createTestId();
319
+ let fixture!: IStockFixture;
320
+ try {
321
+ await app.bean.executor.mockCtx(async () => {
322
+ await app.bean.passport.signinMock();
323
+ try {
324
+ fixture = await prepareStock(suffix, fixtures, 1);
325
+ } finally {
326
+ await app.bean.passport.signout();
327
+ }
328
+ });
329
+
330
+ const reserveInContext = async (correlationSuffix: string) => {
331
+ return await app.bean.executor.mockCtx(async () => {
332
+ await app.bean.passport.signinMock();
333
+ try {
334
+ return await reserve(fixture.skuId, correlationSuffix, 1);
335
+ } finally {
336
+ await app.bean.passport.signout();
337
+ }
338
+ });
339
+ };
340
+ const results = await Promise.allSettled([
341
+ reserveInContext(`${suffix}-first`),
342
+ reserveInContext(`${suffix}-second`),
343
+ ]);
344
+ assert.equal(
345
+ results.filter(result => result.status === 'fulfilled').length,
346
+ 1,
347
+ JSON.stringify(results),
348
+ );
349
+ assert.equal(
350
+ results.filter(result => result.status === 'rejected').length,
351
+ 1,
352
+ JSON.stringify(results),
353
+ );
354
+ const rejected = results.find(result => result.status === 'rejected');
355
+ assert.equal(
356
+ (rejected as PromiseRejectedResult | undefined)?.reason?.code,
357
+ 409,
358
+ String((rejected as PromiseRejectedResult | undefined)?.reason),
359
+ );
213
360
 
214
- const balance = await app.scope('commerce-trade').model.stockBalance.get({ skuId });
361
+ await app.bean.executor.mockCtx(async () => {
362
+ const balance = await app
363
+ .scope('commerce-trade')
364
+ .model.stockBalance.get({ skuId: fixture.skuId });
215
365
  assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [1, 1, 0]);
216
366
  const reservations = await app.scope('commerce-trade').model.stockReservation.select({
217
- where: { skuId },
367
+ where: { skuId: fixture.skuId },
368
+ });
369
+ const audits = await app.scope('commerce-trade').model.stockAudit.select({
370
+ where: { skuId: fixture.skuId },
218
371
  });
219
- const audits = await app
220
- .scope('commerce-trade')
221
- .model.stockAudit.select({ where: { skuId } });
222
372
  assert.equal(reservations.length, 1);
223
373
  assert.deepEqual(
224
374
  audits.map(audit => audit.operation),
225
375
  ['adjust', 'reserve'],
226
376
  );
227
- } finally {
228
- await app.bean.passport.signout();
229
- }
230
- });
377
+ assert.equal(audits.filter(audit => audit.operation === 'reserve').length, 1);
378
+ assert.equal(
379
+ audits.some(audit => audit.correlationId === `reservation-${suffix}-first`) ||
380
+ audits.some(audit => audit.correlationId === `reservation-${suffix}-second`),
381
+ true,
382
+ );
383
+ });
384
+ } finally {
385
+ await app.bean.executor.mockCtx(async () => {
386
+ await dropStockFixtures(fixtures);
387
+ });
388
+ }
231
389
  });
232
390
 
233
- it('rolls back reservation and balance when audit persistence fails', async () => {
391
+ it('rolls back reservation, balance, and audit writes in one transaction', async () => {
234
392
  await app.bean.executor.mockCtx(async () => {
235
- const suffix = `${Date.now()}`;
393
+ const fixtures: IStockFixturePartial[] = [];
236
394
  await app.bean.passport.signinMock();
237
395
  try {
238
- const skuId = await prepareStock(suffix);
239
- const modelStockAudit = app.scope('commerce-trade').model.stockAudit;
240
- const insert = modelStockAudit.insert.bind(modelStockAudit);
241
- (modelStockAudit as any).insert = async () => {
242
- throw new Error('reservation audit insert failure');
243
- };
244
- try {
245
- const [_, err] = await catchError(() => reserve(skuId, suffix));
246
- assert.match(err?.message ?? '', /reservation audit insert failure/);
247
- } finally {
248
- (modelStockAudit as any).insert = insert;
249
- }
396
+ const suffix = createTestId();
397
+ const fixture = await prepareStock(suffix, fixtures);
398
+ const balance = await app.scope('commerce-trade').model.stockBalance.get({
399
+ skuId: fixture.skuId,
400
+ });
401
+ const db = app.ctx.db;
402
+ const [_, err] = await catchError(async () => {
403
+ await db.transaction.begin(async () => {
404
+ const modelStockBalance = app
405
+ .scope('commerce-trade')
406
+ .model.stockBalance.newInstance(db);
407
+ const modelStockReservation = app
408
+ .scope('commerce-trade')
409
+ .model.stockReservation.newInstance(db);
410
+ const modelStockAudit = app.scope('commerce-trade').model.stockAudit.newInstance(db);
411
+ const reservation = await modelStockReservation.insert({
412
+ stockBalanceId: balance!.id,
413
+ skuId: fixture.skuId,
414
+ quantity: 2,
415
+ state: 'reserved',
416
+ correlationId: `reservation-${suffix}-rollback`,
417
+ });
418
+ await modelStockBalance.updateById(balance!.id, {
419
+ onHand: 3,
420
+ reserved: 2,
421
+ available: 1,
422
+ });
423
+ await modelStockAudit.insert({
424
+ stockBalanceId: balance!.id,
425
+ skuId: fixture.skuId,
426
+ stockReservationId: reservation.id,
427
+ actorId: app.bean.passport.currentUser!.id,
428
+ operation: 'reserve',
429
+ delta: -2,
430
+ reason: 'transaction rollback proof',
431
+ correlationId: reservation.correlationId,
432
+ priorOnHand: 3,
433
+ priorReserved: 0,
434
+ priorAvailable: 3,
435
+ onHand: 3,
436
+ reserved: 2,
437
+ available: 1,
438
+ });
439
+ throw new Error('transaction rollback proof');
440
+ });
441
+ });
442
+ assert.match(err?.message ?? '', /transaction rollback proof/);
250
443
 
251
- const balance = await app.scope('commerce-trade').model.stockBalance.get({ skuId });
252
- assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [3, 0, 3]);
444
+ const unchanged = await app
445
+ .scope('commerce-trade')
446
+ .model.stockBalance.get({ skuId: fixture.skuId });
447
+ assert.deepEqual([unchanged?.onHand, unchanged?.reserved, unchanged?.available], [3, 0, 3]);
253
448
  assert.equal(
254
- await app.scope('commerce-trade').model.stockReservation.get({ skuId }),
449
+ await app.scope('commerce-trade').model.stockReservation.get({ skuId: fixture.skuId }),
255
450
  undefined,
256
451
  );
452
+ const audits = await app.scope('commerce-trade').model.stockAudit.select({
453
+ where: { skuId: fixture.skuId },
454
+ });
455
+ assert.equal(audits.length, 1);
257
456
  } finally {
457
+ await dropStockFixtures(fixtures);
258
458
  await app.bean.passport.signout();
259
459
  }
260
460
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-image-cloudflare",
3
- "version": "5.0.6",
3
+ "version": "5.0.7",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -162,7 +162,7 @@ export class ServiceImageCloudflare extends BeanBase {
162
162
  options: IImageProviderCloudflareClientOptions,
163
163
  ) {
164
164
  const normalized = this._normalizeClientOptions(options);
165
- const response = await fetch(
165
+ const response = await this.bean.core.fetch(
166
166
  `${normalized.apiBaseUrl}/accounts/${normalized.accountId}/images/v1/${encodeURIComponent(image.resourceId)}`,
167
167
  {
168
168
  method: 'DELETE',
@@ -247,13 +247,16 @@ export class ServiceImageCloudflare extends BeanBase {
247
247
  apiPath: string,
248
248
  init: RequestInit,
249
249
  ) {
250
- const response = await fetch(`${options.apiBaseUrl}/accounts/${options.accountId}${apiPath}`, {
251
- ...init,
252
- headers: {
253
- ...this._createHeaders(options),
254
- ...(init.headers ?? {}),
250
+ const response = await this.bean.core.fetch(
251
+ `${options.apiBaseUrl}/accounts/${options.accountId}${apiPath}`,
252
+ {
253
+ ...init,
254
+ headers: {
255
+ ...this._createHeaders(options),
256
+ ...(init.headers ?? {}),
257
+ },
255
258
  },
256
- });
259
+ );
257
260
  return await this._parseEnvelope<T>(response);
258
261
  }
259
262
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-image",
3
- "version": "5.0.7",
3
+ "version": "5.0.8",
4
4
  "description": "",
5
5
  "author": "",
6
6
  "files": [],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-aspect",
3
- "version": "5.1.11",
3
+ "version": "5.1.12",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -15,15 +15,28 @@ export interface IUseAopMethodPropMetadataInner<T extends keyof IAopMethodRecord
15
15
 
16
16
  export interface IAopMethodRecord {}
17
17
 
18
+ export type AopMethodNext = Next & {
19
+ replay: Next;
20
+ };
21
+
22
+ export type AopMethodNextSync = NextSync & {
23
+ replay: NextSync;
24
+ };
25
+
18
26
  export interface IAopMethodGet {
19
- get(options: IDecoratorAopMethodOptions, next: NextSync, receiver: any, prop: string): any;
27
+ get(
28
+ options: IDecoratorAopMethodOptions,
29
+ next: AopMethodNextSync,
30
+ receiver: any,
31
+ prop: string,
32
+ ): any;
20
33
  }
21
34
 
22
35
  export interface IAopMethodSet {
23
36
  set(
24
37
  options: IDecoratorAopMethodOptions,
25
38
  value: any,
26
- next: NextSync,
39
+ next: AopMethodNextSync,
27
40
  receiver: any,
28
41
  prop: string,
29
42
  ): boolean;
@@ -33,7 +46,7 @@ export interface IAopMethodExecute {
33
46
  execute(
34
47
  options: IDecoratorAopMethodOptions,
35
48
  args: [],
36
- next: Next | NextSync,
49
+ next: AopMethodNext | AopMethodNextSync,
37
50
  receiver: any,
38
51
  prop: string,
39
52
  ): Promise<any> | any;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-core",
3
- "version": "5.1.44",
3
+ "version": "5.1.45",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -84,7 +84,9 @@
84
84
  },
85
85
  "title": "a-core",
86
86
  "vonaModule": {
87
- "dependencies": {},
87
+ "dependencies": {
88
+ "a-retryable": "5.0.0"
89
+ },
88
90
  "globalDependencies": {
89
91
  "@babel/core": true,
90
92
  "@babel/traverse": true,
@@ -1,6 +1,8 @@
1
1
  import { BeanBase } from 'vona';
2
2
  import { Bean } from 'vona-module-a-bean';
3
3
 
4
+ import type { TypeContextFetch } from '../types/fetch.ts';
5
+
4
6
  @Bean()
5
7
  export class BeanCore extends BeanBase {
6
8
  get protocol() {
@@ -11,6 +13,10 @@ export class BeanCore extends BeanBase {
11
13
  return this.app.util.host;
12
14
  }
13
15
 
16
+ get fetch(): TypeContextFetch {
17
+ return this.ctx.state.fetch ?? globalThis.fetch;
18
+ }
19
+
14
20
  getAbsoluteUrl(path?: string) {
15
21
  return this.app.util.getAbsoluteUrl(path);
16
22
  }
@@ -1,2 +1,3 @@
1
1
  export * from './.metadata/index.ts';
2
2
  export * from './lib/index.ts';
3
+ export * from './types/index.ts';
@@ -3,6 +3,7 @@ import type { IFilterOptionsError } from 'vona-module-a-error';
3
3
  import type { IAopMethodOptionsLog } from 'vona-module-a-logger';
4
4
  import type { TypeUseOnionOmitOptionsGlobal } from 'vona-module-a-onion';
5
5
  import type { IAopMethodOptionsTransaction } from 'vona-module-a-orm';
6
+ import type { IAopMethodOptionsRetryable } from 'vona-module-a-retryable';
6
7
  import type { IInterceptorOptionsUpload } from 'vona-module-a-upload';
7
8
 
8
9
  import { Aspect } from 'vona-module-a-aspect';
@@ -29,6 +30,10 @@ function Transaction(options?: Partial<IAopMethodOptionsTransaction>): MethodDec
29
30
  return Aspect.aopMethod('a-orm:transaction', options);
30
31
  }
31
32
 
33
+ function Retryable(options: IAopMethodOptionsRetryable): MethodDecorator {
34
+ return Aspect.aopMethod('a-retryable:retryable', options);
35
+ }
36
+
32
37
  function CaptchaVerify(options?: Partial<IInterceptorOptionsCaptchaVerify>): MethodDecorator {
33
38
  return Aspect.interceptor('a-captcha:captchaVerify', options);
34
39
  }
@@ -46,6 +51,7 @@ export const Core = {
46
51
  error: Error,
47
52
  log: Log,
48
53
  transaction: Transaction,
54
+ retryable: Retryable,
49
55
  captchaVerify: CaptchaVerify,
50
56
  fileUpload: FileUpload,
51
57
  serializer: Serializer,