cabloy 5.1.112 → 5.1.113
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.
- package/.cabloy-version +1 -1
- package/.claude/skills/cabloy-backend-scaffold/SKILL.md +2 -0
- package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +9 -0
- package/.github/workflows/playwright-e2e.yml +34 -2
- package/.github/workflows/vona-test-pg.yml +8 -0
- package/CHANGELOG.md +13 -0
- package/CLAUDE.md +2 -0
- package/cabloy-docs/backend/migration-and-changes.md +13 -9
- package/cabloy-docs/backend/unit-testing.md +25 -0
- package/e2e/scripts/startE2eVona.ts +51 -13
- package/e2e/specs/a-commerce/commerce.spec.ts +1 -0
- package/e2e/specs/cabloy-basic/basic.spec.ts +12 -7
- package/package.json +1 -1
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +6 -9
- package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +50 -21
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +67 -67
- package/vona/src/backend/config/config/config.test.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +234 -0
- package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +3 -3
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +4 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +4 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +4 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockAudit.tsx +39 -7
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +38 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +268 -65
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +276 -81
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/version.ts +12 -12
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/types/version.ts +4 -4
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/pnpm-lock.yaml +14 -14
- package/zova/src/suite/a-commerce/modules/commerce-catalog/src/page/product/controller.tsx +0 -1
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +1 -0
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +17 -7
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +1 -1
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +17 -7
|
@@ -5,32 +5,70 @@ import assert from 'node:assert';
|
|
|
5
5
|
import { describe, it } from 'node:test';
|
|
6
6
|
import { app } from 'vona-mock';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
interface IStockFixture {
|
|
9
|
+
categoryId: number;
|
|
10
|
+
productId: number;
|
|
11
|
+
skuId: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type IStockFixturePartial = Partial<IStockFixture>;
|
|
15
|
+
|
|
16
|
+
async function createSku(suffix: string, fixtures: IStockFixturePartial[]): Promise<IStockFixture> {
|
|
17
|
+
const fixture: IStockFixturePartial = {};
|
|
18
|
+
fixtures.push(fixture);
|
|
19
|
+
fixture.categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
|
|
10
20
|
body: { name: `reservation-category-${suffix}`, published: true },
|
|
11
21
|
});
|
|
12
|
-
|
|
13
|
-
body: {
|
|
22
|
+
fixture.productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
|
|
23
|
+
body: {
|
|
24
|
+
categoryId: fixture.categoryId,
|
|
25
|
+
title: `reservation-product-${suffix}`,
|
|
26
|
+
published: true,
|
|
27
|
+
},
|
|
14
28
|
});
|
|
15
|
-
|
|
29
|
+
fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
|
|
16
30
|
body: {
|
|
17
|
-
productId,
|
|
31
|
+
productId: fixture.productId,
|
|
18
32
|
code: `reservation-sku-${suffix}`,
|
|
19
33
|
priceCents: 100,
|
|
20
34
|
lifecycle: 'active',
|
|
21
35
|
},
|
|
22
36
|
});
|
|
37
|
+
return fixture as IStockFixture;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
async function prepareStock(
|
|
26
|
-
|
|
40
|
+
async function prepareStock(
|
|
41
|
+
suffix: string,
|
|
42
|
+
fixtures: IStockFixturePartial[],
|
|
43
|
+
quantity = 3,
|
|
44
|
+
): Promise<IStockFixture> {
|
|
45
|
+
const fixture = await createSku(suffix, fixtures);
|
|
27
46
|
await app.scope('commerce-trade').service.stockBalance.adjustStock({
|
|
28
|
-
skuId,
|
|
47
|
+
skuId: fixture.skuId,
|
|
29
48
|
delta: quantity,
|
|
30
49
|
reason: 'reservation test setup',
|
|
31
50
|
correlationId: `reservation-setup-${suffix}`,
|
|
32
51
|
});
|
|
33
|
-
return
|
|
52
|
+
return fixture;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function dropStockFixtures(fixtures: IStockFixturePartial[]) {
|
|
56
|
+
const scopeTrade = app.scope('commerce-trade');
|
|
57
|
+
const scopeCatalog = app.scope('commerce-catalog');
|
|
58
|
+
for (const fixture of fixtures.toReversed()) {
|
|
59
|
+
if (fixture.skuId !== undefined) {
|
|
60
|
+
await scopeTrade.model.stockAudit.delete({ skuId: fixture.skuId });
|
|
61
|
+
await scopeTrade.model.stockReservation.delete({ skuId: fixture.skuId });
|
|
62
|
+
await scopeTrade.model.stockBalance.delete({ skuId: fixture.skuId });
|
|
63
|
+
await scopeCatalog.model.sku.delete({ id: fixture.skuId });
|
|
64
|
+
}
|
|
65
|
+
if (fixture.productId !== undefined) {
|
|
66
|
+
await scopeCatalog.model.product.delete({ id: fixture.productId });
|
|
67
|
+
}
|
|
68
|
+
if (fixture.categoryId !== undefined) {
|
|
69
|
+
await scopeCatalog.model.category.delete({ id: fixture.categoryId });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
34
72
|
}
|
|
35
73
|
|
|
36
74
|
async function reserve(
|
|
@@ -62,14 +100,15 @@ async function assertReservationRejected(skuId: number, suffix: string, expected
|
|
|
62
100
|
describe('stockReservation.test.ts', () => {
|
|
63
101
|
it('reserves and consumes stock exactly once with traceable audits', async () => {
|
|
64
102
|
await app.bean.executor.mockCtx(async () => {
|
|
65
|
-
const
|
|
103
|
+
const fixtures: IStockFixturePartial[] = [];
|
|
66
104
|
await app.bean.passport.signinMock();
|
|
67
105
|
try {
|
|
68
|
-
const
|
|
69
|
-
const
|
|
106
|
+
const suffix = `${Date.now()}`;
|
|
107
|
+
const fixture = await prepareStock(suffix, fixtures);
|
|
108
|
+
const reservation = await reserve(fixture.skuId, suffix);
|
|
70
109
|
const balanceAfterReserve = await app
|
|
71
110
|
.scope('commerce-trade')
|
|
72
|
-
.model.stockBalance.get({ skuId });
|
|
111
|
+
.model.stockBalance.get({ skuId: fixture.skuId });
|
|
73
112
|
assert.deepEqual(
|
|
74
113
|
[
|
|
75
114
|
balanceAfterReserve?.onHand,
|
|
@@ -79,7 +118,7 @@ describe('stockReservation.test.ts', () => {
|
|
|
79
118
|
[3, 2, 1],
|
|
80
119
|
);
|
|
81
120
|
|
|
82
|
-
const duplicate = await reserve(skuId, suffix);
|
|
121
|
+
const duplicate = await reserve(fixture.skuId, suffix);
|
|
83
122
|
assert.equal(duplicate.id, reservation.id);
|
|
84
123
|
const consumed = await app.scope('commerce-trade').service.stockBalance.consume({
|
|
85
124
|
reservationId: reservation.id,
|
|
@@ -94,7 +133,7 @@ describe('stockReservation.test.ts', () => {
|
|
|
94
133
|
|
|
95
134
|
const balanceAfterConsume = await app
|
|
96
135
|
.scope('commerce-trade')
|
|
97
|
-
.model.stockBalance.get({ skuId });
|
|
136
|
+
.model.stockBalance.get({ skuId: fixture.skuId });
|
|
98
137
|
assert.deepEqual(
|
|
99
138
|
[
|
|
100
139
|
balanceAfterConsume?.onHand,
|
|
@@ -103,15 +142,16 @@ describe('stockReservation.test.ts', () => {
|
|
|
103
142
|
],
|
|
104
143
|
[1, 0, 1],
|
|
105
144
|
);
|
|
106
|
-
const audits = await app
|
|
107
|
-
.
|
|
108
|
-
|
|
145
|
+
const audits = await app.scope('commerce-trade').model.stockAudit.select({
|
|
146
|
+
where: { skuId: fixture.skuId },
|
|
147
|
+
});
|
|
109
148
|
assert.deepEqual(
|
|
110
149
|
audits.map(audit => audit.operation),
|
|
111
150
|
['adjust', 'reserve', 'consume'],
|
|
112
151
|
);
|
|
113
152
|
assert.equal(audits[1].stockReservationId, reservation.id);
|
|
114
153
|
} finally {
|
|
154
|
+
await dropStockFixtures(fixtures);
|
|
115
155
|
await app.bean.passport.signout();
|
|
116
156
|
}
|
|
117
157
|
});
|
|
@@ -119,36 +159,29 @@ describe('stockReservation.test.ts', () => {
|
|
|
119
159
|
|
|
120
160
|
it('rejects reservations for inactive or unpublished catalog data without writes', async () => {
|
|
121
161
|
await app.bean.executor.mockCtx(async () => {
|
|
122
|
-
const
|
|
162
|
+
const fixtures: IStockFixturePartial[] = [];
|
|
123
163
|
await app.bean.passport.signinMock();
|
|
124
164
|
try {
|
|
125
|
-
const
|
|
126
|
-
await
|
|
165
|
+
const suffix = `${Date.now()}`;
|
|
166
|
+
const inactiveSku = await prepareStock(`${suffix}-inactive`, fixtures);
|
|
167
|
+
await app.scope('commerce-catalog').model.sku.updateById(inactiveSku.skuId, {
|
|
127
168
|
lifecycle: 'inactive',
|
|
128
169
|
});
|
|
129
|
-
await assertReservationRejected(
|
|
170
|
+
await assertReservationRejected(inactiveSku.skuId, `${suffix}-inactive`, 404);
|
|
130
171
|
|
|
131
|
-
const
|
|
132
|
-
const unpublishedProductSku = await app
|
|
133
|
-
.scope('commerce-catalog')
|
|
134
|
-
.model.sku.getById(unpublishedProductSkuId);
|
|
172
|
+
const unpublishedProductSku = await prepareStock(`${suffix}-product`, fixtures);
|
|
135
173
|
await app
|
|
136
174
|
.scope('commerce-catalog')
|
|
137
|
-
.model.product.updateById(unpublishedProductSku
|
|
138
|
-
await assertReservationRejected(
|
|
175
|
+
.model.product.updateById(unpublishedProductSku.productId, { published: false });
|
|
176
|
+
await assertReservationRejected(unpublishedProductSku.skuId, `${suffix}-product`, 409);
|
|
139
177
|
|
|
140
|
-
const
|
|
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);
|
|
178
|
+
const unpublishedCategorySku = await prepareStock(`${suffix}-category`, fixtures);
|
|
147
179
|
await app
|
|
148
180
|
.scope('commerce-catalog')
|
|
149
|
-
.model.category.updateById(
|
|
150
|
-
await assertReservationRejected(
|
|
181
|
+
.model.category.updateById(unpublishedCategorySku.categoryId, { published: false });
|
|
182
|
+
await assertReservationRejected(unpublishedCategorySku.skuId, `${suffix}-category`, 409);
|
|
151
183
|
} finally {
|
|
184
|
+
await dropStockFixtures(fixtures);
|
|
152
185
|
await app.bean.passport.signout();
|
|
153
186
|
}
|
|
154
187
|
});
|
|
@@ -156,11 +189,12 @@ describe('stockReservation.test.ts', () => {
|
|
|
156
189
|
|
|
157
190
|
it('releases and restores stock only from legal reservation states', async () => {
|
|
158
191
|
await app.bean.executor.mockCtx(async () => {
|
|
159
|
-
const
|
|
192
|
+
const fixtures: IStockFixturePartial[] = [];
|
|
160
193
|
await app.bean.passport.signinMock();
|
|
161
194
|
try {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
195
|
+
const suffix = `${Date.now()}`;
|
|
196
|
+
const fixture = await prepareStock(suffix, fixtures);
|
|
197
|
+
const releasedReservation = await reserve(fixture.skuId, `${suffix}-release`);
|
|
164
198
|
const released = await app.scope('commerce-trade').service.stockBalance.release({
|
|
165
199
|
reservationId: releasedReservation.id,
|
|
166
200
|
reason: 'payment failed',
|
|
@@ -172,7 +206,7 @@ describe('stockReservation.test.ts', () => {
|
|
|
172
206
|
});
|
|
173
207
|
assert.equal(duplicateRelease.state, 'released');
|
|
174
208
|
|
|
175
|
-
const consumedReservation = await reserve(skuId, `${suffix}-restore`, 1);
|
|
209
|
+
const consumedReservation = await reserve(fixture.skuId, `${suffix}-restore`, 1);
|
|
176
210
|
await app.scope('commerce-trade').service.stockBalance.consume({
|
|
177
211
|
reservationId: consumedReservation.id,
|
|
178
212
|
reason: 'payment success',
|
|
@@ -182,6 +216,11 @@ describe('stockReservation.test.ts', () => {
|
|
|
182
216
|
reason: 'refund success',
|
|
183
217
|
});
|
|
184
218
|
assert.equal(restored.state, 'restored');
|
|
219
|
+
const duplicateRestore = await app.scope('commerce-trade').service.stockBalance.restore({
|
|
220
|
+
reservationId: consumedReservation.id,
|
|
221
|
+
reason: 'refund success retry',
|
|
222
|
+
});
|
|
223
|
+
assert.equal(duplicateRestore.state, 'restored');
|
|
185
224
|
|
|
186
225
|
const [_, err] = await catchError(() =>
|
|
187
226
|
app.scope('commerce-trade').service.stockBalance.consume({
|
|
@@ -190,71 +229,227 @@ describe('stockReservation.test.ts', () => {
|
|
|
190
229
|
}),
|
|
191
230
|
);
|
|
192
231
|
assert.equal(err?.code, 409);
|
|
193
|
-
const balance = await app
|
|
232
|
+
const balance = await app
|
|
233
|
+
.scope('commerce-trade')
|
|
234
|
+
.model.stockBalance.get({ skuId: fixture.skuId });
|
|
194
235
|
assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [3, 0, 3]);
|
|
236
|
+
const audits = await app.scope('commerce-trade').model.stockAudit.select({
|
|
237
|
+
where: { skuId: fixture.skuId },
|
|
238
|
+
});
|
|
239
|
+
assert.equal(audits.length, 6);
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
audits.map(audit => audit.operation),
|
|
242
|
+
['adjust', 'reserve', 'release', 'reserve', 'consume', 'restore'],
|
|
243
|
+
);
|
|
244
|
+
assert.deepEqual(
|
|
245
|
+
audits.map(audit => audit.stockBalanceId),
|
|
246
|
+
audits.map(() => balance?.id),
|
|
247
|
+
);
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
audits.map(audit => audit.stockReservationId),
|
|
250
|
+
[
|
|
251
|
+
null,
|
|
252
|
+
releasedReservation.id,
|
|
253
|
+
releasedReservation.id,
|
|
254
|
+
consumedReservation.id,
|
|
255
|
+
consumedReservation.id,
|
|
256
|
+
consumedReservation.id,
|
|
257
|
+
],
|
|
258
|
+
);
|
|
259
|
+
assert.deepEqual(
|
|
260
|
+
audits.map(audit => audit.correlationId),
|
|
261
|
+
[
|
|
262
|
+
`reservation-setup-${suffix}`,
|
|
263
|
+
`reservation-${suffix}-release`,
|
|
264
|
+
`reservation-${suffix}-release`,
|
|
265
|
+
`reservation-${suffix}-restore`,
|
|
266
|
+
`reservation-${suffix}-restore`,
|
|
267
|
+
`reservation-${suffix}-restore`,
|
|
268
|
+
],
|
|
269
|
+
);
|
|
270
|
+
assert.deepEqual(
|
|
271
|
+
audits.map(audit => audit.reason),
|
|
272
|
+
[
|
|
273
|
+
'reservation test setup',
|
|
274
|
+
'reservation test',
|
|
275
|
+
'payment failed',
|
|
276
|
+
'reservation test',
|
|
277
|
+
'payment success',
|
|
278
|
+
'refund success',
|
|
279
|
+
],
|
|
280
|
+
);
|
|
281
|
+
assert.deepEqual(
|
|
282
|
+
audits.map(audit => [
|
|
283
|
+
audit.delta,
|
|
284
|
+
audit.priorOnHand,
|
|
285
|
+
audit.priorReserved,
|
|
286
|
+
audit.priorAvailable,
|
|
287
|
+
audit.onHand,
|
|
288
|
+
audit.reserved,
|
|
289
|
+
audit.available,
|
|
290
|
+
]),
|
|
291
|
+
[
|
|
292
|
+
[3, 0, 0, 0, 3, 0, 3],
|
|
293
|
+
[-2, 3, 0, 3, 3, 2, 1],
|
|
294
|
+
[0, 3, 2, 1, 3, 0, 3],
|
|
295
|
+
[-1, 3, 0, 3, 3, 1, 2],
|
|
296
|
+
[-1, 3, 1, 2, 2, 0, 2],
|
|
297
|
+
[1, 2, 0, 2, 3, 0, 3],
|
|
298
|
+
],
|
|
299
|
+
);
|
|
195
300
|
} finally {
|
|
301
|
+
await dropStockFixtures(fixtures);
|
|
196
302
|
await app.bean.passport.signout();
|
|
197
303
|
}
|
|
198
304
|
});
|
|
199
305
|
});
|
|
200
306
|
|
|
201
|
-
it('allows exactly one
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
307
|
+
it('allows exactly one independent reservation for the final unit', async t => {
|
|
308
|
+
if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
|
|
309
|
+
t.skip('requires PostgreSQL row-lock contention');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const fixtures: IStockFixturePartial[] = [];
|
|
313
|
+
const suffix = `${Date.now()}`;
|
|
314
|
+
let fixture!: IStockFixture;
|
|
315
|
+
try {
|
|
316
|
+
await app.bean.executor.mockCtx(async () => {
|
|
317
|
+
await app.bean.passport.signinMock();
|
|
318
|
+
try {
|
|
319
|
+
fixture = await prepareStock(suffix, fixtures, 1);
|
|
320
|
+
} finally {
|
|
321
|
+
await app.bean.passport.signout();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const reserveInContext = async (correlationSuffix: string) => {
|
|
326
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
327
|
+
await app.bean.passport.signinMock();
|
|
328
|
+
try {
|
|
329
|
+
return await reserve(fixture.skuId, correlationSuffix, 1);
|
|
330
|
+
} finally {
|
|
331
|
+
await app.bean.passport.signout();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
const results = await Promise.allSettled([
|
|
336
|
+
reserveInContext(`${suffix}-first`),
|
|
337
|
+
reserveInContext(`${suffix}-second`),
|
|
338
|
+
]);
|
|
339
|
+
assert.equal(
|
|
340
|
+
results.filter(result => result.status === 'fulfilled').length,
|
|
341
|
+
1,
|
|
342
|
+
JSON.stringify(results),
|
|
343
|
+
);
|
|
344
|
+
assert.equal(
|
|
345
|
+
results.filter(result => result.status === 'rejected').length,
|
|
346
|
+
1,
|
|
347
|
+
JSON.stringify(results),
|
|
348
|
+
);
|
|
349
|
+
const rejected = results.find(result => result.status === 'rejected');
|
|
350
|
+
assert.equal(
|
|
351
|
+
(rejected as PromiseRejectedResult | undefined)?.reason?.code,
|
|
352
|
+
409,
|
|
353
|
+
String((rejected as PromiseRejectedResult | undefined)?.reason),
|
|
354
|
+
);
|
|
213
355
|
|
|
214
|
-
|
|
356
|
+
await app.bean.executor.mockCtx(async () => {
|
|
357
|
+
const balance = await app
|
|
358
|
+
.scope('commerce-trade')
|
|
359
|
+
.model.stockBalance.get({ skuId: fixture.skuId });
|
|
215
360
|
assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [1, 1, 0]);
|
|
216
361
|
const reservations = await app.scope('commerce-trade').model.stockReservation.select({
|
|
217
|
-
where: { skuId },
|
|
362
|
+
where: { skuId: fixture.skuId },
|
|
363
|
+
});
|
|
364
|
+
const audits = await app.scope('commerce-trade').model.stockAudit.select({
|
|
365
|
+
where: { skuId: fixture.skuId },
|
|
218
366
|
});
|
|
219
|
-
const audits = await app
|
|
220
|
-
.scope('commerce-trade')
|
|
221
|
-
.model.stockAudit.select({ where: { skuId } });
|
|
222
367
|
assert.equal(reservations.length, 1);
|
|
223
368
|
assert.deepEqual(
|
|
224
369
|
audits.map(audit => audit.operation),
|
|
225
370
|
['adjust', 'reserve'],
|
|
226
371
|
);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
372
|
+
assert.equal(audits.filter(audit => audit.operation === 'reserve').length, 1);
|
|
373
|
+
assert.equal(
|
|
374
|
+
audits.some(audit => audit.correlationId === `reservation-${suffix}-first`) ||
|
|
375
|
+
audits.some(audit => audit.correlationId === `reservation-${suffix}-second`),
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
} finally {
|
|
380
|
+
await app.bean.executor.mockCtx(async () => {
|
|
381
|
+
await dropStockFixtures(fixtures);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
231
384
|
});
|
|
232
385
|
|
|
233
|
-
it('rolls back reservation
|
|
386
|
+
it('rolls back reservation, balance, and audit writes in one transaction', async () => {
|
|
234
387
|
await app.bean.executor.mockCtx(async () => {
|
|
235
|
-
const
|
|
388
|
+
const fixtures: IStockFixturePartial[] = [];
|
|
236
389
|
await app.bean.passport.signinMock();
|
|
237
390
|
try {
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
391
|
+
const suffix = `${Date.now()}`;
|
|
392
|
+
const fixture = await prepareStock(suffix, fixtures);
|
|
393
|
+
const balance = await app.scope('commerce-trade').model.stockBalance.get({
|
|
394
|
+
skuId: fixture.skuId,
|
|
395
|
+
});
|
|
396
|
+
const db = app.ctx.db;
|
|
397
|
+
const [_, err] = await catchError(async () => {
|
|
398
|
+
await db.transaction.begin(async () => {
|
|
399
|
+
const modelStockBalance = app
|
|
400
|
+
.scope('commerce-trade')
|
|
401
|
+
.model.stockBalance.newInstance(db);
|
|
402
|
+
const modelStockReservation = app
|
|
403
|
+
.scope('commerce-trade')
|
|
404
|
+
.model.stockReservation.newInstance(db);
|
|
405
|
+
const modelStockAudit = app.scope('commerce-trade').model.stockAudit.newInstance(db);
|
|
406
|
+
const reservation = await modelStockReservation.insert({
|
|
407
|
+
stockBalanceId: balance!.id,
|
|
408
|
+
skuId: fixture.skuId,
|
|
409
|
+
quantity: 2,
|
|
410
|
+
state: 'reserved',
|
|
411
|
+
correlationId: `reservation-${suffix}-rollback`,
|
|
412
|
+
});
|
|
413
|
+
await modelStockBalance.updateById(balance!.id, {
|
|
414
|
+
onHand: 3,
|
|
415
|
+
reserved: 2,
|
|
416
|
+
available: 1,
|
|
417
|
+
});
|
|
418
|
+
await modelStockAudit.insert({
|
|
419
|
+
stockBalanceId: balance!.id,
|
|
420
|
+
skuId: fixture.skuId,
|
|
421
|
+
stockReservationId: reservation.id,
|
|
422
|
+
actorId: app.bean.passport.currentUser!.id,
|
|
423
|
+
operation: 'reserve',
|
|
424
|
+
delta: -2,
|
|
425
|
+
reason: 'transaction rollback proof',
|
|
426
|
+
correlationId: reservation.correlationId,
|
|
427
|
+
priorOnHand: 3,
|
|
428
|
+
priorReserved: 0,
|
|
429
|
+
priorAvailable: 3,
|
|
430
|
+
onHand: 3,
|
|
431
|
+
reserved: 2,
|
|
432
|
+
available: 1,
|
|
433
|
+
});
|
|
434
|
+
throw new Error('transaction rollback proof');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
assert.match(err?.message ?? '', /transaction rollback proof/);
|
|
250
438
|
|
|
251
|
-
const
|
|
252
|
-
|
|
439
|
+
const unchanged = await app
|
|
440
|
+
.scope('commerce-trade')
|
|
441
|
+
.model.stockBalance.get({ skuId: fixture.skuId });
|
|
442
|
+
assert.deepEqual([unchanged?.onHand, unchanged?.reserved, unchanged?.available], [3, 0, 3]);
|
|
253
443
|
assert.equal(
|
|
254
|
-
await app.scope('commerce-trade').model.stockReservation.get({ skuId }),
|
|
444
|
+
await app.scope('commerce-trade').model.stockReservation.get({ skuId: fixture.skuId }),
|
|
255
445
|
undefined,
|
|
256
446
|
);
|
|
447
|
+
const audits = await app.scope('commerce-trade').model.stockAudit.select({
|
|
448
|
+
where: { skuId: fixture.skuId },
|
|
449
|
+
});
|
|
450
|
+
assert.equal(audits.length, 1);
|
|
257
451
|
} finally {
|
|
452
|
+
await dropStockFixtures(fixtures);
|
|
258
453
|
await app.bean.passport.signout();
|
|
259
454
|
}
|
|
260
455
|
});
|
|
@@ -52,12 +52,12 @@ export class ServiceStartup extends BeanBase {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// version
|
|
55
|
+
// version seed
|
|
56
56
|
if (this.app.meta.isTest) {
|
|
57
57
|
const instanceName = '';
|
|
58
58
|
await this.bean.executor.newCtx(
|
|
59
59
|
async () => {
|
|
60
|
-
await this.$scope.version.service.version.
|
|
60
|
+
await this.$scope.version.service.version.__instanceSeed(instanceName);
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
dbInfo: { level: 1 },
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
IMetaVersionInit,
|
|
13
13
|
IMetaVersionOptions,
|
|
14
14
|
IMetaVersionOptionsInner,
|
|
15
|
-
|
|
15
|
+
IMetaVersionSeed,
|
|
16
16
|
IMetaVersionUpdate,
|
|
17
17
|
} from '../types/version.ts';
|
|
18
18
|
|
|
@@ -61,11 +61,11 @@ export class ServiceVersion extends BeanBase {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
async
|
|
65
|
-
await this.__check({ scene: '
|
|
64
|
+
async __instanceSeed(instanceName: keyof IInstanceRecord) {
|
|
65
|
+
await this.__check({ scene: 'seed', instanceName });
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// scene:
|
|
68
|
+
// scene: update/init/seed
|
|
69
69
|
async __check(options: IMetaVersionOptionsInner) {
|
|
70
70
|
options.result = {};
|
|
71
71
|
|
|
@@ -158,11 +158,11 @@ export class ServiceVersion extends BeanBase {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
if (options.scene === '
|
|
162
|
-
//
|
|
161
|
+
if (options.scene === 'seed') {
|
|
162
|
+
// seed module
|
|
163
163
|
await this.bean.executor.newCtx(
|
|
164
164
|
async () => {
|
|
165
|
-
await this.
|
|
165
|
+
await this.__seedModuleTransaction(module, fileVersionNew, options);
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
instanceName: options.instanceName,
|
|
@@ -260,13 +260,13 @@ export class ServiceVersion extends BeanBase {
|
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
//
|
|
264
|
-
async
|
|
263
|
+
// seed module
|
|
264
|
+
async __seedModuleTransaction(module, version, options) {
|
|
265
265
|
// bean
|
|
266
|
-
const beanVersion = this.__getBeanVersion<
|
|
266
|
+
const beanVersion = this.__getBeanVersion<IMetaVersionSeed>(module.info.relativeName, false);
|
|
267
267
|
// execute
|
|
268
|
-
if (beanVersion && beanVersion.
|
|
269
|
-
await beanVersion.
|
|
268
|
+
if (beanVersion && beanVersion.seed) {
|
|
269
|
+
await beanVersion.seed({ ...options, version });
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
@@ -2,7 +2,7 @@ import type { IInstanceRecord } from 'vona';
|
|
|
2
2
|
import type { ConfigInstanceBase } from 'vona-module-a-instance';
|
|
3
3
|
|
|
4
4
|
export interface IMetaVersionOptions {
|
|
5
|
-
scene: 'update' | 'init' | '
|
|
5
|
+
scene: 'update' | 'init' | 'seed';
|
|
6
6
|
instanceName?: keyof IInstanceRecord;
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ export interface IMetaVersionInitOptions extends ConfigInstanceBase {
|
|
|
19
19
|
version: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export interface
|
|
22
|
+
export interface IMetaVersionSeedOptions {
|
|
23
23
|
version: number;
|
|
24
24
|
instanceName: string;
|
|
25
25
|
}
|
|
@@ -32,8 +32,8 @@ export interface IMetaVersionInit {
|
|
|
32
32
|
init: (options: IMetaVersionInitOptions) => Promise<void>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export interface
|
|
36
|
-
|
|
35
|
+
export interface IMetaVersionSeed {
|
|
36
|
+
seed: (options: IMetaVersionSeedOptions) => Promise<void>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
declare module 'vona' {
|