fix(history): preserve multiple balance updates on the same day
This commit is contained in:
1 parent
99174a3da5
commit
2eb2f4b56e
8 files changed
+106
-14
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`;
|
||||||
@@ -45,6 +45,7 @@ model Position {
|
|||||||
}
|
}
|
||||||
model Revision {
|
model Revision {
|
||||||
id String @id @default(uuid()) @db.Char(36)
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
sequence Int @unique @default(autoincrement())
|
||||||
positionId String @db.Char(36)
|
positionId String @db.Char(36)
|
||||||
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
|
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
|
||||||
amount Decimal @db.Decimal(24,8)
|
amount Decimal @db.Decimal(24,8)
|
||||||
@@ -53,7 +54,7 @@ model Revision {
|
|||||||
reason String @db.VarChar(20)
|
reason String @db.VarChar(20)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@unique([positionId,effectiveDate])
|
@@index([positionId,effectiveDate])
|
||||||
}
|
}
|
||||||
model PositionLink {
|
model PositionLink {
|
||||||
id String @id @default(uuid()) @db.Char(36)
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
|||||||
+14
-4
@@ -35,6 +35,7 @@ const record = positionMeta
|
|||||||
.array(
|
.array(
|
||||||
revisionInput.extend({
|
revisionInput.extend({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
|
sequence: z.number().int().positive().max(2147483647).optional(),
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
}),
|
}),
|
||||||
@@ -79,10 +80,11 @@ export function validateBackup(raw: unknown) {
|
|||||||
amount: '0',
|
amount: '0',
|
||||||
date: p.revisions[0].date,
|
date: p.revisions[0].date,
|
||||||
});
|
});
|
||||||
const dates = new Set<string>();
|
const sequences = new Set<number>();
|
||||||
for (const r of p.revisions) {
|
for (const r of p.revisions) {
|
||||||
if (dates.has(r.date) || revisionIds.has(r.id)) throw new BadRequestException('重复历史记录');
|
if (revisionIds.has(r.id) || (r.sequence !== undefined && sequences.has(r.sequence)))
|
||||||
dates.add(r.date);
|
throw new BadRequestException('重复历史记录');
|
||||||
|
if (r.sequence !== undefined) sequences.add(r.sequence);
|
||||||
revisionIds.add(r.id);
|
revisionIds.add(r.id);
|
||||||
}
|
}
|
||||||
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
||||||
@@ -136,6 +138,7 @@ export class BackupController {
|
|||||||
updatedAt: p.updatedAt.toISOString(),
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
revisions: p.revisions.map((r) => ({
|
revisions: p.revisions.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
sequence: r.sequence,
|
||||||
amount: r.amount.toString(),
|
amount: r.amount.toString(),
|
||||||
date: day(r.effectiveDate),
|
date: day(r.effectiveDate),
|
||||||
notes: r.notes,
|
notes: r.notes,
|
||||||
@@ -250,7 +253,14 @@ export class BackupController {
|
|||||||
createdAt: new Date(p.createdAt),
|
createdAt: new Date(p.createdAt),
|
||||||
updatedAt: new Date(p.updatedAt),
|
updatedAt: new Date(p.updatedAt),
|
||||||
revisions: {
|
revisions: {
|
||||||
create: p.revisions.map((v) => ({
|
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,
|
amount: v.amount,
|
||||||
effectiveDate: new Date(v.date),
|
effectiveDate: new Date(v.date),
|
||||||
notes: v.notes,
|
notes: v.notes,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type Holding = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
revisions: {
|
revisions: {
|
||||||
id: string;
|
id: string;
|
||||||
|
sequence?: number;
|
||||||
|
createdAt?: Date;
|
||||||
amount: { toString(): string };
|
amount: { toString(): string };
|
||||||
effectiveDate: Date;
|
effectiveDate: Date;
|
||||||
notes: string;
|
notes: string;
|
||||||
@@ -22,14 +24,17 @@ export type Rate = {
|
|||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
export const day = (d: Date) => d.toISOString().slice(0, 10);
|
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) {
|
export function history(p: Holding) {
|
||||||
let before = new Decimal(0);
|
let before = new Decimal(0);
|
||||||
return [...p.revisions]
|
return [...p.revisions].sort(compareRevisions).map((r) => {
|
||||||
.sort((a, b) => +a.effectiveDate - +b.effectiveDate)
|
|
||||||
.map((r) => {
|
|
||||||
const after = new Decimal(r.amount.toString());
|
const after = new Decimal(r.amount.toString());
|
||||||
const row = {
|
const row = {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
sequence: r.sequence || 0,
|
||||||
|
createdAt: r.createdAt?.toISOString() || null,
|
||||||
positionId: p.id,
|
positionId: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
@@ -59,7 +64,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
|
|||||||
const items = positions.map((p) => {
|
const items = positions.map((p) => {
|
||||||
const rev = p.revisions
|
const rev = p.revisions
|
||||||
.filter((r) => day(r.effectiveDate) <= date)
|
.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'),
|
amount = new Decimal(rev?.amount.toString() || '0'),
|
||||||
fx = rateAt(rates, p.currency, base, date);
|
fx = rateAt(rates, p.currency, base, date);
|
||||||
if (!fx && !amount.isZero()) missing.add(p.currency);
|
if (!fx && !amount.isZero()) missing.add(p.currency);
|
||||||
@@ -130,7 +135,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
|
|||||||
trend,
|
trend,
|
||||||
recent: positions
|
recent: positions
|
||||||
.flatMap(history)
|
.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),
|
.slice(0, 20),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,10 @@ export class PortfolioController {
|
|||||||
private async own(userId: string, id: string) {
|
private async own(userId: string, id: string) {
|
||||||
const p = await this.db.position.findFirst({
|
const p = await this.db.position.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true },
|
include: {
|
||||||
|
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||||
|
outgoing: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
return p;
|
return p;
|
||||||
@@ -35,7 +38,10 @@ export class PortfolioController {
|
|||||||
@Get('positions') async list(@Req() r: UserRequest) {
|
@Get('positions') async list(@Req() r: UserRequest) {
|
||||||
const rows = await this.db.position.findMany({
|
const rows = await this.db.position.findMany({
|
||||||
where: { userId: r.userId },
|
where: { userId: r.userId },
|
||||||
include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true },
|
include: {
|
||||||
|
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||||
|
outgoing: true,
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return rows.map((p) => ({
|
return rows.map((p) => ({
|
||||||
@@ -92,6 +98,15 @@ export class PortfolioController {
|
|||||||
const p = await tx.position.findFirst({ where: { id, userId: r.userId } });
|
const p = await tx.position.findFirst({ where: { id, userId: r.userId } });
|
||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
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({
|
return tx.revision.create({
|
||||||
data: {
|
data: {
|
||||||
positionId: p.id,
|
positionId: p.id,
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ test('correction recalculates later delta', () => {
|
|||||||
assert.equal(history(a)[1].delta, '20');
|
assert.equal(history(a)[1].delta, '20');
|
||||||
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '110.10');
|
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', () => {
|
test('FX and actual changes separated', () => {
|
||||||
const a = p('asset', 'USD');
|
const a = p('asset', 'USD');
|
||||||
a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')];
|
a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')];
|
||||||
|
|||||||
@@ -232,6 +232,35 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
|||||||
);
|
);
|
||||||
o = (await call('/overview', 'GET', undefined, a.cookie)).data;
|
o = (await call('/overview', 'GET', undefined, a.cookie)).data;
|
||||||
assert.equal(o.net, '570.10');
|
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;
|
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
|
||||||
assert.equal(backup.positions.length, 4);
|
assert.equal(backup.positions.length, 4);
|
||||||
assert.equal(backup.links.length, 2);
|
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);
|
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(
|
assert.equal(
|
||||||
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
|
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
|
||||||
409,
|
409,
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ NestJS API、React/Vite 前端和 MySQL;同源 /api,开发环境由 Vite 代
|
|||||||
|
|
||||||
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。每个金额均为非负原币余额,负债按 side 减去。
|
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。每个金额均为非负原币余额,负债按 side 减去。
|
||||||
|
|
||||||
Revision 保存按业务日期生效的绝对金额,每对象每日一条记录;同日更正须明确调用更正接口。历史前值和变化额按业务日期重新计算,避免更正早期记录后余额与趋势不一致。未来日期不允许。初始余额也写入历史。金额 DECIMAL(24,8),汇率 DECIMAL(24,12),API 使用十进制字符串;计算使用 Decimal,前端仅图形坐标使用 Number。
|
Revision 保存按业务日期生效的绝对金额,每次金额更新新增记录,sequence 在数据库中单调分配以确定同日顺序;按业务日期和顺序读取,日趋势使用当日最后余额。错误记录须明确调用更正接口。历史前值和变化额按业务日期重新计算,避免更正早期记录后余额与趋势不一致。未来日期不允许。初始余额也写入历史。金额 DECIMAL(24,8),汇率 DECIMAL(24,12),API 使用十进制字符串;计算使用 Decimal,前端仅图形坐标使用 Number。
|
||||||
|
|
||||||
趋势按日期重放金额和当时可用汇率。汇率导致的变化和余额导致的变化分别归因;没有可用汇率时总额标记不完整,绝不默认为 1。归档项目仍参与统计,归档仅停止编辑,避免归档导致财富凭空消失。
|
趋势按日期重放金额和当时可用汇率。汇率导致的变化和余额导致的变化分别归因;没有可用汇率时总额标记不完整,绝不默认为 1。归档项目仍参与统计,归档仅停止编辑,避免归档导致财富凭空消失。
|
||||||
|
|
||||||
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入要求同源 Origin,登录限速;生产必须 HTTPS 并启用安全 Cookie。
|
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入要求同源 Origin,登录限速;生产必须 HTTPS 并启用安全 Cookie。
|
||||||
|
|
||||||
备份采用 version=1 JSON,包含项目、历史、关系、汇率、本位币,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;完全相同的重复导入拒绝;汇率冲突拒绝;已有本位币不自动更改。完整验证后事务写入,不修改已有项目。
|
备份采用 version=1 JSON,包含项目、历史、关系、币种、本位币、汇率和导入来源,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;项目 ID 和 importedFromId 识别重复,用户 + importedFromId 有唯一索引,项目修改后仍拒绝原备份重复导入;不同 ID 的同名项目允许共存。汇率冲突拒绝;已有本位币不自动更改,空空间恢复备份本位币。完整验证后以 Serializable 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。
|
||||||
|
|
||||||
页面:注册登录、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
|
页面:注册登录、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
|
||||||
|
|
||||||
数据库使用 Prisma 可追踪 SQL 迁移,部署仅 migrate deploy,禁止 db push/reset。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
|
数据库使用 Prisma 可追踪 SQL 迁移,部署仅 migrate deploy,禁止 db push/reset。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
|
||||||
|
|
||||||
|
代码边界:`auth.ts` 负责身份与 Cookie;`portfolio.ts` 负责持有项目、历史和关系;`calculation.ts` 是无数据库依赖的十进制计算;`rates.ts` 负责公共汇率获取、失败状态和手动设置;`backup.ts` 负责格式验证与原子恢复;`database.ts` 管理数据库生命周期;`validation.ts` 集中定义输入约束。前端 `api.ts` 定义服务访问及数据类型,`App.tsx` 组合各功能流程,共用金额编辑和历史展示,CSS 定义桌面侧栏及手机底栏布局。
|
||||||
|
|
||||||
|
时间:业务日期按香港时区的当日边界验证,数据库 DATE 保存业务日期;创建和更新时间使用 UTC 时间戳。汇率定时检查在 API 进程内执行;更新尝试状态和认证限速当前在内存中,重启后重新初始化。多实例部署时需改为共享限速和独立调度任务。
|
||||||
Reference in new issue
Block a user