feat: improve quick bookkeeping and asset inclusion

Add commemorative assets, session undo, paired repayment, month/day markers and filters. Compact account cards, move transfer entry, persist backup metadata, and update Chinese documentation.
This commit is contained in:
陈煜 committed 2026-10-04 16:01:24 +08:00
1 parent ad6466cb6b
commit 096f1577c2
22 files changed
+1465 -73

No files matched your search

+11
View File
@@ -1,3 +1,4 @@
import { repaymentMonth, date } from './validation';
import { BACKUP_ZIP_VERSION } from './backup-format';
import { Injectable } from '@nestjs/common';
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
@@ -63,6 +64,8 @@ const record = positionMeta
notes: z.string().max(2000),
archived: z.boolean(),
hidden: z.boolean(),
lastRepaymentMonth: repaymentMonth.optional(),
lastBookedDate: date.nullable().optional(),
metalType: z.enum(['gold', 'silver']).nullable(),
metalGrams: amount.nullable(),
metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(),
@@ -172,6 +175,10 @@ export function validateBackup(raw: unknown) {
throw new BadRequestException('图标关联无效');
const revisionIds = new Set<string>();
for (const p of b.positions) {
if (p.lastBookedDate && p.kind !== 'account')
throw new BadRequestException('仅账户支持当天记账标记');
if (p.lastRepaymentMonth && (p.kind !== 'account' || p.side !== 'liability'))
throw new BadRequestException('还款月份仅适用于欠款账户');
if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) {
if (p.kind !== 'asset' || p.category !== 'gold')
throw new BadRequestException('贵金属估价关联无效');
@@ -393,6 +400,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
archived: p.archived,
hidden: p.hidden,
included: p.included,
lastRepaymentMonth: p.lastRepaymentMonth,
lastBookedDate: p.lastBookedDate,
metalType: p.metalType,
metalGrams: p.metalGrams?.toString() ?? null,
metalCostPerGram: p.metalCostPerGram?.toString() ?? null,
@@ -700,6 +709,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
archived: p.archived,
hidden: p.hidden,
included: p.included,
lastRepaymentMonth: p.lastRepaymentMonth ?? null,
lastBookedDate: p.lastBookedDate ?? null,
metalType: p.metalType,
metalGrams: p.metalGrams,
metalCostPerGram: p.metalCostPerGram,
+6
View File
@@ -7,6 +7,9 @@ import {
credentials,
credentialChange,
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
transferInput,
@@ -35,6 +38,9 @@ export function setupOpenApi(app: INestApplication) {
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput,
'PATCH /api/positions/{id}/booked-mark': bookedMarkInput,
'PATCH /api/positions/{id}/inclusion': inclusionInput,
'POST /api/positions/{id}/revisions': revisionInput,
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
'PUT /api/positions/{id}/links': z
+86
View File
@@ -18,6 +18,9 @@ import { Database } from './database';
import { UserRequest } from './auth';
import {
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
today,
@@ -158,6 +161,68 @@ export class PortfolioBusinessService {
});
return { ok: true };
}
async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') {
const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (operation === 'booked' && (p.kind !== 'account' || p.archived))
throw new BadRequestException('仅启用账户支持当天记账标记');
if (operation === 'included' && p.kind !== 'asset')
throw new BadRequestException('仅独立资产支持此快速统计开关');
if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt)
throw new ConflictException('项目已变化,请刷新后重试');
if (
('expectedDate' in v && p.lastBookedDate !== v.expectedDate) ||
('expectedIncluded' in v && p.included !== v.expectedIncluded)
)
throw new ConflictException('标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
});
return {
lastBookedDate: updated.lastBookedDate,
included: updated.included,
updatedAt: updated.updatedAt,
};
});
}
async repaymentMark(r: UserRequest, id: string, raw: unknown) {
const input = repaymentMarkInput.parse(raw);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('账户不存在');
if (p.kind !== 'account' || p.side !== 'liability')
throw new BadRequestException('仅欠款账户支持还款月份标记');
if (p.archived) throw new ConflictException('请先恢复归档账户');
if (
p.lastRepaymentMonth !== input.expectedMonth ||
(input.expectedUpdatedAt && p.updatedAt.toISOString() !== input.expectedUpdatedAt)
)
throw new ConflictException('还款标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: { lastRepaymentMonth: input.month },
});
return {
lastRepaymentMonth: input.month,
previousMonth: p.lastRepaymentMonth,
updatedAt: updated.updatedAt.toISOString(),
};
});
}
async deletion(r: UserRequest, id: string) {
const p = await this.own(r.userId, id, r.revealed);
const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([
@@ -408,6 +473,27 @@ export class PortfolioController {
) {
return this.service.edit(r, id, b);
}
@Patch('positions/:id/booked-mark') async bookedMark(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'booked');
}
@Patch('positions/:id/inclusion') async inclusion(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'included');
}
@Patch('positions/:id/repayment-mark') async repaymentMark(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.repaymentMark(r, id, b);
}
@Get('positions/:id/deletion') async deletion(@Req() r: UserRequest, @Param('id') id: string) {
return this.service.deletion(r, id);
}
+28
View File
@@ -217,3 +217,31 @@ export const settingsInput = z
})
.strict()
.refine((v) => Object.keys(v).length > 0);
export const repaymentMonth = z
.string()
.regex(/^(19|[2-9]\d)\d{2}-(0[1-9]|1[0-2])$/)
.refine((v) => v <= today().slice(0, 7), '不能标记未来还款月份')
.nullable();
export const repaymentMarkInput = z
.object({
month: repaymentMonth,
expectedMonth: repaymentMonth,
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();
export const bookedMarkInput = z
.object({
date: date.nullable(),
expectedDate: date.nullable(),
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();
export const inclusionInput = z
.object({
included: z.boolean(),
expectedIncluded: z.boolean(),
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();