fix(history): preserve multiple balance updates on the same day

This commit is contained in:
陈煜 committed 2026-10-01 16:38:49 +08:00
1 parent 99174a3da5
commit 2eb2f4b56e
8 files changed
+130 -38

No files matched your search

@@ -0,0 +1,4 @@
-- Preserve all amounts and dates; permit multiple independently ordered updates per day.
ALTER TABLE `Revision` ADD COLUMN `sequence` INTEGER NOT NULL AUTO_INCREMENT, ADD UNIQUE INDEX `Revision_sequence_key` (`sequence`);
CREATE INDEX `Revision_positionId_effectiveDate_idx` ON `Revision`(`positionId`, `effectiveDate`);
DROP INDEX `Revision_positionId_effectiveDate_key` ON `Revision`;
+2 -1
View File
@@ -45,6 +45,7 @@ model Position {
}
model Revision {
id String @id @default(uuid()) @db.Char(36)
sequence Int @unique @default(autoincrement())
positionId String @db.Char(36)
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
amount Decimal @db.Decimal(24,8)
@@ -53,7 +54,7 @@ model Revision {
reason String @db.VarChar(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([positionId,effectiveDate])
@@index([positionId,effectiveDate])
}
model PositionLink {
id String @id @default(uuid()) @db.Char(36)
+21 -11
View File
@@ -35,6 +35,7 @@ const record = positionMeta
.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
createdAt: timestamp,
updatedAt: timestamp,
}),
@@ -79,10 +80,11 @@ export function validateBackup(raw: unknown) {
amount: '0',
date: p.revisions[0].date,
});
const dates = new Set<string>();
const sequences = new Set<number>();
for (const r of p.revisions) {
if (dates.has(r.date) || revisionIds.has(r.id)) throw new BadRequestException('重复历史记录');
dates.add(r.date);
if (revisionIds.has(r.id) || (r.sequence !== undefined && sequences.has(r.sequence)))
throw new BadRequestException('重复历史记录');
if (r.sequence !== undefined) sequences.add(r.sequence);
revisionIds.add(r.id);
}
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
@@ -136,6 +138,7 @@ export class BackupController {
updatedAt: p.updatedAt.toISOString(),
revisions: p.revisions.map((r) => ({
id: r.id,
sequence: r.sequence,
amount: r.amount.toString(),
date: day(r.effectiveDate),
notes: r.notes,
@@ -250,14 +253,21 @@ export class BackupController {
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt),
revisions: {
create: p.revisions.map((v) => ({
amount: v.amount,
effectiveDate: new Date(v.date),
notes: v.notes,
reason: v.reason,
createdAt: new Date(v.createdAt),
updatedAt: new Date(v.updatedAt),
})),
create: [...p.revisions]
.sort(
(a, b) =>
a.date.localeCompare(b.date) ||
(a.sequence || 0) - (b.sequence || 0) ||
a.createdAt.localeCompare(b.createdAt),
)
.map((v) => ({
amount: v.amount,
effectiveDate: new Date(v.date),
notes: v.notes,
reason: v.reason,
createdAt: new Date(v.createdAt),
updatedAt: new Date(v.updatedAt),
})),
},
},
});
+27 -22
View File
@@ -8,6 +8,8 @@ export type Holding = {
currency: string;
revisions: {
id: string;
sequence?: number;
createdAt?: Date;
amount: { toString(): string };
effectiveDate: Date;
notes: string;
@@ -22,28 +24,31 @@ export type Rate = {
source: string;
};
export const day = (d: Date) => d.toISOString().slice(0, 10);
export function compareRevisions(a: Holding['revisions'][number], b: Holding['revisions'][number]) {
return +a.effectiveDate - +b.effectiveDate || (a.sequence || 0) - (b.sequence || 0);
}
export function history(p: Holding) {
let before = new Decimal(0);
return [...p.revisions]
.sort((a, b) => +a.effectiveDate - +b.effectiveDate)
.map((r) => {
const after = new Decimal(r.amount.toString());
const row = {
id: r.id,
positionId: p.id,
name: p.name,
kind: p.kind,
currency: p.currency,
date: day(r.effectiveDate),
before: before.toFixed(),
after: after.toFixed(),
delta: after.minus(before).toFixed(),
notes: r.notes,
reason: r.reason,
};
before = after;
return row;
});
return [...p.revisions].sort(compareRevisions).map((r) => {
const after = new Decimal(r.amount.toString());
const row = {
id: r.id,
sequence: r.sequence || 0,
createdAt: r.createdAt?.toISOString() || null,
positionId: p.id,
name: p.name,
kind: p.kind,
currency: p.currency,
date: day(r.effectiveDate),
before: before.toFixed(),
after: after.toFixed(),
delta: after.minus(before).toFixed(),
notes: r.notes,
reason: r.reason,
};
before = after;
return row;
});
}
export function rateAt(rates: Rate[], currency: string, base: string, date: string) {
if (currency === base) return { value: new Decimal(1), date, source: 'identity' };
@@ -59,7 +64,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
const items = positions.map((p) => {
const rev = p.revisions
.filter((r) => day(r.effectiveDate) <= date)
.sort((a, b) => +b.effectiveDate - +a.effectiveDate)[0],
.sort((a, b) => compareRevisions(b, a))[0],
amount = new Decimal(rev?.amount.toString() || '0'),
fx = rateAt(rates, p.currency, base, date);
if (!fx && !amount.isZero()) missing.add(p.currency);
@@ -130,7 +135,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
trend,
recent: positions
.flatMap(history)
.sort((a, b) => b.date.localeCompare(a.date))
.sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)
.slice(0, 20),
};
}
+17 -2
View File
@@ -27,7 +27,10 @@ export class PortfolioController {
private async own(userId: string, id: string) {
const p = await this.db.position.findFirst({
where: { id, userId },
include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
});
if (!p) throw new NotFoundException('项目不存在');
return p;
@@ -35,7 +38,10 @@ export class PortfolioController {
@Get('positions') async list(@Req() r: UserRequest) {
const rows = await this.db.position.findMany({
where: { userId: r.userId },
include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
orderBy: { createdAt: 'desc' },
});
return rows.map((p) => ({
@@ -92,6 +98,15 @@ export class PortfolioController {
const p = await tx.position.findFirst({ where: { id, userId: r.userId } });
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
if (v.reason === 'repayment') {
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
const prior = await tx.revision.findFirst({
where: { positionId: p.id, effectiveDate: { lte: new Date(v.date) } },
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
});
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
throw new BadRequestException('还款后的欠款不能高于该业务日期的原欠款');
}
return tx.revision.create({
data: {
positionId: p.id,
+17
View File
@@ -46,6 +46,23 @@ test('correction recalculates later delta', () => {
assert.equal(history(a)[1].delta, '20');
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '110.10');
});
test('multiple same-day balances preserve ordering and use last value in totals', () => {
const a = p();
a.revisions = [
{ ...rev('120.10', '2026-09-01'), sequence: 3 },
{ ...rev('100.10', '2026-09-01'), sequence: 1 },
{ ...rev('110.10', '2026-09-01'), sequence: 2 },
];
assert.deepEqual(
history(a).map((r) => r.after),
['100.1', '110.1', '120.1'],
);
assert.equal(history(a)[2].delta, '10');
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '120.10');
a.revisions[1].amount = '90.10';
assert.equal(history(a)[1].delta, '20');
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '120.10');
});
test('FX and actual changes separated', () => {
const a = p('asset', 'USD');
a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')];
+36
View File
@@ -232,6 +232,35 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
);
o = (await call('/overview', 'GET', undefined, a.cookie)).data;
assert.equal(o.net, '570.10');
assert.equal(
(
await call(
'/positions/' + bank + '/revisions',
'POST',
{ amount: '120.10', date: '2026-09-02' },
a.cookie,
)
).status,
201,
);
const sameDay = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history;
assert.equal(sameDay.length, 3);
assert.equal(sameDay[2].after, '120.1');
assert.equal(sameDay[2].delta, '10');
assert.ok(sameDay[2].sequence > sameDay[1].sequence);
assert.equal(
(
await call(
'/positions/' + bank + '/revisions',
'POST',
{ amount: '0', date: '2026-09-03', reason: 'repayment' },
a.cookie,
)
).status,
400,
);
o = (await call('/overview', 'GET', undefined, a.cookie)).data;
assert.equal(o.net, '580.10');
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
assert.equal(backup.positions.length, 4);
assert.equal(backup.links.length, 2);
@@ -270,6 +299,13 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
),
);
assert.equal(restored.find((p: { kind: string }) => p.kind === 'debt').outgoing.length, 2);
const restoredHistory = restored.find(
(p: { kind: string; side: string }) => p.kind === 'account' && p.side === 'asset',
).history;
assert.deepEqual(
restoredHistory.map((h: { after: string }) => h.after),
sameDay.map((h: { after: string }) => h.after),
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,