feat: streamline portfolio UI and support atomic record edits
This commit is contained in:
1 parent
932a864ed6
commit
9adf7fdbc5
26 files changed
+988
-112
No files matched your search
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||
.strict(),
|
||||
'POST /api/transfers': transferInput,
|
||||
'PUT /api/transfers/{id}': transferInput,
|
||||
'POST /api/schedules': scheduleInput,
|
||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||
'PATCH /api/settings': z
|
||||
@@ -53,7 +54,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
}),
|
||||
};
|
||||
for (const [path, entry] of Object.entries(document.paths)) {
|
||||
for (const method of ['get', 'post', 'patch', 'put'] as const) {
|
||||
for (const method of ['get', 'post', 'patch', 'put', 'delete'] as const) {
|
||||
const operation = entry[method];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
|
||||
+28
-17
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Req,
|
||||
Param,
|
||||
@@ -28,6 +29,7 @@ import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
import { RatesService } from './rates';
|
||||
import { captureReplay } from './replay';
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
constructor(
|
||||
@@ -204,23 +206,9 @@ export class PortfolioController {
|
||||
throw new BadRequestException('仅资产账户支持负余额');
|
||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
const transfer = await tx.revision.findFirst({
|
||||
where: {
|
||||
positionId: p.id,
|
||||
reason: { in: pairedReasons },
|
||||
OR: [
|
||||
{ effectiveDate: { gt: original.effectiveDate } },
|
||||
{ effectiveDate: original.effectiveDate, sequence: { gte: original.sequence } },
|
||||
{ effectiveDate: { gt: toBusinessDate(v.date) } },
|
||||
{ effectiveDate: toBusinessDate(v.date), sequence: { gte: original.sequence } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (transfer)
|
||||
throw new ConflictException(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
if (pairedReasons.includes(original.reason))
|
||||
throw new ConflictException('请在资金往来中修改完整配对记录');
|
||||
const replay = await captureReplay(tx, r.userId, [id]);
|
||||
await tx.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
@@ -230,6 +218,29 @@ export class PortfolioController {
|
||||
reason: 'correction',
|
||||
},
|
||||
});
|
||||
await replay();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
return this.db.serial(async (tx) => {
|
||||
const replay = await captureReplay(tx, r.userId, [id]);
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
|
||||
if (!row) throw new NotFoundException('历史记录不存在');
|
||||
if (pairedReasons.includes(row.reason))
|
||||
throw new ConflictException('请在资金往来中删除完整配对记录');
|
||||
await tx.revision.delete({ where: { id: revisionId } });
|
||||
await replay();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import Decimal from 'decimal.js';
|
||||
import { movementDeltas, type Movement } from './movement';
|
||||
import { pairedReasons } from './validation';
|
||||
|
||||
// Lock all affected positions in the same order as movement creation. Absolute
|
||||
// balance observations remain anchors; transfers and expenses retain their delta.
|
||||
export async function captureReplay(tx: Prisma.TransactionClient, userId: string, ids: string[]) {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${userId}
|
||||
AND id IN (${Prisma.join([...ids].sort())}) ORDER BY id FOR UPDATE`);
|
||||
const rows = await tx.revision.findMany({
|
||||
where: { positionId: { in: ids }, position: { userId } },
|
||||
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
|
||||
});
|
||||
const deltas = new Map<string, Decimal>();
|
||||
const balances = new Map<string, Decimal>();
|
||||
for (const row of rows) {
|
||||
if (row.reason === 'scheduled_expense')
|
||||
deltas.set(
|
||||
row.id,
|
||||
new Decimal(row.amount.toString()).minus(balances.get(row.positionId) || 0),
|
||||
);
|
||||
balances.set(row.positionId, new Decimal(row.amount.toString()));
|
||||
}
|
||||
return async () => {
|
||||
const transfers = await tx.transfer.findMany({
|
||||
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
|
||||
});
|
||||
for (const t of transfers) {
|
||||
const d = movementDeltas(
|
||||
t.operation as Movement,
|
||||
t.amount.toString(),
|
||||
t.received.toString(),
|
||||
t.fee.toString(),
|
||||
);
|
||||
deltas.set(t.sourceRevisionId, d.source);
|
||||
deltas.set(t.targetRevisionId, d.target);
|
||||
}
|
||||
const positions = await tx.position.findMany({ where: { userId, id: { in: ids } } });
|
||||
const current = await tx.revision.findMany({
|
||||
where: { positionId: { in: ids }, position: { userId } },
|
||||
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
|
||||
});
|
||||
balances.clear();
|
||||
for (const row of current) {
|
||||
const p = positions.find((v) => v.id === row.positionId)!;
|
||||
const delta =
|
||||
row.reason === 'scheduled_expense' || pairedReasons.includes(row.reason)
|
||||
? deltas.get(row.id)
|
||||
: undefined;
|
||||
if (pairedReasons.includes(row.reason) && !delta)
|
||||
throw new ConflictException('配对记录不完整,无法重算');
|
||||
if (delta && !balances.has(row.positionId))
|
||||
throw new ConflictException('资金操作之前必须保留一条余额记录');
|
||||
const amount = delta
|
||||
? balances.get(row.positionId)!.plus(delta)
|
||||
: new Decimal(row.amount.toString());
|
||||
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
throw new BadRequestException('修改后债务或资产金额不能为负数');
|
||||
if (amount.abs().gte('10000000000000000'))
|
||||
throw new BadRequestException('重算后的金额超出支持范围');
|
||||
balances.set(row.positionId, amount);
|
||||
if (!amount.eq(row.amount.toString()))
|
||||
await tx.revision.update({ where: { id: row.id }, data: { amount: amount.toFixed() } });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
NotFoundException,
|
||||
Body,
|
||||
Req,
|
||||
Query,
|
||||
@@ -15,6 +19,7 @@ import { UserRequest } from './auth';
|
||||
import { transferInput, toBusinessDate } from './validation';
|
||||
import { businessTime } from './calculation';
|
||||
import { movementDeltas } from './movement';
|
||||
import { captureReplay } from './replay';
|
||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
@@ -43,6 +48,61 @@ export class TransfersController {
|
||||
revealed: r.revealed,
|
||||
};
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.change(r, id, v);
|
||||
}
|
||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.change(r, id);
|
||||
}
|
||||
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
|
||||
return this.db.serial(async (tx) => {
|
||||
const row = await tx.transfer.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: r.userId,
|
||||
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
|
||||
},
|
||||
include: { source: true, target: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('资金往来记录不存在');
|
||||
if (row.source.archived || row.target.archived)
|
||||
throw new ConflictException('请先恢复归档项目');
|
||||
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
|
||||
if (v) {
|
||||
if (
|
||||
v.sourceId !== row.sourceId ||
|
||||
v.targetId !== row.targetId ||
|
||||
v.operation !== row.operation
|
||||
)
|
||||
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
|
||||
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const effectiveDate = toBusinessDate(v.date);
|
||||
await tx.transfer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
notes: v.notes,
|
||||
effectiveDate,
|
||||
},
|
||||
});
|
||||
await tx.revision.updateMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
data: { effectiveDate, notes: v.notes },
|
||||
});
|
||||
} else {
|
||||
await tx.transfer.delete({ where: { id } });
|
||||
await tx.revision.deleteMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
});
|
||||
}
|
||||
await replay();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||
|
||||
@@ -128,10 +128,18 @@ export const credentials = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history', 'calendar']);
|
||||
export const menuKey = z.enum([
|
||||
'overview',
|
||||
'account',
|
||||
'asset',
|
||||
'debt',
|
||||
'history',
|
||||
'calendar',
|
||||
'schedules',
|
||||
]);
|
||||
export const hiddenMenus = z
|
||||
.array(menuKey)
|
||||
.max(6)
|
||||
.max(7)
|
||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||
export const transferInput = z
|
||||
.object({
|
||||
|
||||
Reference in new issue
Block a user