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.
- 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 +13 -0
- package/.github/workflows/playwright-e2e.yml +34 -2
- package/.github/workflows/vona-test-mysql.yml +12 -0
- package/.github/workflows/vona-test-pg.yml +12 -0
- package/CHANGELOG.md +27 -0
- package/CLAUDE.md +6 -0
- package/cabloy-docs/backend/internal-aop-guide.md +35 -0
- package/cabloy-docs/backend/migration-and-changes.md +13 -9
- package/cabloy-docs/backend/unit-testing.md +97 -1
- 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 +74 -32
- package/vona/packages-utils/compose/package.json +1 -1
- package/vona/packages-utils/compose/src/index.ts +19 -11
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +82 -67
- package/vona/src/backend/config/config/config.test.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +234 -0
- package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
- package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +11 -13
- package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +79 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +14 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +14 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -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/entity/stockReservation.tsx +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +32 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +274 -66
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +282 -82
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
- package/vona/src/suite-vendor/a-image/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
- package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
- 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/modules/a-vona/package.json +2 -1
- package/vona/src/suite-vendor/a-vona/package.json +2 -1
- package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
- package/zova/packages-utils/zova-jsx/package.json +3 -3
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- 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-promotion/cli/openapi.config.ts +9 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +14 -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
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
|
|
3
|
+
import { BeanBase } from 'vona';
|
|
4
|
+
import { Service } from 'vona-module-a-bean';
|
|
5
|
+
import { Core } from 'vona-module-a-core';
|
|
6
|
+
|
|
7
|
+
import type { DtoCheckoutCreate } from '../dto/checkoutCreate.tsx';
|
|
8
|
+
import type { DtoCheckoutResult } from '../dto/checkoutResult.tsx';
|
|
9
|
+
import type { DtoOrderAddressSnapshot } from '../dto/orderAddressSnapshot.tsx';
|
|
10
|
+
import type { DtoOrderCouponSnapshot } from '../dto/orderCouponSnapshot.tsx';
|
|
11
|
+
import type { DtoOrderLineSkuAttributeSnapshot } from '../dto/orderLineSkuAttributeSnapshot.tsx';
|
|
12
|
+
import type { EntityOrder } from '../entity/order.tsx';
|
|
13
|
+
import type { EntityOrderLine } from '../entity/orderLine.tsx';
|
|
14
|
+
|
|
15
|
+
const maxOrderCents = 2_147_483_647;
|
|
16
|
+
|
|
17
|
+
const serializationRetryOptions = {
|
|
18
|
+
retries: 1,
|
|
19
|
+
factor: 1,
|
|
20
|
+
minTimeout: 0,
|
|
21
|
+
maxTimeout: 0,
|
|
22
|
+
randomize: false,
|
|
23
|
+
errorCodes: ['40001'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface IOrderSnapshotLineCommand {
|
|
27
|
+
skuId: TableIdentity;
|
|
28
|
+
quantity: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IOrderSnapshotCreateCommand {
|
|
32
|
+
addressId: TableIdentity;
|
|
33
|
+
correlationId: string;
|
|
34
|
+
couponGrantId?: TableIdentity;
|
|
35
|
+
lines: IOrderSnapshotLineCommand[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IOrderSnapshotCreateResult {
|
|
39
|
+
order: EntityOrder;
|
|
40
|
+
lines: EntityOrderLine[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface IPreparedOrderLine {
|
|
44
|
+
index: number;
|
|
45
|
+
skuId: TableIdentity;
|
|
46
|
+
productId: TableIdentity;
|
|
47
|
+
skuCodeSnapshot: string;
|
|
48
|
+
titleSnapshot: string;
|
|
49
|
+
skuAttributesSnapshot: DtoOrderLineSkuAttributeSnapshot[];
|
|
50
|
+
unitPriceCents: number;
|
|
51
|
+
quantity: number;
|
|
52
|
+
eligibleSubtotalCents: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Service()
|
|
56
|
+
export class ServiceOrder extends BeanBase {
|
|
57
|
+
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
58
|
+
@Core.retryable(serializationRetryOptions)
|
|
59
|
+
async createSnapshot(command: IOrderSnapshotCreateCommand): Promise<IOrderSnapshotCreateResult> {
|
|
60
|
+
this._assertCommand(command);
|
|
61
|
+
|
|
62
|
+
const existingOrder = await this.scope.model.order.getForUpdate({
|
|
63
|
+
userId: this.bean.passport.currentUser!.id,
|
|
64
|
+
correlationId: command.correlationId,
|
|
65
|
+
});
|
|
66
|
+
if (existingOrder) {
|
|
67
|
+
await this._assertExistingSnapshotMatchesCommand(existingOrder, command);
|
|
68
|
+
return (await this._viewSnapshot(existingOrder))!;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const address = await this.$scope.commerceMember.model.address.get({
|
|
72
|
+
id: command.addressId,
|
|
73
|
+
userId: this.bean.passport.currentUser!.id,
|
|
74
|
+
});
|
|
75
|
+
if (!address) {
|
|
76
|
+
this.app.throw(404, 'Address not found');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const preparedLines = await Promise.all(
|
|
80
|
+
command.lines.map((line, index) => this._prepareLine(line, index)),
|
|
81
|
+
);
|
|
82
|
+
const eligibleSubtotalCents = preparedLines.reduce((total, line) => {
|
|
83
|
+
const nextTotal = total + line.eligibleSubtotalCents;
|
|
84
|
+
if (!Number.isSafeInteger(nextTotal) || nextTotal > maxOrderCents) {
|
|
85
|
+
this.app.throw(400, 'order total exceeds the supported range');
|
|
86
|
+
}
|
|
87
|
+
return nextTotal;
|
|
88
|
+
}, 0);
|
|
89
|
+
const order = await this.scope.model.order.insert({
|
|
90
|
+
userId: this.bean.passport.currentUser!.id,
|
|
91
|
+
addressId: address.id,
|
|
92
|
+
correlationId: command.correlationId,
|
|
93
|
+
addressSnapshot: this._toAddressSnapshot(address),
|
|
94
|
+
state: 'awaiting_payment',
|
|
95
|
+
currency: 'USD',
|
|
96
|
+
eligibleSubtotalCents,
|
|
97
|
+
discountCents: 0,
|
|
98
|
+
payableTotalCents: eligibleSubtotalCents,
|
|
99
|
+
reservationExpiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
|
100
|
+
});
|
|
101
|
+
let couponSnapshot: DtoOrderCouponSnapshot | undefined;
|
|
102
|
+
if (command.couponGrantId) {
|
|
103
|
+
const reservation = await this.$scope.commercePromotion.service.coupon.reserve({
|
|
104
|
+
couponGrantId: command.couponGrantId,
|
|
105
|
+
userId: this.bean.passport.currentUser!.id,
|
|
106
|
+
orderId: order.id,
|
|
107
|
+
eligibleSubtotalCents,
|
|
108
|
+
currency: 'USD',
|
|
109
|
+
correlationId: `${command.correlationId}:coupon`,
|
|
110
|
+
reason: 'order snapshot created',
|
|
111
|
+
});
|
|
112
|
+
const couponGrant = reservation.couponGrant;
|
|
113
|
+
couponSnapshot = {
|
|
114
|
+
couponGrantId: couponGrant.id,
|
|
115
|
+
couponTemplateId: couponGrant.templateId,
|
|
116
|
+
couponCode: couponGrant.couponCode,
|
|
117
|
+
templateName: couponGrant.templateNameSnapshot,
|
|
118
|
+
currency: couponGrant.currencySnapshot,
|
|
119
|
+
fixedDiscountCents: couponGrant.discountCentsSnapshot,
|
|
120
|
+
minSpendCents: couponGrant.minSpendCentsSnapshot,
|
|
121
|
+
appliedDiscountCents: reservation.discountCents,
|
|
122
|
+
};
|
|
123
|
+
await this.scope.model.order.updateById(order.id, {
|
|
124
|
+
couponSnapshot,
|
|
125
|
+
discountCents: reservation.discountCents,
|
|
126
|
+
payableTotalCents: eligibleSubtotalCents - reservation.discountCents,
|
|
127
|
+
});
|
|
128
|
+
order.couponSnapshot = couponSnapshot;
|
|
129
|
+
order.discountCents = reservation.discountCents;
|
|
130
|
+
order.payableTotalCents = eligibleSubtotalCents - reservation.discountCents;
|
|
131
|
+
}
|
|
132
|
+
const lines = await Promise.all(
|
|
133
|
+
preparedLines.map(async line => {
|
|
134
|
+
const orderLine = await this.scope.model.orderLine.insert({
|
|
135
|
+
orderId: order.id,
|
|
136
|
+
skuId: line.skuId,
|
|
137
|
+
productId: line.productId,
|
|
138
|
+
skuCodeSnapshot: line.skuCodeSnapshot,
|
|
139
|
+
titleSnapshot: line.titleSnapshot,
|
|
140
|
+
skuAttributesSnapshot: line.skuAttributesSnapshot,
|
|
141
|
+
unitPriceCents: line.unitPriceCents,
|
|
142
|
+
quantity: line.quantity,
|
|
143
|
+
eligibleSubtotalCents: line.eligibleSubtotalCents,
|
|
144
|
+
lineTotalCents: line.eligibleSubtotalCents,
|
|
145
|
+
});
|
|
146
|
+
await this.scope.service.stockBalance.reserve({
|
|
147
|
+
skuId: orderLine.skuId,
|
|
148
|
+
orderLineId: orderLine.id,
|
|
149
|
+
quantity: orderLine.quantity,
|
|
150
|
+
correlationId: `${command.correlationId}:line:${line.index}`,
|
|
151
|
+
reason: 'order snapshot created',
|
|
152
|
+
});
|
|
153
|
+
return orderLine;
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
return { order, lines };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
160
|
+
@Core.retryable(serializationRetryOptions)
|
|
161
|
+
async checkout(command: DtoCheckoutCreate): Promise<DtoCheckoutResult> {
|
|
162
|
+
this._assertCheckoutCommand(command);
|
|
163
|
+
const userId = this.bean.passport.currentUser!.id;
|
|
164
|
+
const existingOrder = await this.scope.model.order.getForUpdate({
|
|
165
|
+
userId,
|
|
166
|
+
correlationId: command.correlationId,
|
|
167
|
+
});
|
|
168
|
+
if (existingOrder) {
|
|
169
|
+
if (
|
|
170
|
+
String(existingOrder.addressId) !== String(command.addressId) ||
|
|
171
|
+
String(existingOrder.couponSnapshot?.couponGrantId ?? '') !==
|
|
172
|
+
String(command.couponGrantId ?? '')
|
|
173
|
+
) {
|
|
174
|
+
this.app.throw(409, 'checkout correlation conflicts with an existing order');
|
|
175
|
+
}
|
|
176
|
+
return await this._checkoutResult(existingOrder);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cart = await this.scope.model.cart.getForUpdate({ userId });
|
|
180
|
+
if (!cart) this.app.throw(409, 'cart is empty');
|
|
181
|
+
const cartItems = await this.scope.model.cartItem.select({
|
|
182
|
+
where: { cartId: cart.id },
|
|
183
|
+
orders: [['skuId', 'asc']],
|
|
184
|
+
});
|
|
185
|
+
if (cartItems.length === 0) this.app.throw(409, 'cart is empty');
|
|
186
|
+
|
|
187
|
+
const lockedCartItems: typeof cartItems = [];
|
|
188
|
+
for (const item of cartItems) {
|
|
189
|
+
const lockedItem = await this.scope.model.cartItem.getByIdForUpdate(item.id);
|
|
190
|
+
if (lockedItem && String(lockedItem.cartId) === String(cart.id))
|
|
191
|
+
lockedCartItems.push(lockedItem);
|
|
192
|
+
}
|
|
193
|
+
if (lockedCartItems.length === 0) this.app.throw(409, 'cart is empty');
|
|
194
|
+
|
|
195
|
+
const preparedLines: IPreparedOrderLine[] = [];
|
|
196
|
+
for (const [index, item] of lockedCartItems.entries()) {
|
|
197
|
+
preparedLines.push(
|
|
198
|
+
await this._prepareLine({ skuId: item.skuId, quantity: item.quantity }, index),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const address = await this.$scope.commerceMember.model.address.get({
|
|
202
|
+
id: command.addressId,
|
|
203
|
+
userId,
|
|
204
|
+
});
|
|
205
|
+
if (!address) this.app.throw(404, 'Address not found');
|
|
206
|
+
|
|
207
|
+
const { order } = await this._createOrder({
|
|
208
|
+
userId,
|
|
209
|
+
address,
|
|
210
|
+
correlationId: command.correlationId,
|
|
211
|
+
couponGrantId: command.couponGrantId,
|
|
212
|
+
preparedLines,
|
|
213
|
+
reason: 'checkout created',
|
|
214
|
+
});
|
|
215
|
+
const paymentAttempt = await this.$scope.commercePayment.service.paymentAttempt.create({
|
|
216
|
+
orderId: order.id,
|
|
217
|
+
userId,
|
|
218
|
+
currency: order.currency,
|
|
219
|
+
amountCents: order.payableTotalCents,
|
|
220
|
+
correlationId: `${command.correlationId}:payment`,
|
|
221
|
+
});
|
|
222
|
+
await this._appendAudit({
|
|
223
|
+
orderId: order.id,
|
|
224
|
+
operation: 'created',
|
|
225
|
+
toState: 'awaiting_payment',
|
|
226
|
+
correlationId: command.correlationId,
|
|
227
|
+
reason: 'checkout created',
|
|
228
|
+
});
|
|
229
|
+
for (const item of lockedCartItems) {
|
|
230
|
+
await this.scope.model.cartItem.deleteById(item.id);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
orderId: order.id,
|
|
234
|
+
paymentAttemptId: paymentAttempt.id,
|
|
235
|
+
state: 'awaiting_payment',
|
|
236
|
+
paymentAttemptState: 'created',
|
|
237
|
+
currency: order.currency,
|
|
238
|
+
payableTotalCents: order.payableTotalCents,
|
|
239
|
+
reservationExpiresAt: order.reservationExpiresAt,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
244
|
+
@Core.retryable(serializationRetryOptions)
|
|
245
|
+
async expireIfDue(orderId: TableIdentity, now = new Date()): Promise<boolean> {
|
|
246
|
+
const order = await this.scope.model.order.getByIdForUpdate(orderId);
|
|
247
|
+
if (!order || order.state !== 'awaiting_payment' || order.reservationExpiresAt > now)
|
|
248
|
+
return false;
|
|
249
|
+
|
|
250
|
+
if (order.couponSnapshot) {
|
|
251
|
+
await this.$scope.commercePromotion.service.coupon.release({
|
|
252
|
+
couponGrantId: order.couponSnapshot.couponGrantId,
|
|
253
|
+
orderId: order.id,
|
|
254
|
+
correlationId: `${order.correlationId}:expiry:coupon`,
|
|
255
|
+
reason: 'unpaid order expired',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const lines = await this.scope.model.orderLine.select({
|
|
259
|
+
where: { orderId: order.id },
|
|
260
|
+
orders: [['skuId', 'asc']],
|
|
261
|
+
});
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const reservation = await this.scope.model.stockReservation.get({ orderLineId: line.id });
|
|
264
|
+
if (reservation) {
|
|
265
|
+
await this.scope.service.stockBalance.release({
|
|
266
|
+
reservationId: reservation.id,
|
|
267
|
+
reason: 'unpaid order expired',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await this.$scope.commercePayment.service.paymentAttempt.cancel(order.id);
|
|
272
|
+
await this.scope.model.order.updateById(order.id, { state: 'expired' });
|
|
273
|
+
await this._appendAudit({
|
|
274
|
+
orderId: order.id,
|
|
275
|
+
operation: 'expired',
|
|
276
|
+
fromState: 'awaiting_payment',
|
|
277
|
+
toState: 'expired',
|
|
278
|
+
correlationId: `${order.correlationId}:expiry`,
|
|
279
|
+
reason: 'unpaid order expired',
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async viewSnapshot(id: TableIdentity): Promise<IOrderSnapshotCreateResult | undefined> {
|
|
285
|
+
const order = await this.scope.model.order.get({
|
|
286
|
+
id,
|
|
287
|
+
userId: this.bean.passport.currentUser!.id,
|
|
288
|
+
});
|
|
289
|
+
if (!order) return undefined;
|
|
290
|
+
return await this._viewSnapshot(order);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async _checkoutResult(order: EntityOrder): Promise<DtoCheckoutResult> {
|
|
294
|
+
const paymentAttempt = await this.$scope.commercePayment.model.paymentAttempt.get({
|
|
295
|
+
orderId: order.id,
|
|
296
|
+
});
|
|
297
|
+
if (!paymentAttempt || paymentAttempt.state !== 'created') {
|
|
298
|
+
this.app.throw(409, 'checkout payment attempt is no longer available');
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
orderId: order.id,
|
|
302
|
+
paymentAttemptId: paymentAttempt.id,
|
|
303
|
+
state: 'awaiting_payment',
|
|
304
|
+
paymentAttemptState: 'created',
|
|
305
|
+
currency: order.currency,
|
|
306
|
+
payableTotalCents: order.payableTotalCents,
|
|
307
|
+
reservationExpiresAt: order.reservationExpiresAt,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async _createOrder({
|
|
312
|
+
userId,
|
|
313
|
+
address,
|
|
314
|
+
correlationId,
|
|
315
|
+
couponGrantId,
|
|
316
|
+
preparedLines,
|
|
317
|
+
reason,
|
|
318
|
+
}: {
|
|
319
|
+
userId: TableIdentity;
|
|
320
|
+
address: {
|
|
321
|
+
id: TableIdentity;
|
|
322
|
+
recipientName: string;
|
|
323
|
+
phone: string;
|
|
324
|
+
countryCode: string;
|
|
325
|
+
region: string;
|
|
326
|
+
city: string;
|
|
327
|
+
postalCode: string;
|
|
328
|
+
addressLine1: string;
|
|
329
|
+
addressLine2?: string;
|
|
330
|
+
};
|
|
331
|
+
correlationId: string;
|
|
332
|
+
couponGrantId?: TableIdentity;
|
|
333
|
+
preparedLines: IPreparedOrderLine[];
|
|
334
|
+
reason: string;
|
|
335
|
+
}): Promise<IOrderSnapshotCreateResult> {
|
|
336
|
+
const eligibleSubtotalCents = preparedLines.reduce((total, line) => {
|
|
337
|
+
const nextTotal = total + line.eligibleSubtotalCents;
|
|
338
|
+
if (!Number.isSafeInteger(nextTotal) || nextTotal > maxOrderCents) {
|
|
339
|
+
this.app.throw(400, 'order total exceeds the supported range');
|
|
340
|
+
}
|
|
341
|
+
return nextTotal;
|
|
342
|
+
}, 0);
|
|
343
|
+
const order = await this.scope.model.order.insert({
|
|
344
|
+
userId,
|
|
345
|
+
addressId: address.id,
|
|
346
|
+
correlationId,
|
|
347
|
+
addressSnapshot: this._toAddressSnapshot(address),
|
|
348
|
+
state: 'awaiting_payment',
|
|
349
|
+
currency: 'USD',
|
|
350
|
+
eligibleSubtotalCents,
|
|
351
|
+
discountCents: 0,
|
|
352
|
+
payableTotalCents: eligibleSubtotalCents,
|
|
353
|
+
reservationExpiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
|
354
|
+
});
|
|
355
|
+
if (couponGrantId) {
|
|
356
|
+
const reservation = await this.$scope.commercePromotion.service.coupon.reserve({
|
|
357
|
+
couponGrantId,
|
|
358
|
+
userId,
|
|
359
|
+
orderId: order.id,
|
|
360
|
+
eligibleSubtotalCents,
|
|
361
|
+
currency: 'USD',
|
|
362
|
+
correlationId: `${correlationId}:coupon`,
|
|
363
|
+
reason,
|
|
364
|
+
});
|
|
365
|
+
const couponGrant = reservation.couponGrant;
|
|
366
|
+
const couponSnapshot: DtoOrderCouponSnapshot = {
|
|
367
|
+
couponGrantId: couponGrant.id,
|
|
368
|
+
couponTemplateId: couponGrant.templateId,
|
|
369
|
+
couponCode: couponGrant.couponCode,
|
|
370
|
+
templateName: couponGrant.templateNameSnapshot,
|
|
371
|
+
currency: couponGrant.currencySnapshot,
|
|
372
|
+
fixedDiscountCents: couponGrant.discountCentsSnapshot,
|
|
373
|
+
minSpendCents: couponGrant.minSpendCentsSnapshot,
|
|
374
|
+
appliedDiscountCents: reservation.discountCents,
|
|
375
|
+
};
|
|
376
|
+
await this.scope.model.order.updateById(order.id, {
|
|
377
|
+
couponSnapshot,
|
|
378
|
+
discountCents: reservation.discountCents,
|
|
379
|
+
payableTotalCents: eligibleSubtotalCents - reservation.discountCents,
|
|
380
|
+
});
|
|
381
|
+
order.couponSnapshot = couponSnapshot;
|
|
382
|
+
order.discountCents = reservation.discountCents;
|
|
383
|
+
order.payableTotalCents = eligibleSubtotalCents - reservation.discountCents;
|
|
384
|
+
}
|
|
385
|
+
const lines: EntityOrderLine[] = [];
|
|
386
|
+
for (const line of preparedLines) {
|
|
387
|
+
const orderLine = await this.scope.model.orderLine.insert({
|
|
388
|
+
orderId: order.id,
|
|
389
|
+
skuId: line.skuId,
|
|
390
|
+
productId: line.productId,
|
|
391
|
+
skuCodeSnapshot: line.skuCodeSnapshot,
|
|
392
|
+
titleSnapshot: line.titleSnapshot,
|
|
393
|
+
skuAttributesSnapshot: line.skuAttributesSnapshot,
|
|
394
|
+
unitPriceCents: line.unitPriceCents,
|
|
395
|
+
quantity: line.quantity,
|
|
396
|
+
eligibleSubtotalCents: line.eligibleSubtotalCents,
|
|
397
|
+
lineTotalCents: line.eligibleSubtotalCents,
|
|
398
|
+
});
|
|
399
|
+
await this.scope.service.stockBalance.reserve({
|
|
400
|
+
skuId: orderLine.skuId,
|
|
401
|
+
orderLineId: orderLine.id,
|
|
402
|
+
quantity: orderLine.quantity,
|
|
403
|
+
correlationId: `${correlationId}:line:${line.index}`,
|
|
404
|
+
reason,
|
|
405
|
+
});
|
|
406
|
+
lines.push(orderLine);
|
|
407
|
+
}
|
|
408
|
+
return { order, lines };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private async _appendAudit({
|
|
412
|
+
orderId,
|
|
413
|
+
operation,
|
|
414
|
+
fromState,
|
|
415
|
+
toState,
|
|
416
|
+
correlationId,
|
|
417
|
+
reason,
|
|
418
|
+
}: {
|
|
419
|
+
orderId: TableIdentity;
|
|
420
|
+
operation: 'created' | 'expired';
|
|
421
|
+
fromState?: 'awaiting_payment' | 'expired';
|
|
422
|
+
toState: 'awaiting_payment' | 'expired';
|
|
423
|
+
correlationId: string;
|
|
424
|
+
reason: string;
|
|
425
|
+
}) {
|
|
426
|
+
await this.scope.model.orderAudit.insert({
|
|
427
|
+
orderId,
|
|
428
|
+
operation,
|
|
429
|
+
fromState,
|
|
430
|
+
toState,
|
|
431
|
+
actorId: this.bean.passport.currentUser?.anonymous
|
|
432
|
+
? undefined
|
|
433
|
+
: this.bean.passport.currentUser?.id,
|
|
434
|
+
correlationId,
|
|
435
|
+
reason,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private async _viewSnapshot(order: EntityOrder): Promise<IOrderSnapshotCreateResult> {
|
|
440
|
+
const lines = await this.scope.model.orderLine.select({
|
|
441
|
+
where: { orderId: order.id },
|
|
442
|
+
orders: [['id', 'asc']],
|
|
443
|
+
});
|
|
444
|
+
return { order, lines };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private async _assertExistingSnapshotMatchesCommand(
|
|
448
|
+
order: EntityOrder,
|
|
449
|
+
command: IOrderSnapshotCreateCommand,
|
|
450
|
+
) {
|
|
451
|
+
const lines = await this.scope.model.orderLine.select({
|
|
452
|
+
where: { orderId: order.id },
|
|
453
|
+
orders: [['id', 'asc']],
|
|
454
|
+
});
|
|
455
|
+
if (
|
|
456
|
+
String(order.addressId) !== String(command.addressId) ||
|
|
457
|
+
String(order.couponSnapshot?.couponGrantId ?? '') !== String(command.couponGrantId ?? '') ||
|
|
458
|
+
lines.length !== command.lines.length ||
|
|
459
|
+
lines.some(
|
|
460
|
+
(line, index) =>
|
|
461
|
+
String(line.skuId) !== String(command.lines[index].skuId) ||
|
|
462
|
+
line.quantity !== command.lines[index].quantity,
|
|
463
|
+
)
|
|
464
|
+
) {
|
|
465
|
+
this.app.throw(409, 'order snapshot correlation conflicts with an existing order');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async _prepareLine(command: IOrderSnapshotLineCommand, index = 0) {
|
|
470
|
+
if (!Number.isInteger(command.quantity) || command.quantity <= 0) {
|
|
471
|
+
this.app.throw(400, 'order line quantity must be a positive integer');
|
|
472
|
+
}
|
|
473
|
+
const sku = await this.$scope.commerceCatalog.model.sku.getById(command.skuId, {
|
|
474
|
+
include: {
|
|
475
|
+
product: {
|
|
476
|
+
include: {
|
|
477
|
+
category: true,
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
if (!sku || sku.lifecycle !== 'active') {
|
|
483
|
+
this.app.throw(404, 'SKU not found');
|
|
484
|
+
}
|
|
485
|
+
if (!sku.product?.published || !sku.product.category?.published) {
|
|
486
|
+
this.app.throw(409, 'SKU is not sellable');
|
|
487
|
+
}
|
|
488
|
+
const eligibleSubtotalCents = sku.priceCents * command.quantity;
|
|
489
|
+
if (!Number.isSafeInteger(eligibleSubtotalCents) || eligibleSubtotalCents > maxOrderCents) {
|
|
490
|
+
this.app.throw(400, 'order line total exceeds the supported range');
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
index,
|
|
494
|
+
skuId: sku.id,
|
|
495
|
+
productId: sku.product.id,
|
|
496
|
+
skuCodeSnapshot: sku.code,
|
|
497
|
+
titleSnapshot: sku.product.title,
|
|
498
|
+
skuAttributesSnapshot: sku.attributes.map(
|
|
499
|
+
attribute =>
|
|
500
|
+
({
|
|
501
|
+
name: attribute.name,
|
|
502
|
+
value: attribute.value,
|
|
503
|
+
}) satisfies DtoOrderLineSkuAttributeSnapshot,
|
|
504
|
+
),
|
|
505
|
+
unitPriceCents: sku.priceCents,
|
|
506
|
+
quantity: command.quantity,
|
|
507
|
+
eligibleSubtotalCents,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private _toAddressSnapshot(address: {
|
|
512
|
+
recipientName: string;
|
|
513
|
+
phone: string;
|
|
514
|
+
countryCode: string;
|
|
515
|
+
region: string;
|
|
516
|
+
city: string;
|
|
517
|
+
postalCode: string;
|
|
518
|
+
addressLine1: string;
|
|
519
|
+
addressLine2?: string;
|
|
520
|
+
}): DtoOrderAddressSnapshot {
|
|
521
|
+
return {
|
|
522
|
+
recipientName: address.recipientName,
|
|
523
|
+
phone: address.phone,
|
|
524
|
+
countryCode: address.countryCode,
|
|
525
|
+
region: address.region,
|
|
526
|
+
city: address.city,
|
|
527
|
+
postalCode: address.postalCode,
|
|
528
|
+
addressLine1: address.addressLine1,
|
|
529
|
+
addressLine2: address.addressLine2,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private _assertCheckoutCommand(command: DtoCheckoutCreate) {
|
|
534
|
+
if (!command.correlationId.trim()) {
|
|
535
|
+
this.app.throw(400, 'checkout correlationId is required');
|
|
536
|
+
}
|
|
537
|
+
if (command.correlationId.length > 80) {
|
|
538
|
+
this.app.throw(400, 'checkout correlationId is too long');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private _assertCommand(command: IOrderSnapshotCreateCommand) {
|
|
543
|
+
if (!command.correlationId.trim()) {
|
|
544
|
+
this.app.throw(400, 'order snapshot correlationId is required');
|
|
545
|
+
}
|
|
546
|
+
if (command.correlationId.length > 70) {
|
|
547
|
+
this.app.throw(400, 'order snapshot correlationId is too long');
|
|
548
|
+
}
|
|
549
|
+
if (command.lines.length === 0) {
|
|
550
|
+
this.app.throw(400, 'order snapshot requires at least one line');
|
|
551
|
+
}
|
|
552
|
+
if (new Set(command.lines.map(line => String(line.skuId))).size !== command.lines.length) {
|
|
553
|
+
this.app.throw(400, 'order snapshot cannot contain duplicate SKUs');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { ModelStockBalance } from '../model/stockBalance.ts';
|
|
|
17
17
|
|
|
18
18
|
export interface IStockReservationCommand {
|
|
19
19
|
skuId: TableIdentity;
|
|
20
|
+
orderLineId?: TableIdentity;
|
|
20
21
|
quantity: number;
|
|
21
22
|
correlationId: string;
|
|
22
23
|
reason: string;
|
|
@@ -27,6 +28,16 @@ export interface IStockReservationTransition {
|
|
|
27
28
|
reason: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const serializationRetryOptions = {
|
|
32
|
+
retries: 1,
|
|
33
|
+
factor: 1,
|
|
34
|
+
minTimeout: 0,
|
|
35
|
+
maxTimeout: 0,
|
|
36
|
+
randomize: false,
|
|
37
|
+
errorCodes: ['40001'],
|
|
38
|
+
ownerOnly: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
30
41
|
@Service()
|
|
31
42
|
export class ServiceStockBalance extends BeanBase {
|
|
32
43
|
async select(params?: IQueryParams<ModelStockBalance>): Promise<DtoStockBalanceSelectRes> {
|
|
@@ -47,6 +58,11 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
47
58
|
let stockBalance = await this.scope.model.stockBalance.getForUpdate({
|
|
48
59
|
skuId: stockAdjust.skuId,
|
|
49
60
|
});
|
|
61
|
+
const priorBalance = stockBalance ?? {
|
|
62
|
+
onHand: 0,
|
|
63
|
+
reserved: 0,
|
|
64
|
+
available: 0,
|
|
65
|
+
};
|
|
50
66
|
if (!stockBalance) {
|
|
51
67
|
if (stockAdjust.delta < 0) {
|
|
52
68
|
throw new Error('stock adjustment would make balance negative');
|
|
@@ -79,6 +95,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
79
95
|
|
|
80
96
|
await this._appendAudit({
|
|
81
97
|
stockBalance,
|
|
98
|
+
priorBalance,
|
|
82
99
|
delta: stockAdjust.delta,
|
|
83
100
|
operation: 'adjust',
|
|
84
101
|
reason: stockAdjust.reason,
|
|
@@ -88,6 +105,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
108
|
+
@Core.retryable(serializationRetryOptions)
|
|
91
109
|
async reserve(command: IStockReservationCommand): Promise<EntityStockReservation> {
|
|
92
110
|
this._assertReservationCommand(command);
|
|
93
111
|
const sku = await this.$scope.commerceCatalog.model.sku.getById(command.skuId, {
|
|
@@ -116,6 +134,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
116
134
|
if (existingReservation) {
|
|
117
135
|
if (
|
|
118
136
|
String(existingReservation.skuId) !== String(command.skuId) ||
|
|
137
|
+
String(existingReservation.orderLineId ?? '') !== String(command.orderLineId ?? '') ||
|
|
119
138
|
existingReservation.quantity !== command.quantity
|
|
120
139
|
) {
|
|
121
140
|
this.app.throw(409, 'stock reservation correlation conflicts with an existing reservation');
|
|
@@ -129,16 +148,19 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
129
148
|
const reservation = await this.scope.model.stockReservation.insert({
|
|
130
149
|
stockBalanceId: stockBalance.id,
|
|
131
150
|
skuId: command.skuId,
|
|
151
|
+
orderLineId: command.orderLineId,
|
|
132
152
|
quantity: command.quantity,
|
|
133
153
|
state: 'reserved',
|
|
134
154
|
correlationId: command.correlationId,
|
|
135
155
|
});
|
|
156
|
+
const priorBalance = stockBalance;
|
|
136
157
|
const updatedBalance = await this._updateBalance(stockBalance, {
|
|
137
158
|
onHand: stockBalance.onHand,
|
|
138
159
|
reserved: stockBalance.reserved + command.quantity,
|
|
139
160
|
});
|
|
140
161
|
await this._appendAudit({
|
|
141
162
|
stockBalance: updatedBalance,
|
|
163
|
+
priorBalance,
|
|
142
164
|
stockReservationId: reservation.id,
|
|
143
165
|
delta: -command.quantity,
|
|
144
166
|
operation: 'reserve',
|
|
@@ -218,6 +240,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
218
240
|
if (!stockBalance) {
|
|
219
241
|
this.app.throw(404, 'stock balance not found');
|
|
220
242
|
}
|
|
243
|
+
const priorBalance = stockBalance;
|
|
221
244
|
const updatedBalance = await this._updateBalance(
|
|
222
245
|
stockBalance,
|
|
223
246
|
calculateBalance(stockBalance, reservation),
|
|
@@ -228,6 +251,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
228
251
|
targetState === 'consumed' ? 'consume' : targetState === 'released' ? 'release' : 'restore';
|
|
229
252
|
await this._appendAudit({
|
|
230
253
|
stockBalance: updatedBalance,
|
|
254
|
+
priorBalance,
|
|
231
255
|
stockReservationId: reservation.id,
|
|
232
256
|
delta:
|
|
233
257
|
operation === 'consume'
|
|
@@ -264,6 +288,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
264
288
|
|
|
265
289
|
private async _appendAudit({
|
|
266
290
|
stockBalance,
|
|
291
|
+
priorBalance,
|
|
267
292
|
stockReservationId,
|
|
268
293
|
delta,
|
|
269
294
|
operation,
|
|
@@ -271,6 +296,7 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
271
296
|
correlationId,
|
|
272
297
|
}: {
|
|
273
298
|
stockBalance: EntityStockBalance;
|
|
299
|
+
priorBalance: Pick<EntityStockBalance, 'onHand' | 'reserved' | 'available'>;
|
|
274
300
|
stockReservationId?: TableIdentity;
|
|
275
301
|
delta: number;
|
|
276
302
|
operation: 'adjust' | 'reserve' | 'consume' | 'release' | 'restore';
|
|
@@ -281,10 +307,16 @@ export class ServiceStockBalance extends BeanBase {
|
|
|
281
307
|
stockBalanceId: stockBalance.id,
|
|
282
308
|
skuId: stockBalance.skuId,
|
|
283
309
|
stockReservationId,
|
|
310
|
+
actorId: this.bean.passport.currentUser?.anonymous
|
|
311
|
+
? undefined
|
|
312
|
+
: this.bean.passport.currentUser?.id,
|
|
284
313
|
operation,
|
|
285
314
|
delta,
|
|
286
315
|
reason,
|
|
287
316
|
correlationId,
|
|
317
|
+
priorOnHand: priorBalance.onHand,
|
|
318
|
+
priorReserved: priorBalance.reserved,
|
|
319
|
+
priorAvailable: priorBalance.available,
|
|
288
320
|
onHand: stockBalance.onHand,
|
|
289
321
|
reserved: stockBalance.reserved,
|
|
290
322
|
available: stockBalance.available,
|