feat: multi-module iteration
This commit is contained in:
@@ -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": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveOrderCityPartner,
|
||||
validatePartnerCityBinding,
|
||||
validatePartnerCommissionRates,
|
||||
} from './city-partner';
|
||||
|
||||
describe('resolveOrderCityPartner', () => {
|
||||
const bindings = [
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'CITY_WIDE' as const,
|
||||
districtCodes: null,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerAccountId: '20',
|
||||
scopeType: 'DISTRICT' as const,
|
||||
districtCodes: ['410105', '金水区'],
|
||||
orderCommissionRate: 0.01,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
];
|
||||
|
||||
it('prefers district partner when district matches adcode', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410105');
|
||||
expect(ref?.partnerAccountId).toBe('20');
|
||||
expect(ref?.scopeType).toBe('DISTRICT');
|
||||
});
|
||||
|
||||
it('falls back to city-wide partner', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410102');
|
||||
expect(ref?.partnerAccountId).toBe('10');
|
||||
expect(ref?.scopeType).toBe('CITY_WIDE');
|
||||
});
|
||||
|
||||
it('returns null when no bindings', () => {
|
||||
expect(resolveOrderCityPartner([], '410105')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCityBinding', () => {
|
||||
it('rejects duplicate partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects second city-wide partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '11', scopeType: 'CITY_WIDE' },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows overlapping district codes as labels', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'DISTRICT',
|
||||
districtCodes: ['410105'],
|
||||
companyName: '甲公司',
|
||||
},
|
||||
],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('allows district partner without district codes', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: [] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('allows valid district binding', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410106'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCommissionRates', () => {
|
||||
it('allows sum within default 5% cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.02, 0.03).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects sum above cap', () => {
|
||||
const result = validatePartnerCommissionRates(0.03, 0.03, 0.05);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain('5.00%');
|
||||
});
|
||||
|
||||
it('respects custom city cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.04, 0.04, 0.08).ok).toBe(true);
|
||||
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
export type CityPartnerScopeType = 'CITY_WIDE' | 'DISTRICT';
|
||||
export type CityPartnerStatus = 'ACTIVE' | 'PAUSED';
|
||||
|
||||
export interface PartnerCityBindingInput {
|
||||
id?: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
/** 仅用于重合报错文案 */
|
||||
companyName?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerCityResolveRef {
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
}
|
||||
|
||||
export interface PartnerCityValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function normalizeDistrictCodes(codes?: string[] | null): string[] {
|
||||
if (!codes?.length) return [];
|
||||
return [...new Set(codes.map((c) => String(c).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** 区县 adcode 或名称命中区域合伙;否则全城合伙 */
|
||||
export function resolveOrderCityPartner(
|
||||
bindings: Array<{
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
}>,
|
||||
receiverDistrict?: string | null,
|
||||
): PartnerCityResolveRef | null {
|
||||
const active = bindings.filter((b) => b.bindingStatus !== 'PAUSED');
|
||||
if (!active.length) return null;
|
||||
|
||||
const districtKey = receiverDistrict?.trim();
|
||||
if (districtKey) {
|
||||
const districtHit = active.find((b) => {
|
||||
if (b.scopeType !== 'DISTRICT') return false;
|
||||
const codes = normalizeDistrictCodes(b.districtCodes);
|
||||
return codes.some((code) => code === districtKey || districtKey.includes(code) || code.includes(districtKey));
|
||||
});
|
||||
if (districtHit) {
|
||||
return {
|
||||
id: districtHit.id,
|
||||
partnerAccountId: districtHit.partnerAccountId,
|
||||
scopeType: districtHit.scopeType,
|
||||
orderCommissionRate: districtHit.orderCommissionRate,
|
||||
redeemCommissionRate: districtHit.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cityWide = active.find((b) => b.scopeType === 'CITY_WIDE');
|
||||
if (cityWide) {
|
||||
return {
|
||||
id: cityWide.id,
|
||||
partnerAccountId: cityWide.partnerAccountId,
|
||||
scopeType: cityWide.scopeType,
|
||||
orderCommissionRate: cityWide.orderCommissionRate,
|
||||
redeemCommissionRate: cityWide.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validatePartnerCityBinding(
|
||||
existing: PartnerCityBindingInput[],
|
||||
input: PartnerCityBindingInput,
|
||||
excludeId?: string,
|
||||
): PartnerCityValidationResult {
|
||||
const others = existing.filter((b) => b.id !== excludeId);
|
||||
|
||||
if (others.some((b) => b.partnerAccountId === input.partnerAccountId)) {
|
||||
return { ok: false, message: '该合伙人已绑定此城市' };
|
||||
}
|
||||
|
||||
if (input.scopeType === 'CITY_WIDE') {
|
||||
if (others.some((b) => b.scopeType === 'CITY_WIDE')) {
|
||||
return { ok: false, message: '每城最多 1 名全城合伙人' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// 区域合伙人所选区县可为空(录入时可稍后补全)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @deprecated use validatePartnerCityBinding */
|
||||
export const validateCityPartnerBinding = validatePartnerCityBinding;
|
||||
|
||||
export const DEFAULT_MAX_PARTNER_COMMISSION_RATE = 0.05;
|
||||
|
||||
export function resolveMaxPartnerCommissionRate(maxRate?: number | null): number {
|
||||
if (maxRate == null || Number.isNaN(Number(maxRate))) {
|
||||
return DEFAULT_MAX_PARTNER_COMMISSION_RATE;
|
||||
}
|
||||
return Number(maxRate);
|
||||
}
|
||||
|
||||
/** 订单佣金 + 核销佣金不得超过城市配置上限(默认 5%) */
|
||||
export function validatePartnerCommissionRates(
|
||||
orderCommissionRate: number,
|
||||
redeemCommissionRate: number,
|
||||
maxSumRate = DEFAULT_MAX_PARTNER_COMMISSION_RATE,
|
||||
): PartnerCityValidationResult {
|
||||
const sum = orderCommissionRate + redeemCommissionRate;
|
||||
const max = resolveMaxPartnerCommissionRate(maxSumRate);
|
||||
if (sum > max + 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type DevPlanTaskStatus = 'TODO' | 'DEVELOPED' | 'RELEASED';
|
||||
export type DevPlanVersionStatus = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED';
|
||||
|
||||
export function computeDurationMinutes(
|
||||
devStartedAt: Date | null | undefined,
|
||||
devCompletedAt: Date | null | undefined,
|
||||
): number | null {
|
||||
if (!devStartedAt || !devCompletedAt) return null;
|
||||
const ms = devCompletedAt.getTime() - devStartedAt.getTime();
|
||||
if (ms < 0) return null;
|
||||
return Math.round(ms / 60000);
|
||||
}
|
||||
|
||||
export function versionStatusTimestamps(
|
||||
prev: DevPlanVersionStatus,
|
||||
next: DevPlanVersionStatus,
|
||||
current: {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
},
|
||||
): {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
durationMinutes?: number | null;
|
||||
} {
|
||||
const patch: {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
durationMinutes?: number | null;
|
||||
} = {};
|
||||
const now = new Date();
|
||||
|
||||
if (next === 'IN_PROGRESS' && !current.devStartedAt) {
|
||||
patch.devStartedAt = now;
|
||||
}
|
||||
if (next === 'TESTING' && !current.devCompletedAt) {
|
||||
patch.devCompletedAt = now;
|
||||
}
|
||||
if (next === 'RELEASED' && !current.releasedAt) {
|
||||
patch.releasedAt = now;
|
||||
}
|
||||
|
||||
const started = patch.devStartedAt ?? current.devStartedAt ?? null;
|
||||
const completed = patch.devCompletedAt ?? current.devCompletedAt ?? null;
|
||||
const duration = computeDurationMinutes(started, completed);
|
||||
if (duration != null) patch.durationMinutes = duration;
|
||||
|
||||
void prev;
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function taskCompletedAtOnStatus(
|
||||
prev: DevPlanTaskStatus,
|
||||
next: DevPlanTaskStatus,
|
||||
currentCompletedAt?: Date | null,
|
||||
): Date | null | undefined {
|
||||
if (next === 'RELEASED' && !currentCompletedAt) return new Date();
|
||||
void prev;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calcBenefitAmount,
|
||||
calcRedeemSettleAmount,
|
||||
calcLogisticsFeeByBottles,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
validateMinPurchase,
|
||||
validateBusinessHours,
|
||||
formatBusinessHours,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
calcBenefitSummary,
|
||||
orderTabToStatuses,
|
||||
classifyRedeemClientError,
|
||||
generateRedeemPendingNo,
|
||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
sumUnbilledPayoutAmount,
|
||||
validateStoreWithdraw,
|
||||
pickPayoutsForWithdrawAmount,
|
||||
} 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);
|
||||
});
|
||||
|
||||
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBusinessHours', () => {
|
||||
it('accepts one segment', () => {
|
||||
expect(validateBusinessHours([{ open: '09:00', close: '22:00' }]).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts two non-overlapping segments', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '17:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects overlapping second segment', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '13:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBusinessHours', () => {
|
||||
it('formats one or two segments', () => {
|
||||
expect(formatBusinessHours({ openTime: '9:00', closeTime: '22:00' })).toBe('9:00-22:00');
|
||||
expect(
|
||||
formatBusinessHours({
|
||||
openTime: '09:00',
|
||||
closeTime: '14:00',
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
}),
|
||||
).toBe('09:00-14:00,17:00-21:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRedeemAmount', () => {
|
||||
it('rejects over balance or non-positive amount', () => {
|
||||
expect(validateRedeemAmount(100, 50).ok).toBe(true);
|
||||
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
||||
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
||||
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows direct redeem up to total balance without a document cap', () => {
|
||||
expect(validateRedeemAmount(700, 650).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 701).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('caps document-based redeem by document amount', () => {
|
||||
expect(validateRedeemAmount(700, 300, 300).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcRedeemSettleAmount', () => {
|
||||
it('applies settlement rate', () => {
|
||||
expect(calcRedeemSettleAmount(100, 0.6)).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcLogisticsFeeByBottles', () => {
|
||||
const xfx = {
|
||||
baseBottles: 2,
|
||||
baseFee: 6,
|
||||
extraBottleFee: 2,
|
||||
boxBottles: 6,
|
||||
boxFee: 14,
|
||||
};
|
||||
|
||||
it('charges base for 1~2 bottles', () => {
|
||||
expect(calcLogisticsFeeByBottles(1, xfx)).toBe(6);
|
||||
expect(calcLogisticsFeeByBottles(2, xfx)).toBe(6);
|
||||
});
|
||||
|
||||
it('adds extra bottle fee', () => {
|
||||
expect(calcLogisticsFeeByBottles(3, xfx)).toBe(8);
|
||||
expect(calcLogisticsFeeByBottles(5, xfx)).toBe(12);
|
||||
});
|
||||
|
||||
it('uses box fee for full boxes', () => {
|
||||
expect(calcLogisticsFeeByBottles(6, xfx)).toBe(14);
|
||||
expect(calcLogisticsFeeByBottles(12, xfx)).toBe(28);
|
||||
});
|
||||
|
||||
it('combines boxes with remainder ladder', () => {
|
||||
expect(calcLogisticsFeeByBottles(7, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(8, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(9, xfx)).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldAutoCourierDispatch', () => {
|
||||
it('holds at 10 full boxes (60 bottles)', () => {
|
||||
expect(shouldHoldAutoCourierDispatch(59)).toBe(false);
|
||||
expect(shouldHoldAutoCourierDispatch(60)).toBe(true);
|
||||
expect(shouldHoldAutoCourierDispatch(66)).toBe(true);
|
||||
expect(calcOrderBoxCount(60)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects when a document cap is lower than requested amount', () => {
|
||||
expect(allocateBenefitCoupons(coupons, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderTabToStatuses', () => {
|
||||
it('maps V3.0 three user tabs', () => {
|
||||
expect(orderTabToStatuses('pending_pay')).toEqual(['PENDING_PAY']);
|
||||
expect(orderTabToStatuses('paid')).toEqual([
|
||||
'PENDING_SHIP',
|
||||
'OUT_WAREHOUSE',
|
||||
'SHIPPING',
|
||||
'PENDING_RECEIVE',
|
||||
]);
|
||||
expect(orderTabToStatuses('completed')).toEqual(['COMPLETED']);
|
||||
});
|
||||
|
||||
it('keeps legacy tab keys mapped to paid', () => {
|
||||
expect(orderTabToStatuses('pending_ship')).toEqual(orderTabToStatuses('paid'));
|
||||
expect(orderTabToStatuses('pending_receive')).toEqual(orderTabToStatuses('paid'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcBenefitSummary', () => {
|
||||
it('max redeem equals total balance', () => {
|
||||
expect(calcBenefitSummary([300, 400])).toEqual({
|
||||
totalBalance: 700,
|
||||
maxRedeemAmount: 700,
|
||||
activeCouponCount: 2,
|
||||
});
|
||||
expect(calcBenefitSummary([100])).toEqual({
|
||||
totalBalance: 100,
|
||||
maxRedeemAmount: 100,
|
||||
activeCouponCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRedeemClientError', () => {
|
||||
it('treats 5xx and network messages as NETWORK', () => {
|
||||
expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK');
|
||||
});
|
||||
|
||||
it('treats business messages as BUSINESS', () => {
|
||||
expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS');
|
||||
expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRedeemPendingNo', () => {
|
||||
it('uses RP prefix', () => {
|
||||
expect(generateRedeemPendingNo().startsWith('RP')).toBe(true);
|
||||
});
|
||||
|
||||
it('exports weaknet threshold of 5', () => {
|
||||
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumUnbilledPayoutAmount', () => {
|
||||
it('sums payout amounts', () => {
|
||||
expect(sumUnbilledPayoutAmount([{ payoutAmount: 60 }, { payoutAmount: 40.5 }])).toBe(100.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStoreWithdraw', () => {
|
||||
const base = {
|
||||
availableAmount: 1000,
|
||||
requestAmount: 200,
|
||||
todayApplied: 0,
|
||||
dailyLimit: 5000,
|
||||
hasPendingRequest: false,
|
||||
hasBankAccount: true,
|
||||
};
|
||||
|
||||
it('rejects when daily limit exceeded', () => {
|
||||
expect(
|
||||
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects pending request / missing bank / over available', () => {
|
||||
expect(validateStoreWithdraw({ ...base, hasPendingRequest: true }).ok).toBe(false);
|
||||
expect(validateStoreWithdraw({ ...base, hasBankAccount: false }).ok).toBe(false);
|
||||
expect(validateStoreWithdraw({ ...base, requestAmount: 1001 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid request', () => {
|
||||
expect(validateStoreWithdraw(base).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickPayoutsForWithdrawAmount', () => {
|
||||
it('picks FIFO exact match', () => {
|
||||
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }, { payoutAmount: 30 }];
|
||||
const r = pickPayoutsForWithdrawAmount(rows, 100);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.selected).toHaveLength(2);
|
||||
expect(r.amount).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-exact match', () => {
|
||||
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }];
|
||||
expect(pickPayoutsForWithdrawAmount(rows, 50).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
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' | 'ON_SITE_PICKUP',
|
||||
quantity: number,
|
||||
localMinQty: number,
|
||||
crossMinQty: number,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
const min = localMinQty > 0 ? localMinQty : 2;
|
||||
if (quantity < min) {
|
||||
return { ok: false, message: `现场提货至少购买 ${min} 瓶` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
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,
|
||||
documentAmount?: number | null,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
||||
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
||||
if (documentAmount != null && amount > documentAmount) {
|
||||
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface BenefitCouponBalance {
|
||||
id: string;
|
||||
balance: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** FIFO 跨多张权益券分配核销金额(购酒按单发券,展示为总余额) */
|
||||
export function allocateBenefitCoupons(
|
||||
coupons: BenefitCouponBalance[],
|
||||
amount: number,
|
||||
documentAmount?: number | null,
|
||||
): { 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, documentAmount);
|
||||
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[],
|
||||
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
|
||||
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
|
||||
return {
|
||||
totalBalance,
|
||||
maxRedeemAmount: totalBalance,
|
||||
activeCouponCount: balances.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function calcRedeemSettleAmount(amount: number, settlementRate: number): number {
|
||||
return Math.round(amount * settlementRate * 100) / 100;
|
||||
}
|
||||
|
||||
export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>): number {
|
||||
return Math.round(payouts.reduce((sum, p) => sum + Number(p.payoutAmount || 0), 0) * 100) / 100;
|
||||
}
|
||||
|
||||
export type ValidateStoreWithdrawInput = {
|
||||
availableAmount: number;
|
||||
requestAmount: number;
|
||||
todayApplied: number;
|
||||
dailyLimit: number;
|
||||
hasPendingRequest: boolean;
|
||||
hasBankAccount: boolean;
|
||||
};
|
||||
|
||||
/** 门店未出账提现护栏(FIN-002 单日上限 + 幂等/账户) */
|
||||
export function validateStoreWithdraw(
|
||||
input: ValidateStoreWithdrawInput,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (!input.hasBankAccount) {
|
||||
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
||||
}
|
||||
if (input.hasPendingRequest) {
|
||||
return { ok: false, message: '已有待审核提现申请,请等待处理完成' };
|
||||
}
|
||||
if (!(input.requestAmount > 0)) {
|
||||
return { ok: false, message: '提现金额必须大于 0' };
|
||||
}
|
||||
if (input.requestAmount > input.availableAmount + 1e-9) {
|
||||
return { ok: false, message: '提现金额不能超过可提未出账余额' };
|
||||
}
|
||||
const remaining = Math.round((input.dailyLimit - input.todayApplied) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
return { ok: false, message: `已达单店单日提现上限 ¥${input.dailyLimit.toFixed(2)}` };
|
||||
}
|
||||
if (input.requestAmount > remaining + 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `超过单日剩余额度 ¥${remaining.toFixed(2)}(上限 ¥${input.dailyLimit.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FIFO 选取未出账 payout,使合计尽量等于 targetAmount(不超过目标)。
|
||||
* 若无法精确凑齐,返回合计 ≤ target 的最长前缀子集。
|
||||
*/
|
||||
export function pickPayoutsForWithdrawAmount<T extends { payoutAmount: number }>(
|
||||
payoutsAsc: T[],
|
||||
targetAmount: number,
|
||||
): { ok: true; selected: T[]; amount: number } | { ok: false; message: string } {
|
||||
if (!(targetAmount > 0)) return { ok: false, message: '提现金额必须大于 0' };
|
||||
const selected: T[] = [];
|
||||
let sum = 0;
|
||||
for (const p of payoutsAsc) {
|
||||
const next = Math.round((sum + Number(p.payoutAmount)) * 100) / 100;
|
||||
if (next <= targetAmount + 1e-9) {
|
||||
selected.push(p);
|
||||
sum = next;
|
||||
if (Math.abs(sum - targetAmount) < 1e-9) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selected.length) {
|
||||
return { ok: false, message: '无可匹配的未出账结算明细,请调整提现金额' };
|
||||
}
|
||||
if (Math.abs(sum - targetAmount) > 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `无法按 ¥${targetAmount.toFixed(2)} 精确匹配明细,请按可提总额全额提现或调整金额`,
|
||||
};
|
||||
}
|
||||
return { ok: true, selected, amount: sum };
|
||||
}
|
||||
|
||||
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
export type BusinessHoursSegment = { open: string; close: string };
|
||||
|
||||
export function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** 校验 1~2 段营业时间,例如 09:00-22:00 或 09:00-14:00 + 17:00-21:00 */
|
||||
export function validateBusinessHours(
|
||||
segments: BusinessHoursSegment[],
|
||||
): { ok: boolean; message?: string } {
|
||||
const cleaned = segments
|
||||
.map((s) => ({ open: String(s.open || '').trim(), close: String(s.close || '').trim() }))
|
||||
.filter((s) => s.open || s.close);
|
||||
if (cleaned.length < 1) return { ok: false, message: '请设置营业时间' };
|
||||
if (cleaned.length > 2) return { ok: false, message: '营业时间最多支持 2 段' };
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const s = cleaned[i];
|
||||
if (!TIME_HM_RE.test(s.open) || !TIME_HM_RE.test(s.close)) {
|
||||
return { ok: false, message: `第 ${i + 1} 段时间格式须为 HH:MM` };
|
||||
}
|
||||
if (timeToMinutes(s.open) >= timeToMinutes(s.close)) {
|
||||
return { ok: false, message: `第 ${i + 1} 段结束时间须晚于开始时间` };
|
||||
}
|
||||
}
|
||||
if (cleaned.length === 2 && timeToMinutes(cleaned[0].close) >= timeToMinutes(cleaned[1].open)) {
|
||||
return { ok: false, message: '第二段开始时间须晚于第一段结束时间' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function formatBusinessHours(store: {
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) {
|
||||
parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
}
|
||||
if (store.openTime2 && store.closeTime2) {
|
||||
parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
}
|
||||
return parts.length ? parts.join(',') : '10:00-22:00';
|
||||
}
|
||||
|
||||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||||
export const BOTTLES_PER_BOX = 6;
|
||||
|
||||
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
|
||||
export const XFX_AUTO_DISPATCH_MAX_BOXES = 10;
|
||||
|
||||
/** 按箱规向上取整得到箱数 */
|
||||
export function calcOrderBoxCount(quantity: number, bottlesPerBox = BOTTLES_PER_BOX): number {
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
if (qty <= 0 || !(bottlesPerBox > 0)) return 0;
|
||||
return Math.ceil(qty / bottlesPerBox);
|
||||
}
|
||||
|
||||
/** 是否因大单拦截自动推承运商(默认 ≥10 箱 = 60 瓶) */
|
||||
export function shouldHoldAutoCourierDispatch(
|
||||
quantity: number,
|
||||
options?: { bottlesPerBox?: number; maxBoxes?: number },
|
||||
): boolean {
|
||||
const bottlesPerBox = options?.bottlesPerBox ?? BOTTLES_PER_BOX;
|
||||
const maxBoxes = options?.maxBoxes ?? XFX_AUTO_DISPATCH_MAX_BOXES;
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
return qty >= bottlesPerBox * maxBoxes;
|
||||
}
|
||||
|
||||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||||
export type LogisticsPricingRule = {
|
||||
baseBottles: number;
|
||||
baseFee: number;
|
||||
extraBottleFee: number;
|
||||
boxBottles?: number;
|
||||
boxFee?: number;
|
||||
};
|
||||
|
||||
function roundMoney(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function calcLogisticsBottleLadder(quantity: number, rule: LogisticsPricingRule): number {
|
||||
const qty = Math.floor(quantity);
|
||||
if (qty <= 0) return 0;
|
||||
if (qty <= rule.baseBottles) return rule.baseFee;
|
||||
return rule.baseFee + (qty - rule.baseBottles) * rule.extraBottleFee;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按承运商计价标准计算单票物流费。
|
||||
* 配置了箱规时:整箱按 boxFee,余瓶按「起送瓶数/加瓶」阶梯。
|
||||
*/
|
||||
export function calcLogisticsFeeByBottles(quantity: number, rule: LogisticsPricingRule): number {
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
if (qty <= 0) return 0;
|
||||
if (!(rule.baseBottles > 0) || !(rule.baseFee >= 0) || !(rule.extraBottleFee >= 0)) {
|
||||
throw new Error('计价标准不完整');
|
||||
}
|
||||
|
||||
const boxBottles = rule.boxBottles && rule.boxBottles > 0 ? rule.boxBottles : 0;
|
||||
const boxFee = rule.boxFee != null && rule.boxFee >= 0 ? rule.boxFee : null;
|
||||
if (boxBottles > 0 && boxFee != null) {
|
||||
const boxes = Math.floor(qty / boxBottles);
|
||||
const rem = qty % boxBottles;
|
||||
return roundMoney(boxes * boxFee + calcLogisticsBottleLadder(rem, rule));
|
||||
}
|
||||
return roundMoney(calcLogisticsBottleLadder(qty, rule));
|
||||
}
|
||||
|
||||
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 generateRedeemPendingNo(): string {
|
||||
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
/** 弱网核销:网络类失败达到该次数后触发兜底 */
|
||||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||
|
||||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||
|
||||
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
|
||||
export function classifyRedeemClientError(input: {
|
||||
status?: number | null;
|
||||
message?: string | null;
|
||||
}): RedeemErrorClass {
|
||||
const status = input.status;
|
||||
const message = (input.message ?? '').toLowerCase();
|
||||
if (status != null && status >= 500) return 'NETWORK';
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
if (
|
||||
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
return 'BUSINESS';
|
||||
}
|
||||
|
||||
export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
switch (tab) {
|
||||
case 'pending_pay':
|
||||
return ['PENDING_PAY'];
|
||||
case 'paid':
|
||||
case 'pending_ship':
|
||||
case 'pending_receive':
|
||||
return ['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE'];
|
||||
case 'completed':
|
||||
return ['COMPLETED'];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './city-partner';
|
||||
export * from './dev-plan';
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user