feat(settlement): v4.0.6 酒厂 T+3 每 3 天出一期并与订单完全比对
含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addShanghaiDays,
|
||||
isWineryIssueDay,
|
||||
listWineryIssueDays,
|
||||
previousShanghaiMonth,
|
||||
shanghaiLaggedIssueWindow,
|
||||
shanghaiMonthLastInstant,
|
||||
shanghaiMonthRange,
|
||||
shanghaiPeriodYmds,
|
||||
shanghaiT1DayWindow,
|
||||
shanghaiWineryPeriodWindow,
|
||||
shanghaiYmd,
|
||||
startOfShanghaiDay,
|
||||
wineryIssueDateFromCompletedAt,
|
||||
} from './shanghai-date';
|
||||
|
||||
describe('shanghaiYmd / startOfShanghaiDay', () => {
|
||||
@@ -46,7 +50,7 @@ describe('shanghaiT1DayWindow', () => {
|
||||
});
|
||||
|
||||
describe('shanghaiLaggedIssueWindow', () => {
|
||||
it('酒厂 T+3:出账日是今天,纳入 3 天前完成的订单', () => {
|
||||
it('旧日滞后:出账日是今天,纳入 3 天前完成的订单', () => {
|
||||
const { start, end, billDate } = shanghaiLaggedIssueWindow(
|
||||
new Date('2026-08-31T00:00:00.000Z'),
|
||||
3,
|
||||
@@ -57,6 +61,55 @@ describe('shanghaiLaggedIssueWindow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('酒厂 T+3 每 3 天一期', () => {
|
||||
const epoch = '2026-08-01';
|
||||
const period = 3;
|
||||
|
||||
it('出账日:8/4、8/7、8/28;非 8/5、8/29', () => {
|
||||
expect(isWineryIssueDay(new Date('2026-08-04T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||
expect(isWineryIssueDay(new Date('2026-08-07T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||
expect(isWineryIssueDay(new Date('2026-08-28T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||
expect(isWineryIssueDay(new Date('2026-08-01T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||
expect(isWineryIssueDay(new Date('2026-08-05T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||
expect(isWineryIssueDay(new Date('2026-08-29T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||
});
|
||||
|
||||
it('窗口:出账日 8/28 → 完成日 [8/25, 8/28)', () => {
|
||||
const { start, end, billDate } = shanghaiWineryPeriodWindow(
|
||||
new Date('2026-08-28T00:00:00+08:00'),
|
||||
period,
|
||||
);
|
||||
expect(shanghaiYmd(billDate)).toBe('2026-08-28');
|
||||
expect(shanghaiYmd(start)).toBe('2026-08-25');
|
||||
expect(shanghaiYmd(end)).toBe('2026-08-28');
|
||||
});
|
||||
|
||||
it('完成日归属出账日', () => {
|
||||
expect(
|
||||
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-01T04:00:00.000Z'), period, epoch)),
|
||||
).toBe('2026-08-04');
|
||||
expect(
|
||||
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-03T04:00:00.000Z'), period, epoch)),
|
||||
).toBe('2026-08-04');
|
||||
expect(
|
||||
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-04T04:00:00.000Z'), period, epoch)),
|
||||
).toBe('2026-08-07');
|
||||
expect(
|
||||
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-27T04:00:00.000Z'), period, epoch)),
|
||||
).toBe('2026-08-28');
|
||||
});
|
||||
|
||||
it('listWineryIssueDays 只返回周期日', () => {
|
||||
const days = listWineryIssueDays(
|
||||
new Date('2026-08-01T00:00:00+08:00'),
|
||||
new Date('2026-08-10T00:00:00+08:00'),
|
||||
period,
|
||||
epoch,
|
||||
).map(shanghaiYmd);
|
||||
expect(days).toEqual(['2026-08-04', '2026-08-07', '2026-08-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addShanghaiDays', () => {
|
||||
it('跨月跨年按北京日历加减', () => {
|
||||
expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe(
|
||||
@@ -78,23 +131,15 @@ describe('shanghaiMonthRange', () => {
|
||||
|
||||
it('UTC 月末 23:59:59 不会把账期止日滚成下月', () => {
|
||||
const periodStart = new Date('2026-08-01T00:00:00.000Z');
|
||||
expect(shanghaiPeriodYmds(periodStart)).toEqual({
|
||||
periodStart: '2026-08-01',
|
||||
periodEnd: '2026-08-31',
|
||||
});
|
||||
const ymds = shanghaiPeriodYmds(periodStart);
|
||||
expect(ymds.periodStart).toBe('2026-08-01');
|
||||
expect(ymds.periodEnd).toBe('2026-08-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previousShanghaiMonth', () => {
|
||||
it('北京 9 月 1 日 08:00(UTC 9/1 00:00)取 8 月', () => {
|
||||
expect(previousShanghaiMonth(new Date('2026-09-01T00:00:00.000Z'))).toEqual({
|
||||
year: 2026,
|
||||
month: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('北京 9 月 1 日 00:30(UTC 8/31 16:30)取 8 月,不因 UTC 仍是 8 月而错到 7 月', () => {
|
||||
expect(previousShanghaiMonth(new Date('2026-08-31T16:30:00.000Z'))).toEqual({
|
||||
it('北京 9 月 1 日取上月为 8 月', () => {
|
||||
expect(previousShanghaiMonth(new Date('2026-08-31T16:00:00.000Z'))).toEqual({
|
||||
year: 2026,
|
||||
month: 8,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ export function shanghaiT1DayWindow(anchor = new Date()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 酒厂 T+N:出账日 = 今天;纳入窗口为「今天 − lagDays」当天完成的订单
|
||||
* 酒厂日滞后窗口(旧:每天出账只收 lagDays 前那一天)。
|
||||
* 现网酒厂已改为「每 periodDays 天出一期」,见 shanghaiWineryPeriodWindow。
|
||||
*/
|
||||
export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) {
|
||||
const billDate = startOfShanghaiDay(anchor);
|
||||
@@ -42,6 +43,58 @@ export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number)
|
||||
return { start, end, billDate };
|
||||
}
|
||||
|
||||
/** 两个北京自然日之间相差天数(b − a;可为负) */
|
||||
export function shanghaiCalendarDaysBetween(a: Date, b: Date): number {
|
||||
const sa = startOfShanghaiDay(a).getTime();
|
||||
const sb = startOfShanghaiDay(b).getTime();
|
||||
return Math.round((sb - sa) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/** 是否酒厂出账日:相对 epoch,每 periodDays 天一次(首张 = epoch + periodDays) */
|
||||
export function isWineryIssueDay(anchor: Date, periodDays: number, epochYmd: string): boolean {
|
||||
const billDate = startOfShanghaiDay(anchor);
|
||||
const epoch = parseShanghaiYmd(epochYmd);
|
||||
const diff = shanghaiCalendarDaysBetween(epoch, billDate);
|
||||
return diff >= periodDays && diff % periodDays === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 酒厂 T+N 期账单窗口:出账日 = anchor 当天;纳入 [出账日 − periodDays, 出账日) 内完成的订单。
|
||||
* 调用方应先用 isWineryIssueDay 判断是否出账日。
|
||||
*/
|
||||
export function shanghaiWineryPeriodWindow(anchor = new Date(), periodDays: number) {
|
||||
const billDate = startOfShanghaiDay(anchor);
|
||||
const start = addShanghaiDays(billDate, -periodDays);
|
||||
return { start, end: billDate, billDate };
|
||||
}
|
||||
|
||||
/** 订单完成日 → 所属酒厂出账日(落入下一期出账日) */
|
||||
export function wineryIssueDateFromCompletedAt(
|
||||
completedAt: Date,
|
||||
periodDays: number,
|
||||
epochYmd: string,
|
||||
) {
|
||||
const c = startOfShanghaiDay(completedAt);
|
||||
const epoch = parseShanghaiYmd(epochYmd);
|
||||
const daysFromEpoch = shanghaiCalendarDaysBetween(epoch, c);
|
||||
if (daysFromEpoch < 0) {
|
||||
return addShanghaiDays(epoch, periodDays);
|
||||
}
|
||||
const k = Math.floor(daysFromEpoch / periodDays) + 1;
|
||||
return addShanghaiDays(epoch, k * periodDays);
|
||||
}
|
||||
|
||||
/** 枚举 [from, to] 内(含)的全部酒厂出账日 */
|
||||
export function listWineryIssueDays(from: Date, to: Date, periodDays: number, epochYmd: string): Date[] {
|
||||
const start = startOfShanghaiDay(from);
|
||||
const end = startOfShanghaiDay(to);
|
||||
const out: Date[] = [];
|
||||
for (let d = start; d.getTime() <= end.getTime(); d = addShanghaiDays(d, 1)) {
|
||||
if (isWineryIssueDay(d, periodDays, epochYmd)) out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function shanghaiMonthRange(year: number, month: number): { start: Date; endExclusive: Date } {
|
||||
const start = new Date(`${year}-${pad2(month)}-01T00:00:00+08:00`);
|
||||
const endMonth = month === 12 ? 1 : month + 1;
|
||||
|
||||
@@ -187,12 +187,16 @@ export interface StoreBillDto {
|
||||
export interface WineryBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
/** 出账日 YYYY-MM-DD(北京时间;T+3 只决定纳入哪天完成的订单,不是账单日) */
|
||||
/** 出账日 YYYY-MM-DD(北京时间;T+3 = 每 3 天一期,纳入上期 3 天完成订单) */
|
||||
billDate: string;
|
||||
orderCount: number;
|
||||
orderAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
/**
|
||||
* DB:UNPAID | PAID。
|
||||
* 业务展示:wineryAmount === 0 时视为「无需打款」(NO_PAYMENT_NEEDED),仍保留账单以便与订单完全比对。
|
||||
*/
|
||||
status: FinancePayStatus;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
@@ -210,11 +214,20 @@ export interface WineryBillItemDto {
|
||||
/** 门店结算默认比例(核销金额 × 比例) */
|
||||
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
||||
|
||||
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
||||
/** 酒厂账单结算比例(订单实付 × 比例),暂定 30%;含现场提货/同城/跨城 */
|
||||
export const WINERY_SETTLEMENT_RATE = 0.3;
|
||||
|
||||
/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入当天出账的账单;账单日=出账当天北京日历日) */
|
||||
export const WINERY_SETTLEMENT_LAG_DAYS = 3;
|
||||
/**
|
||||
* 酒厂 T+3:每 3 个自然日出具一张账单;出账日纳入上一期 3 天内完成的已付订单。
|
||||
* 例:出账日 8/4 → 完成日 [8/1, 8/4);下一张 8/7 → [8/4, 8/7)。
|
||||
*/
|
||||
export const WINERY_SETTLEMENT_PERIOD_DAYS = 3;
|
||||
|
||||
/** @deprecated 使用 WINERY_SETTLEMENT_PERIOD_DAYS;保留别名避免旧引用断裂 */
|
||||
export const WINERY_SETTLEMENT_LAG_DAYS = WINERY_SETTLEMENT_PERIOD_DAYS;
|
||||
|
||||
/** 酒厂账期对齐起点(北京日);首张出账日 = 起点 + PERIOD_DAYS */
|
||||
export const WINERY_BILL_EPOCH_YMD = '2026-08-01';
|
||||
|
||||
export type { LogisticsSettlementMethod } from './enums';
|
||||
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
|
||||
|
||||
Reference in New Issue
Block a user