This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@dukang/domain",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"lint": "echo ok"
},
"devDependencies": {
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
calcBenefitAmount,
calcRedeemSettleAmount,
validateMinPurchase,
validateRedeemAmount,
allocateBenefitCoupons,
calcBenefitSummary,
} from './index';
describe('calcBenefitAmount', () => {
it('uses benefitAmount when set', () => {
expect(calcBenefitAmount({ price: 599, benefitAmount: 200 })).toBe(200);
});
it('falls back to price', () => {
expect(calcBenefitAmount({ price: 599 })).toBe(599);
});
});
describe('validateMinPurchase', () => {
it('local requires 2 bottles', () => {
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false);
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true);
});
it('cross city requires 6 bottles', () => {
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
});
});
describe('validateRedeemAmount', () => {
it('rejects over balance or 500', () => {
expect(validateRedeemAmount(100, 50).ok).toBe(true);
expect(validateRedeemAmount(100, 501).ok).toBe(false);
expect(validateRedeemAmount(50, 60).ok).toBe(false);
expect(validateRedeemAmount(100, 0).ok).toBe(false);
});
});
describe('calcRedeemSettleAmount', () => {
it('applies settlement rate', () => {
expect(calcRedeemSettleAmount(100, 0.6)).toBe(60);
});
});
describe('allocateBenefitCoupons', () => {
const coupons = [
{ id: '1', balance: 300, createdAt: 1 },
{ id: '2', balance: 400, createdAt: 2 },
];
it('allocates FIFO across coupons', () => {
const result = allocateBenefitCoupons(coupons, 500);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.allocations).toEqual([
{ couponId: '1', amount: 300 },
{ couponId: '2', amount: 200 },
]);
}
});
it('rejects when total balance insufficient', () => {
expect(allocateBenefitCoupons(coupons, 800).ok).toBe(false);
});
});
describe('calcBenefitSummary', () => {
it('caps max redeem by total and limit', () => {
expect(calcBenefitSummary([300, 400])).toEqual({
totalBalance: 700,
maxRedeemAmount: 500,
activeCouponCount: 2,
});
expect(calcBenefitSummary([100])).toEqual({
totalBalance: 100,
maxRedeemAmount: 100,
activeCouponCount: 1,
});
});
});
+126
View File
@@ -0,0 +1,126 @@
export interface ProductPricing {
price: number;
benefitAmount?: number | null;
}
export function calcBenefitAmount(product: ProductPricing): number {
return product.benefitAmount ?? product.price;
}
export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY',
quantity: number,
localMinQty: number,
crossMinQty: number,
): { ok: boolean; message?: string } {
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (quantity < min) {
return {
ok: false,
message:
deliveryType === 'LOCAL'
? `同城配送至少购买 ${min}`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
};
}
return { ok: true };
}
export function validateRedeemAmount(
balance: number,
amount: number,
maxAmount = 500,
): { ok: boolean; message?: string } {
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
if (amount > maxAmount) return { ok: false, message: `单次核销不能超过 ¥${maxAmount}` };
return { ok: true };
}
export interface BenefitCouponBalance {
id: string;
balance: number;
createdAt: number;
}
/** FIFO 跨多张权益券分配核销金额(购酒按单发券,展示为总余额) */
export function allocateBenefitCoupons(
coupons: BenefitCouponBalance[],
amount: number,
maxAmount = 500,
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
const active = coupons
.filter((c) => c.balance > 0)
.sort((a, b) => a.createdAt - b.createdAt);
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
const check = validateRedeemAmount(totalBalance, amount, maxAmount);
if (!check.ok) return { ok: false, message: check.message! };
let remaining = amount;
const allocations: Array<{ couponId: string; amount: number }> = [];
for (const coupon of active) {
if (remaining <= 0) break;
const take = Math.min(coupon.balance, remaining);
allocations.push({ couponId: coupon.id, amount: take });
remaining = Math.round((remaining - take) * 100) / 100;
}
if (remaining > 0) {
return { ok: false, message: '权益余额不足' };
}
return { ok: true, allocations };
}
export function calcBenefitSummary(
balances: number[],
maxAmount = 500,
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
return {
totalBalance,
maxRedeemAmount: Math.min(maxAmount, totalBalance),
activeCouponCount: balances.length,
};
}
export function calcRedeemSettleAmount(amount: number, settlementRate: number): number {
return Math.round(amount * settlementRate * 100) / 100;
}
export function generateUserNo(): string {
const suffix = Math.floor(10000000 + Math.random() * 90000000);
return `DK${suffix}`;
}
export function generateOrderNo(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const rand = String(Math.floor(Math.random() * 100000)).padStart(5, '0');
return `DK${y}${m}${d}${rand}`;
}
export function generateCouponNo(): string {
return `BC${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
export function generateRedeemNo(): string {
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
export function orderTabToStatuses(tab: string): string[] | undefined {
switch (tab) {
case 'pending_pay':
return ['PENDING_PAY'];
case 'pending_ship':
return ['PENDING_SHIP', 'OUT_WAREHOUSE'];
case 'pending_receive':
return ['SHIPPING', 'PENDING_RECEIVE'];
case 'completed':
return ['COMPLETED'];
default:
return undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
},
});