feat: create precious metals by grams and show optional purchase profit

This commit is contained in:
陈煜 committed 2026-10-03 22:43:59 +08:00
1 parent 027a8c1b6a
commit 0491f515fe
22 files changed
+570 -105

No files matched your search

@@ -0,0 +1 @@
ALTER TABLE `Position` ADD COLUMN `metalCostPerGram` DECIMAL(24,12) NULL COMMENT '买入时每克实物成本,原币;空值表示不计算盈亏';
@@ -0,0 +1,10 @@
ALTER TABLE `_prisma_migrations`
COMMENT = 'Prisma 数据库迁移执行记录,由迁移引擎管理',
MODIFY `id` VARCHAR(36) NOT NULL COMMENT '迁移执行唯一标识',
MODIFY `checksum` VARCHAR(64) NOT NULL COMMENT '迁移文件 SHA-256 校验摘要',
MODIFY `finished_at` DATETIME(3) NULL COMMENT '迁移成功完成时间',
MODIFY `migration_name` VARCHAR(255) NOT NULL COMMENT '迁移目录名称',
MODIFY `logs` TEXT NULL COMMENT '迁移引擎错误和诊断信息',
MODIFY `rolled_back_at` DATETIME(3) NULL COMMENT '迁移标记回滚时间',
MODIFY `started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '迁移开始时间',
MODIFY `applied_steps_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已执行步骤数量';
+2
View File
@@ -224,6 +224,8 @@ model Position {
metalGrams Decimal? @db.Decimal(24,8)
/// 贵金属纯度比例,0 至 1
metalPurity Decimal @default(1) @db.Decimal(12,8)
/// 买入时每克实物成本,原币;空值表示不计算盈亏
metalCostPerGram Decimal? @db.Decimal(24,12)
/// 是否随每日价格更新自动记录估值
autoValuation Boolean @default(false)
/// 记录创建时间,UTC
+24 -11
View File
@@ -5,16 +5,29 @@ const { resolve } = require('node:path');
const db = new PrismaClient();
(async () => {
const tables = await db.$queryRaw`SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME`;
const columns = await db.$queryRaw`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME, ORDINAL_POSITION`;
WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME`;
const columns =
await db.$queryRaw`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME, ORDINAL_POSITION`;
if (tables.some((t) => !t.TABLE_COMMENT) || columns.some((c) => !c.COLUMN_COMMENT))
throw Error('Application tables or columns are missing comments.');
writeFileSync(resolve(__dirname, '../../../docs/database-comments.json'), JSON.stringify({
tableCount: tables.length, columnCount: columns.length, tables, columns,
}, null, 2) + '\n');
throw Error('Database tables or columns are missing comments.');
writeFileSync(
resolve(__dirname, '../../../docs/database-comments.json'),
JSON.stringify(
{
tableCount: tables.length,
columnCount: columns.length,
tables,
columns,
},
null,
2,
) + '\n',
);
console.log(`Database comments verified: ${tables.length} tables, ${columns.length} columns.`);
})().catch(() => {
console.error('Comment verification failed. Check database access and table/column comments.');
process.exitCode = 1;
}).finally(() => db.$disconnect());
})()
.catch(() => {
console.error('Comment verification failed. Check database access and table/column comments.');
process.exitCode = 1;
})
.finally(() => db.$disconnect());
+5 -1
View File
@@ -58,6 +58,7 @@ const record = positionMeta
.extend({
metalType: z.enum(['gold', 'silver']).nullable().optional(),
metalGrams: amount.nullable().optional(),
metalCostPerGram: metalConfig.shape.metalCostPerGram,
metalPurity: metalConfig.shape.metalPurity.optional(),
autoValuation: z.boolean().optional(),
kind: z.enum(['account', 'asset', 'debt']),
@@ -164,12 +165,13 @@ export function validateBackup(raw: unknown) {
throw new BadRequestException('图标关联无效');
const revisionIds = new Set<string>();
for (const p of b.positions) {
if (p.metalType || p.metalGrams || p.autoValuation) {
if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) {
if (p.kind !== 'asset' || p.category !== 'gold')
throw new BadRequestException('贵金属估价关联无效');
metalConfig.parse({
metalType: p.metalType,
metalGrams: p.metalGrams,
metalCostPerGram: p.metalCostPerGram,
metalPurity: p.metalPurity || '1',
autoValuation: p.autoValuation || false,
});
@@ -396,6 +398,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
included: p.included,
metalType: p.metalType,
metalGrams: p.metalGrams?.toString() ?? null,
metalCostPerGram: p.metalCostPerGram?.toString() ?? null,
metalPurity: p.metalPurity.toString(),
autoValuation: p.autoValuation,
createdAt: p.createdAt.toISOString(),
@@ -702,6 +705,7 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
included: p.included,
metalType: p.metalType,
metalGrams: p.metalGrams,
metalCostPerGram: p.metalCostPerGram,
metalPurity: p.metalPurity,
autoValuation: p.autoValuation,
createdAt: new Date(p.createdAt),
+9 -2
View File
@@ -7,6 +7,8 @@ export type Holding = {
side: string;
currency: string;
included?: boolean;
metalType?: string | null;
createdAt?: Date;
revisions: {
id: string;
sequence?: number;
@@ -66,6 +68,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
let assets = new Decimal(0),
liabilities = new Decimal(0);
const missing = new Set<string>();
const missingValuations: { id: string; name: string }[] = [];
const items = positions.map((p) => {
const rev = p.revisions
.filter((r) => businessDay(r.effectiveDate) <= date)
@@ -73,7 +76,9 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
amount = new Decimal(rev?.amount.toString() || '0'),
fx = rateAt(rates, p.currency, base, date);
if (p.included !== false && !fx && !amount.isZero()) missing.add(p.currency);
const converted = fx ? amount.mul(fx.value) : null;
const pending = !!p.metalType && !rev && (!p.createdAt || businessDay(p.createdAt) <= date);
if (pending && p.included !== false) missingValuations.push({ id: p.id, name: p.name });
const converted = fx && !pending ? amount.mul(fx.value) : null;
if (converted && p.included !== false) {
if (p.side === 'asset') assets = assets.plus(converted);
else if (p.kind === 'account' && converted.isNegative()) assets = assets.minus(converted);
@@ -86,6 +91,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
kind: p.kind,
side: p.side,
currency: p.currency,
valuationAvailable: !pending,
amount: amount.toFixed(),
converted: converted?.toFixed(2) ?? null,
rateDate: fx?.date ?? null,
@@ -97,7 +103,8 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
assets: assets.toFixed(2),
liabilities: liabilities.toFixed(2),
net: assets.minus(liabilities).toFixed(2),
complete: missing.size === 0,
complete: missing.size === 0 && missingValuations.length === 0,
missingValuations,
missing: [...missing],
items,
};
+7 -1
View File
@@ -6,7 +6,7 @@ import { TransfersBusinessService } from '../transfers';
import { SchedulesBusinessService, scheduleInput } from '../schedules';
import { CalendarBusinessService } from '../calendar';
import { SettingsBusinessService } from '../rates';
import { MetalsBusinessService, metalConfig, metalPriceInput } from '../metals';
import { MetalsBusinessService, metalConfig, metalHoldingInput, metalPriceInput } from '../metals';
import { IconsBusinessService } from '../icons';
import { BackupBusinessService } from '../backup';
import {
@@ -289,6 +289,12 @@ export class AgentCatalogue {
write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) =>
settings.refresh(r),
),
write(
'metal_holding_create',
'按克数创建金银资产,无需市场价格;买入每克成本可选,缺少报价时待估值。日期为 UTC+8 业务时间。',
metalHoldingInput,
(r, p) => metals.create(r, p),
),
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) =>
metals.refresh(r),
+80 -3
View File
@@ -18,7 +18,16 @@ import Decimal from 'decimal.js';
import { z } from 'zod';
import { Database } from './database';
import { UserRequest } from './auth';
import { amount, currency, date, rateValue, today, toBusinessDate } from './validation';
import {
amount,
currency,
date,
rateValue,
today,
toBusinessDate,
businessDate,
notes,
} from './validation';
import { businessTime, businessDay } from './calculation';
export const metalType = z.enum(['gold', 'silver']);
export const metalConfig = z
@@ -29,9 +38,43 @@ export const metalConfig = z
.string()
.regex(/^(0|1)(\.\d{1,8})?$/)
.refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1'),
metalCostPerGram: rateValue
.nullable()
.optional()
.describe('每克实物买入成本,原币十进制字符串;null 清除成本,省略保留'),
autoValuation: z.boolean(),
})
.strict();
export const metalHoldingInput = metalConfig
.extend({
name: z.string().trim().min(1).max(100),
currency,
notes,
hidden: z.boolean().default(false),
included: z.boolean().default(true),
date: businessDate,
})
.strict();
export function metalPerformance(
p: {
metalType?: string | null;
metalGrams?: { toString(): string } | null;
metalCostPerGram?: { toString(): string } | null;
},
balance: string,
hasRevision: boolean,
) {
const valuationAvailable = !p.metalType || hasRevision;
const cost =
p.metalGrams && p.metalCostPerGram
? new Decimal(p.metalGrams.toString()).mul(p.metalCostPerGram.toString()).toFixed(8)
: null;
return {
valuationAvailable,
metalCost: cost,
metalProfit: cost && valuationAvailable ? new Decimal(balance).minus(cost).toFixed(8) : null,
};
}
export const metalPriceInput = z.object({ metalType, currency, price: rateValue, date }).strict();
export function metalValue(grams: string, purity: string, price: string) {
const value = new Decimal(grams).mul(purity).mul(price);
@@ -90,6 +133,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
id: string,
revealed: boolean,
force = false,
when?: Date,
) {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id=${id} AND userId=${userId} FOR UPDATE`,
@@ -115,7 +159,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
userId,
metalType: p.metalType,
currency: p.currency,
date: { lte: new Date(today()) },
date: { lte: new Date(when ? businessDay(when) : today()) },
},
orderBy: { date: 'desc' },
});
@@ -131,7 +175,7 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
return { amount: value, changed: false };
if (!force && last && businessDay(last.effectiveDate) > businessDay(quote.date))
return { amount: value, changed: false };
const now = toBusinessDate(businessTime(new Date()));
const now = when || toBusinessDate(businessTime(new Date()));
if (last && last.effectiveDate > now) throw new BadRequestException('估价时间不能早于最新余额');
await tx.revision.create({
data: {
@@ -310,6 +354,36 @@ export class MetalsBusinessService {
return { message: '贵金属价格已保存' };
});
}
async create(r: UserRequest, body: unknown) {
const v = metalHoldingInput.parse(body);
const when = toBusinessDate(v.date);
const result = await this.db.serial(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
const { date: _, ...data } = v;
const p = await tx.position.create({
data: {
...data,
userId: r.userId,
kind: 'asset',
side: 'asset',
category: 'gold',
createdAt: when,
},
});
const quote = await tx.metalPrice.findFirst({
where: {
userId: r.userId,
metalType: v.metalType,
currency: v.currency,
date: { lte: new Date(businessDay(when)) },
},
});
if (quote) await this.metals.apply(tx, r.userId, p.id, true, true, when);
return { id: p.id, valuationAvailable: !!quote };
});
this.metals.invalidate(r.userId);
return result;
}
async configure(r: UserRequest, id: string, body: unknown) {
const v = metalConfig.parse(body);
const result = await this.db.serial(async (tx) => {
@@ -356,6 +430,9 @@ export class MetalsController {
@Get() async list(@Req() r: UserRequest) {
return this.service.list(r);
}
@Post('holdings') create(@Req() r: UserRequest, @Body() body: unknown) {
return this.service.create(r, body);
}
@Post('refresh') refresh(@Req() r: UserRequest) {
return this.service.refresh(r);
}
+14 -3
View File
@@ -29,7 +29,7 @@ import { currentPositions, currentRates, historyPage, trendData, trendInput } fr
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
import { MetalsService } from './metals';
import { MetalsService, metalPerformance } from './metals';
import { RatesService } from './rates';
import { captureReplay } from './replay';
import { changeMovement } from './transfers';
@@ -67,6 +67,7 @@ export class PortfolioBusinessService {
);
return rows.map(({ revisions, userId, ...p }) => ({
...p,
...metalPerformance(p, revisions[0]?.amount.toString() || '0', !!revisions.length),
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
rateDate: amounts.get(p.id)!.rateDate,
@@ -75,7 +76,11 @@ export class PortfolioBusinessService {
}
async detail(r: UserRequest, id: string) {
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
return {
...p,
...metalPerformance(p, revisions[0]?.amount.toString() || '0', !!revisions.length),
amount: revisions[0]?.amount.toString() || '0',
};
}
async history(r: UserRequest, query: unknown) {
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
@@ -141,7 +146,13 @@ export class PortfolioBusinessService {
data: {
...v,
...(p.kind === 'asset' && v.category !== 'gold'
? { metalType: null, metalGrams: null, metalPurity: '1', autoValuation: false }
? {
metalType: null,
metalGrams: null,
metalCostPerGram: null,
metalPurity: '1',
autoValuation: false,
}
: {}),
},
});
+98
View File
@@ -294,3 +294,101 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await db.$disconnect();
}
});
test('gram-only creation, optional exact purchase cost, pending totals and backward-compatible backups', async () => {
const a = await fixture(),
b = await fixture();
try {
const input = {
name: 'gram asset',
currency: 'CNY',
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
date: '2026-10-01T12:00',
metalCostPerGram: '12.8',
};
const created = await call('/metals/holdings', a.cookie, 'POST', input);
assert.equal(created.status, 201, JSON.stringify(created.data));
assert.equal(created.data.valuationAvailable, false);
const id = created.data.id;
let p = (await call('/positions/' + id, a.cookie)).data;
assert.equal(p.metalCost, '32.00000000');
assert.equal(p.metalProfit, null);
assert.equal(p.valuationAvailable, false);
assert.equal(await db.revision.count({ where: { positionId: id } }), 0);
const ov = (await call('/overview', a.cookie)).data;
assert.equal(ov.complete, false);
assert.deepEqual(ov.missingValuations, [{ id, name: input.name }]);
assert.equal((await call('/metals/' + id, b.cookie, 'PUT', input)).status, 400); // strict creation fields rejected
assert.equal(
(
await call('/metals/' + id, b.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
})
).status,
404,
);
const price = await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '20',
date: '2026-10-01',
});
assert.equal(price.status, 201, JSON.stringify(price.data));
p = (await call('/positions/' + id, a.cookie)).data;
assert.equal(p.amount, '40');
assert.equal(p.metalProfit, '8.00000000');
assert.equal(p.valuationAvailable, true);
const cfg = { metalType: 'gold', metalGrams: '2.5', metalPurity: '0.8', autoValuation: true };
assert.equal((await call('/metals/' + id, a.cookie, 'PUT', cfg)).status, 200);
assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8');
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
const backup = await (controller as any).data(a.id);
assert.equal(backup.positions[0].metalCostPerGram, '12.8');
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/positions?kind=asset', b.cookie)).data[0];
assert.equal(restored.metalCostPerGram, '12.8');
assert.equal(restored.metalProfit, '8.00000000');
const invalidCost = structuredClone(backup);
Object.assign(invalidCost.positions[0], {
metalType: null,
metalGrams: null,
autoValuation: false,
});
assert.throws(() =>
(require('../src/backup') as typeof import('../src/backup')).validateBackup(invalidCost),
);
const old = structuredClone(backup);
delete old.positions[0].metalCostPerGram;
assert.doesNotThrow(() =>
(require('../src/backup') as typeof import('../src/backup')).validateBackup(old),
);
assert.equal(
(await call('/metals/' + id, a.cookie, 'PUT', { ...cfg, metalCostPerGram: null })).status,
200,
);
assert.equal((await call('/positions/' + id, a.cookie)).data.metalProfit, null);
const next = await call('/metals/holdings', a.cookie, 'POST', {
...input,
name: 'quoted',
metalCostPerGram: null,
});
assert.equal(next.status, 201);
assert.equal(next.data.valuationAvailable, true);
assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '40');
assert.equal(
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status,
400,
);
} finally {
await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } });
await db.$disconnect();
}
});
+16 -1
View File
@@ -117,7 +117,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
await write(a, 'metals_refresh');
await call(a, 'connection_info');
const discovered = await a.client.listTools();
assert.ok(discovered.tools.length >= 40);
assert.equal(discovered.tools.length, 49);
assert.equal(
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
true,
@@ -322,6 +322,21 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
});
assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642');
await write(a, 'metal_value', { id: metal });
const gram = (
await write(a, 'metal_holding_create', {
name: 'grams',
currency: 'CNY',
metalType: 'gold',
metalGrams: '2',
metalPurity: '1',
autoValuation: true,
metalCostPerGram: '9',
date: day,
})
).result.id;
const gramDetail = await call(a, 'position_get', { id: gram });
assert.equal(gramDetail.metalCost, '18.00000000');
assert.equal(gramDetail.metalProfit, '3.75308642');
await call(a, 'metals_prices');
await call(a, 'settings_get');
await call(a, 'overview_get', { limit: 1 });
+27
View File
@@ -81,3 +81,30 @@ test('public metal update preserves decimal values and manual quotes, skips clea
globalThis.fetch = original;
}
});
test('unpriced gram holdings make included totals incomplete and cost uses physical grams', async () => {
const p: Holding = {
id: 'pending',
name: 'pending',
kind: 'asset',
side: 'asset',
currency: 'CNY',
metalType: 'gold',
createdAt: new Date('2026-10-01'),
revisions: [],
};
const v = totals([p], [], 'CNY', '2026-10-03');
assert.equal(v.complete, false);
assert.equal(v.items[0].converted, null);
assert.equal(v.missingValuations[0].id, p.id);
assert.equal(totals([{ ...p, included: false }], [], 'CNY', '2026-10-03').complete, true);
assert.equal(totals([p], [], 'CNY', '2026-09-30').complete, true);
const { metalPerformance } = await import('../src/metals');
assert.deepEqual(
metalPerformance(
{ metalType: 'gold', metalGrams: '2.5', metalCostPerGram: '12.8' },
'40',
true,
),
{ valuationAvailable: true, metalCost: '32.00000000', metalProfit: '8.00000000' },
);
});
+102 -35
View File
@@ -253,6 +253,10 @@ export default function App() {
[error, setError] = useState(''),
[success, setSuccess] = useState('');
const [calendarRefresh, setCalendarRefresh] = useState(0);
const [newAssetCategory, setNewAssetCategory] = useState(categories.asset[0][0]);
useEffect(() => {
setNewAssetCategory(categories.asset[0][0]);
}, [modal?.kind]);
const [quickTransfer, setQuickTransfer] = useState<{
sourceId?: string;
targetId?: string;
@@ -1029,7 +1033,7 @@ export default function App() {
</button>
</section>
)}
{!overview.complete && (
{!!overview.missing.length && (
<div className="notice warning">
{tr('缺少')}
{overview.missing.join('、')} {tr('到')}
@@ -1039,6 +1043,12 @@ export default function App() {
</button>
</div>
)}
{!!overview.missingValuations?.length && (
<div className="notice warning">
{tr('待估值')}:{overview.missingValuations.map((p) => p.name).join('、')} ·{' '}
{tr('缺少报价,当前总额不完整。')}
</div>
)}
<div
className="metrics"
hidden={
@@ -1277,14 +1287,17 @@ export default function App() {
{p.archived ? tr(' · 已归档') : ''}
</span>
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}>
{money(positionAmount(p), p.currency)}
{p.valuationAvailable === false
? tr('待估值')
: money(positionAmount(p), p.currency)}
{p.metalGrams && <small> · {p.metalGrams} g</small>}
</h2>
{p.hidden && <span className="badge">{tr('隐藏资产')}</span>}
<p className="muted" hidden={p.currency === user.baseCurrency}>
{tr('本位币:')}
{p.converted !== null
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
: tr('缺少汇率')}
: tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')}
</p>
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
{(p.included === false ||
@@ -1548,12 +1561,15 @@ export default function App() {
positionAmount(p).startsWith('-') ? 'danger-text' : undefined
}
>
{money(positionAmount(p), p.currency)}
{p.valuationAvailable === false
? tr('待估值')
: money(positionAmount(p), p.currency)}
{p.metalGrams && <small> · {p.metalGrams} g</small>}
</strong>
<small hidden={p.currency === user.baseCurrency}>
{tr('折合')}{' '}
{p.converted === null
? tr('缺少汇率')
? tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
</small>
</button>
@@ -2659,6 +2675,24 @@ export default function App() {
}),
tr('项目资料已保存'),
);
} else if (kind === 'asset' && v.category === 'gold') {
void act(
() =>
api('/metals/holdings', 'POST', {
name: v.name,
currency: v.currency,
notes: v.notes,
date: v.date,
metalType: v.metalType,
metalGrams: v.metalGrams,
metalPurity: v.metalPurity,
metalCostPerGram: v.metalCostPerGram || null,
autoValuation: true,
hidden: f.has('hidden'),
included: f.has('included'),
}),
tr('项目已添加'),
);
} else {
const category = String(v.category),
side =
@@ -2742,6 +2776,7 @@ export default function App() {
<Field label={tr('类别')}>
<select
name="category"
onChange={(e) => setNewAssetCategory(e.target.value)}
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
>
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
@@ -2782,37 +2817,69 @@ export default function App() {
</select>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field
label={
tr(
modal.p?.kind === 'debt'
? '剩余本金'
: modal.p?.kind === 'asset'
? '当前估值'
: '当前余额',
) + (modal.p ? '(' + modal.p.currency + ')' : '')
}
>
<input
name="amount"
inputMode="decimal"
required
pattern={
modal.p?.kind === 'account' || modal.kind === 'account'
? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
: '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) &&
!(modal.kind === 'asset' && newAssetCategory === 'gold') && (
<Field
label={
tr(
modal.p?.kind === 'debt'
? '剩余本金'
: modal.p?.kind === 'asset'
? '当前估值'
: '当前余额',
) + (modal.p ? '(' + modal.p.currency + ')' : '')
}
defaultValue={
modal.p
? accountInputAmount(modal.p, modal.h?.after || modal.p.amount)
: modal.kind === 'debt'
? '0'
: ''
}
placeholder="0.00"
/>
</Field>
>
<input
name="amount"
inputMode="decimal"
required
pattern={
modal.p?.kind === 'account' || modal.kind === 'account'
? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
: '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
}
defaultValue={
modal.p
? accountInputAmount(modal.p, modal.h?.after || modal.p.amount)
: modal.kind === 'debt'
? '0'
: ''
}
placeholder="0.00"
/>
</Field>
)}
{modal.kind === 'asset' && newAssetCategory === 'gold' && (
<>
<Field label={tr('贵金属品种')}>
<select name="metalType">
<option value="gold">{tr('黄金')}</option>
<option value="silver">{tr('白银')}</option>
</select>
</Field>
<Field label={tr('重量(克)')}>
<input
name="metalGrams"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</Field>
<Field label={tr('纯度(0–1,例如 0.999)')}>
<input name="metalPurity" required defaultValue="1" inputMode="decimal" />
</Field>
<Field label={tr('买入价(每克,选填)')}>
<input
name="metalCostPerGram"
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
/>
</Field>
<p className="muted">
{tr('按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。')}
</p>
</>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={tr('记录时间(UTC+8)')}>
+22 -1
View File
@@ -87,7 +87,8 @@ export function MetalPanel({
String(position.metalGrams) +
String(position.autoValuation) +
String(position.metalPurity) +
String(position.metalType)
String(position.metalType) +
String(position.metalCostPerGram)
}
onSubmit={(e) => {
e.preventDefault();
@@ -97,6 +98,7 @@ export function MetalPanel({
api('/metals/' + position.id, 'PUT', {
metalType: f.get('metalType'),
metalGrams: f.get('metalGrams'),
metalCostPerGram: f.get('metalCostPerGram') || null,
metalPurity: f.get('metalPurity'),
autoValuation: f.has('autoValuation'),
}),
@@ -133,6 +135,25 @@ export function MetalPanel({
defaultValue={position.metalPurity || '1'}
/>
</label>
<label>
{tr('买入价(每克,选填)')}({position.currency})
<input
name="metalCostPerGram"
inputMode="decimal"
defaultValue={position.metalCostPerGram || ''}
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
/>
</label>
<p className="muted">
{tr('买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。')}
</p>
{position.metalCost != null && (
<p>
{tr('买入成本')}:{position.metalCost} {position.currency} · {tr('估值盈亏')}:
{position.metalProfit ?? tr('待估值')}{' '}
{position.metalProfit != null ? position.currency : ''}
</p>
)}
<label className="check-line">
<input type="checkbox" name="autoValuation" defaultChecked={position.autoValuation} />
{tr('随参考价自动记录估值')}
+5
View File
@@ -53,6 +53,10 @@ export type History = {
export type Position = {
included?: boolean;
metalType?: string | null;
metalCostPerGram?: string | null;
metalCost?: string | null;
metalProfit?: string | null;
valuationAvailable?: boolean;
metalGrams?: string | null;
metalPurity?: string;
autoValuation?: boolean;
@@ -79,6 +83,7 @@ export type Total = {
net: string;
complete: boolean;
missing: string[];
missingValuations?: { id: string; name: string }[];
balanceChange?: string | null;
fxChange?: string | null;
};
+8 -1
View File
@@ -551,5 +551,12 @@
"正在更新贵金属参考价": "Updating reference prices",
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "Price update failed. Previous prices and values are preserved; you can enter prices manually.",
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "Create at least two active accounts. Unlock hidden accounts before selecting them.",
"请选择启用的账户": "Choose an active account"
"请选择启用的账户": "Choose an active account",
"买入价(每克,选填)": "Purchase price per gram (optional)",
"买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。": "Cost uses total physical grams; valuation uses grams and purity. Clear purchase price to hide profit/loss.",
"买入成本": "Purchase cost",
"估值盈亏": "Valuation profit/loss",
"待估值": "Awaiting valuation",
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete."
}
+48 -41
View File
@@ -438,11 +438,11 @@
"配对记录不完整,无法重算": "配對記錄不完整,無法重算",
"资金操作之前必须保留一条余额记录": "資金操作之前必須保留一條餘額記錄",
"修改后债务或资产金额不能为负数": "修改後債務或資產金額不能為負數",
"重算后的金额超出支持范围": "重算後的金額超出支援範圍",
"重算后的金额超出支持范围": "重算後的金額超出支持範圍",
"请在资金往来中修改完整配对记录": "請在資金往來中修改完整配對記錄",
"请在资金往来中删除完整配对记录": "請在資金往來中刪除完整配對記錄",
"资金往来记录不存在": "資金往來記錄不存在",
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新建立",
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新創建",
"修改": "修改",
"统计粒度": "統計粒度",
"删除记录": "刪除記錄",
@@ -451,57 +451,57 @@
"这条记录将被删除,并重算后续余额。": "這條記錄將被刪除,並重算後續餘額。",
"删除后无法撤销,请确认已保存所需备份。": "刪除後無法撤銷,請確認已保存所需備份。",
"账号与密码": "賬號與密碼",
"修改后其他登录会话将退出,当前会话保持登录。": "修改後其他登入會話將退出,目前會話保持登入。",
"当前密码": "目前密碼",
"修改后其他登录会话将退出,当前会话保持登录。": "修改後其他登錄會話將退出,當前會話保持登錄。",
"当前密码": "當前密碼",
"新密码(不修改请留空)": "新密碼(不修改請留空)",
"确认新密码": "確認新密碼",
"两次输入的新密码不一致": "兩次輸入的新密碼不一致",
"账号与密码已更新": "賬號與密碼已更新",
"当前密码错误": "目前密碼錯誤",
"当前密码错误": "當前密碼錯誤",
"请填写新的账号或密码": "請填寫新的賬號或密碼",
"账号已变更,请重新登录后操作": "賬號已變更,請重新登入後操作",
"账号已变更,请重新登录后操作": "賬號已變更,請重新登錄後操作",
"备份下载失败": "備份下載失敗",
"备份文件格式错误": "備份檔案格式錯誤",
"备份文件为空": "備份檔案為空",
"备份已生成,请确认文件已保存": "備份已產生,請確認檔案已保存",
"备份文件格式错误": "備份文件格式錯誤",
"备份文件为空": "備份文件為空",
"备份已生成,请确认文件已保存": "備份已生成,請確認文件已保存",
"溢缴存款": "溢繳存款",
"信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "信用卡、貸款賬戶:負數為欠款,正數為溢繳存款;其餘賬戶按實際餘額填寫。",
"负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "負數表示欠款,正數表示溢繳存款;溢繳部分計入總資產。",
"仅账户支持负余额": "僅賬戶支援負餘額",
"仅账户支持负余额": "僅賬戶支持負餘額",
"分组排序": "分組排序",
"上移": "上移",
"下移": "下移",
"分组顺序已保存": "分組順序已儲存",
"分组顺序已保存": "分組順序已保存",
"分组不可重复": "分組不可重複",
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。",
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。儲存後同步到帳戶頁和選擇彈窗。",
"账户分组顺序": "帳戶分組順序",
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整賬戶分組順序,賬戶頁和選擇彈窗會同步使用。",
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。保存後同步到賬戶頁和選擇彈窗。",
"账户分组顺序": "賬戶分組順序",
"拖动分组": "拖動分組",
"已取消拖拽": "已取消拖曳",
"常规设置": "一般設定",
"显示与菜单": "顯示與選單",
"登录与隐私": "登入與私隱",
"导航菜单": "導覽選單",
"设置已保存": "設定已儲存",
"已取消拖拽": "已取消拖拽",
"常规设置": "常規設置",
"显示与菜单": "顯示與菜單",
"登录与隐私": "登錄與隱私",
"导航菜单": "導航菜單",
"设置已保存": "設置已保存",
"总览显示卡片": "總覽顯示卡片",
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "選擇資產總覽需要顯示的卡片,資料和記錄仍會保留。",
"登录有效期(小时)": "登入有效期(小時)",
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "範圍 1 小時至 30 天(720 小時)。儲存後目前登入重新計時,其他會話沿用原到期時間;後續登入使用新設定。",
"当前会话到期:": "目前會話到期:",
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "選擇資產總覽需要顯示的卡片,數據和記錄仍會保留。",
"登录有效期(小时)": "登錄有效期(小時)",
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "範圍 1 小時至 30 天(720 小時)。保存後當前登錄重新計時,其他會話沿用原到期時間;後續登錄使用新設置。",
"当前会话到期:": "當前會話到期:",
"查看隐藏项目需要密码": "查看隱藏項目需要密碼",
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "關閉後,已登入帳號可直接顯示隱藏項目。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏項目。",
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "關閉後,已登錄賬號可直接顯示隱藏項目。每次顯示持續 5 分鐘,也可手動鎖定;保存此設置會鎖定所有會話的隱藏項目。",
"隐藏项目已显示": "隱藏項目已顯示",
"显示隐藏项目": "顯示隱藏項目",
"当前包含隐藏项目 · 5 分钟后自动锁定": "目前包含隱藏項目 · 5 分鐘後自動鎖定",
"当前包含隐藏项目 · 5 分钟后自动锁定": "當前包含隱藏項目 · 5 分鐘後自動鎖定",
"尚未选择总览卡片": "尚未選擇總覽卡片",
"设置显示卡片": "設定顯示卡片",
"设置显示卡片": "設置顯示卡片",
"修改计划": "修改計劃",
"下次执行时间": "下次執行時間",
"计划已保存": "計劃已儲存",
"计划已保存": "計劃已保存",
"操作提示": "操作提示",
"快速转账": "快速轉賬",
"重选": "重選",
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "按目前時間記錄,手續費為 0。跨幣種請填寫實際到賬金額。",
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "按當前時間記錄,手續費為 0。跨幣種請填寫實際到賬金額。",
"关闭快速转账": "關閉快速轉賬",
"点击账户卡片选择转出账户": "點擊賬戶卡片選擇轉出賬戶",
"转出账户:": "轉出賬戶:",
@@ -516,10 +516,10 @@
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "清除本賬號全部賬戶、資產、債務、歷史、私有圖標和匯率(包括隱藏資產);保留登錄賬號、個人設置和已發佈的共享圖標。請先下載備份並確認文件已保存。",
"隐藏资产已解锁 5 分钟": "隱藏資產已解鎖 5 分鐘",
"查看隐藏资产需要密码": "查看隱藏資產需要密碼",
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登錄賬號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;保存此設置會鎖定所有會話的隱藏資產。",
"隐藏资产已显示": "隱藏資產已顯示",
"显示隐藏资产": "顯示隱藏資產",
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定",
"当前包含隐藏资产 · 5 分钟后自动锁定": "當前包含隱藏資產 · 5 分鐘後自動鎖定",
"独立资产计入总览": "獨立資產計入總覽",
"关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。": "關閉後,獨立資產不參與總額和淨資產軌跡,資料與歷史仍保留。",
"计入资产总览": "計入資產總覽",
@@ -533,23 +533,30 @@
"重量(克)": "重量(克)",
"纯度(0–1,例如 0.999)": "純度(0–1,例如 0.999)",
"随参考价自动记录估值": "隨參考價自動記錄估值",
"保存估价设置": "儲存估價設定",
"保存估价设置": "保存估價設置",
"开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。": "開啟後,每日價格更新會新增估值歷史;已有歷史不會改寫。關閉後可手動應用估價。",
"刷新贵金属价格": "更新貴金屬價格",
"刷新贵金属价格": "刷新貴金屬價格",
"应用估价": "應用估價",
"参考价": "參考價",
"克": "克",
"手动录入贵金属价格": "手動輸入貴金屬價格",
"手动录入贵金属价格": "手動錄入貴金屬價格",
"每克纯金属价格": "每克純金屬價格",
"报价日期": "報價日期",
"保存参考价": "儲存參考價",
"贵金属估价设置已保存": "貴金屬估價設定已儲存",
"保存参考价": "保存參考價",
"贵金属估价设置已保存": "貴金屬估價設置已保存",
"贵金属参考价已更新": "貴金屬參考價已更新",
"估价已记入历史": "估價已記入歷史",
"贵金属价格已保存": "貴金屬價格已儲存",
"当前没有已设置重量的贵金属资产": "目前沒有已設定重量的貴金屬資產",
"贵金属价格已保存": "貴金屬價格已保存",
"当前没有已设置重量的贵金属资产": "當前沒有已設置重量的貴金屬資產",
"正在更新贵金属参考价": "正在更新貴金屬參考價",
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "貴金屬價格更新失敗,已有價格與估值已保留,可手動輸入價格",
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "請先建立至少兩個啟用中的賬戶。隱藏賬戶需驗證密碼後才能選擇。",
"请选择启用的账户": "請選擇啟用的賬戶"
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "貴金屬價格更新失敗,已有價格與估值已保留,可手動錄價",
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "請先創建至少兩個啟用中的賬戶。隱藏賬戶需驗證密碼後才能選擇。",
"请选择启用的账户": "請選擇啟用的賬戶",
"买入价(每克,选填)": "買入價(每克,選填)",
"买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。": "買入成本按實物總克數計算;參考估值按克數和純度計算。清空買入價後不顯示盈虧。",
"买入成本": "買入成本",
"估值盈亏": "估值盈虧",
"待估值": "待估值",
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。"
}
+51 -2
View File
@@ -1,7 +1,11 @@
{
"tableCount": 16,
"columnCount": 150,
"tableCount": 17,
"columnCount": 159,
"tables": [
{
"TABLE_NAME": "_prisma_migrations",
"TABLE_COMMENT": "Prisma 数据库迁移执行记录,由迁移引擎管理"
},
{
"TABLE_NAME": "agentauthorization",
"TABLE_COMMENT": "短期 OAuth 授权请求及一次性授权码摘要"
@@ -68,6 +72,46 @@
}
],
"columns": [
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "id",
"COLUMN_COMMENT": "迁移执行唯一标识"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "checksum",
"COLUMN_COMMENT": "迁移文件 SHA-256 校验摘要"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "finished_at",
"COLUMN_COMMENT": "迁移成功完成时间"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "migration_name",
"COLUMN_COMMENT": "迁移目录名称"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "logs",
"COLUMN_COMMENT": "迁移引擎错误和诊断信息"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "rolled_back_at",
"COLUMN_COMMENT": "迁移标记回滚时间"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "started_at",
"COLUMN_COMMENT": "迁移开始时间"
},
{
"TABLE_NAME": "_prisma_migrations",
"COLUMN_NAME": "applied_steps_count",
"COLUMN_COMMENT": "已执行步骤数量"
},
{
"TABLE_NAME": "agentauthorization",
"COLUMN_NAME": "id",
@@ -503,6 +547,11 @@
"COLUMN_NAME": "autoValuation",
"COLUMN_COMMENT": "是否随每日价格更新自动记录估值"
},
{
"TABLE_NAME": "position",
"COLUMN_NAME": "metalCostPerGram",
"COLUMN_COMMENT": "买入时每克实物成本,原币;空值表示不计算盈亏"
},
{
"TABLE_NAME": "positionlink",
"COLUMN_NAME": "id",
+3 -2
View File
@@ -2,7 +2,7 @@
盘点来源:App.tsx、Calendar、SchedulePanel、MetalPanel、IconLibrary、TransferForm、DebtPaymentForm、GroupOrderList 及所有 API Controller 路由。架构沿用 NestJS/Express/Prisma/MySQL/Zod,不新增消费账本或 AI 框架。
验证记号:**M** `test/mcp.test.ts` 第一组真实官方 SDK/MySQL;**C** 第二组并发/图标/密码修改;**O** 第三组 OAuth;**R** 原 17 组真实 REST/MySQL 回归;**U** 原单元计算/行情/ZIP 测试。均有实际业务断言。M 中公共刷新验证无需要更新资产时的真实调用;外部行情响应和失败回退由 U 验证,未声称行情供应商稳定可用。
验证记号:**M** `test/mcp.test.ts` 第一组真实官方 SDK/MySQL;**C** 第二组并发/图标/密码修改;**O** 第三组 OAuth;**R** 18 组真实 REST/MySQL 回归;**U** 原单元计算/行情/ZIP 测试。均有实际业务断言。M 中公共刷新验证无需要更新资产时的真实调用;外部行情响应和失败回退由 U 验证,未声称行情供应商稳定可用。
普通写入需要 draft 或 write,且受用户 readonly/draft/direct 策略约束;敏感操作额外要求 sensitive 并强制网页确认。所有写入工具要求幂等键和 expectedState。每项都先检查可信用户归属;annotations 不替代权限。
@@ -11,6 +11,7 @@
| 账户/独立资产/债务列表 GET positions | positions_list:kind、名称、归档、方向、币种、分组、排序、offset/limit | read | 无 | M:查询、重名、分页和外用户隔离 |
| 项目详情 GET positions/:id | position_get | read | 隐藏项目须连接独立网页解锁 | M:当前余额、隐藏/外用户拒绝 |
| 创建项目 POST positions | position_create,保留账户/资产/债务初始绝对余额语义 | draft/write | 策略决定草稿或直接 | M/C:3 类对象、重复调用、无效金额、并发 |
| 按克数创建贵金属 POST metals/holdings | metal_holding_create | draft/write | 策略 | M/R/U:无报价待估值、现有报价估值、可选成本盈亏、旧备份 |
| 元数据、分类、分组、图标、归档/恢复、隐藏、计入开关 PATCH positions/:id | position_update,完整 metadata;币种与方向固定 | draft/write | 策略 | M:分组、归档禁止金额更新与恢复;R:隐藏和开关 |
| 分组拖拽排序 PATCH settings | settings_update.accountGroupOrder | draft/write | 策略 | M/R:持久化、排序、备份 |
| 类别选项、名称与分组配置 | position_create/update;positions_list 读取现有值,Schema/描述提供规则 | read + draft/write | 策略 | M/R;当前没有独立分类/分组实体 CRUD |
@@ -33,7 +34,7 @@
| 金银报价与状态 GET metals | metals_prices,最近 100 条 | read | 无;查询 MCP 不触发隐式自动更新 | M/U/R |
| 金银报价刷新 POST metals/refresh | metals_refresh | draft/write | 策略,沿用自动估值 | M:无配置资产调用;U:外部响应和失败回退 |
| 每克报价手动回退 POST metals/prices | metal_price_set,Decimal 价格 | draft/write | 策略 | M/R/U:手动优先、精确估值 |
| 克数、品种、纯度、自动估价 PUT metals/:id | metal_configure | draft/write | 策略 | M/R/U:估值历史和去重 |
| 克数、品种、纯度、买入成本、自动估价 PUT metals/:id | metal_configure | draft/write | 策略 | M/R/U:估值历史和去重 |
| 应用估价 POST metals/:id/value | metal_value | draft/write | 策略 | M/R/U |
| 收支月日历 GET calendar | calendar_month,YYYY-MM | read | 无 | M/R/U |
| 单日日历明细 GET calendar/day | calendar_day,业务日和分页 | read | 无 | M/R/U:历史修改、转账排除、Decimal |
+7 -1
View File
@@ -13,6 +13,12 @@
密码只在网站填写。账号或密码修改后其他连接撤销,当前发起连接只保留 5 分钟 read 权限以读取完成结果,不可刷新;需要重新授权才能继续写入。隐藏资产解锁仅持续 5 分钟并限定当前连接,网站的解锁不自动授予 Agent。
## 贵金属按克数接入
新增 `metal_holding_create`:name、currency、metalType(gold/silver)、metalGrams、metalPurity、autoValuation、date,买入价 metalCostPerGram 可省略或 null;无需填写金额或市场价格。写入仍需 expectedState 和 idempotencyKey,并遵循写入策略。有对应业务日或更早报价时复用估值逻辑创建历史,没有报价时返回 valuationAvailable=false,查询总额 complete=false 与 missingValuations。position_get/list 返回可选 metalCost 和 metalProfit,均为原币十进制字符串。成本按实物重量计算,市场价值按重量×纯度×每克纯金属报价计算。
metal_configure 省略 metalCostPerGram 保留旧成本,传 null 清除。克数或成本修改与其他写入一样参与并发状态校验;盈亏由共享业务服务计算,不存储额外浮点累计值。[更新与验证](update-metal-grams-2026-10-03.md)。
## 开发启动与环境
```powershell
@@ -130,6 +136,6 @@ location /api/ {
主要修改:原 8 个业务模块及 auth 抽取 BusinessService;database 增加事务上下文;新增 `src/mcp/{oauth,catalogue,operations,files,management,transport}.ts`;设置页新增 AgentConnections;SDK 精确版本和 pnpm lock;新增真实 MCP 测试及只读 probe。
实际通过:前后端 typecheck/build、35 项原单元检查、17 组真实 REST/MySQL 回归、3 组真实官方 MCP SDK 端到端检查,以及 mcp:probe(发现 48 个工具)。新迁移 deploy 成功,实际 MySQL 16 张应用表、150 个字段注释完整。测试真实连接 MySQL,使用随机临时用户并清理,未替代现有 REST 回归。端到端文件传输、事务回滚、归属、金额精度、幂等、并发、失效/撤销和 OAuth 均在测试中检查;外部行情源的正常和失败路径由原业务单元测试验证,不保证外部报价服务实时可用。
实际通过:前后端 typecheck/build、36 项单元检查、18 组真实 REST/MySQL 回归、3 组真实官方 MCP SDK 端到端检查,工具发现验证 49 个工具(原 mcp:probe 的 48 个工具上增加 metal_holding_create)。新迁移 deploy 成功,实际 MySQL 17 张表(含 Prisma 迁移表)、159 个字段注释完整。测试真实连接 MySQL,使用随机临时用户并清理,未替代现有 REST 回归。端到端文件传输、事务回滚、归属、金额精度、幂等、并发、失效/撤销和 OAuth 均在测试中检查;外部行情源的正常和失败路径由原业务单元测试验证,不保证外部报价服务实时可用。
部署未执行;未推送代码;其他客户端及实际 HTTPS/代理兼容性尚未验证。
+29
View File
@@ -0,0 +1,29 @@
# 贵金属克数与数据库注释更新
独立资产新增弹窗选择「贵金属」后,填写黄金/白银、克数、纯度、原币和业务时间,无需填写金额或市场单价。默认随参考价自动估值;没有有效报价时为「待估值」,总览提示不完整,不把未知价值作为已知零值。后续自动/手动报价继续沿用现有 MetalsService 和历史逻辑。
买入价为可选的每克实物成本(原币)。成本 = 总克数 × 买入价;估值 = 总克数 × 纯度 × 对应币种纯金属每克报价;盈亏 = 当前估值 − 成本。全程 Decimal,金额 8 位小数四舍五入;每克成本最多 12 位整数、12 位小数且必须大于零。它是估值盈亏,不包含未记录的税费/已实现卖出收益。省略或清空价格不显示盈亏,缺少估值时盈亏待定。旧贵金属资产可在详情补充成本。
## 服务、备份和 MCP
REST POST /api/metals/holdings 与 MCP metal_holding_create 共用 MetalsBusinessService.create;无需 amount。现有 PUT /api/metals/:id 与 metal_configure 接受可选 metalCostPerGram,省略保留、null 清除。position 查询返回 valuationAvailable、metalCost、metalProfit。前端只展示服务端结果,不以 Number 计算盈亏。
ZIP v8 增加可选成本字段,既有备份缺少字段仍可验证恢复;配置、成本和估值历史保持用户隔离。新 MCP 工具遵循服务端草稿、确认、幂等与并发规则,工具总数为 49。
## 迁移和启动
- 20261003120000_metal_cost:Position 增加可空 DECIMAL(24,12) metalCostPerGram,附中文字段注释,不回填未知成本。
- 20261003130000_migration_comments:为 Prisma 自己的迁移表和 8 个字段补齐注释,保持实际类型、可空性和默认值。
- 前一 MCP 提交已新增 20261003090000_agent_access、20261003100000_agent_audit、20261003110000_agent_comments。
全部 migration deploy 已在当前 MySQL 实际执行,累计 22 个迁移。scripts/check-comments.cjs 验证所有 17 张表、159 个字段均有注释,清单见 database-comments.json。无新增环境变量;启动命令见 [MCP 文档](mcp.md)。先暂停 API,再 db:generate、db:migrate,然后 dev;不使用 reset。
## 实际验证
- 前后端 typecheck/build 通过。
- 36 项单元测试通过:待估值完整性、精确克数成本、行情/失败回退、原计算和 ZIP 等。
- 18 组真实 REST/MySQL 测试通过:无价格克数创建、拥有报价创建、精确盈亏、成本保留/清除、外用户拒绝、无效克数拒绝、新备份恢复和旧备份兼容,以及原业务回归。
- 3 组官方 SDK/MySQL MCP 测试通过:包含新增克数工具真实写入和查询;原 OAuth、确认、幂等、并发与失败回滚继续通过。
- 登录后的新增弹窗尚未通过浏览器人工端到端验证:自动审批拒绝临时测试账号登录,等待用户授权。已有 MCP 管理页在前一阶段已浏览器验证。无其他商业 Agent 客户端或生产代理部署验证。
本次不部署、不推送、不改本地 .env,也不把图片保存进 docs。
+2
View File
@@ -52,6 +52,8 @@
- ~~转账转入转出对账号选择没有要求,信用卡等也可以转账~~ — 已完成并验证:2026-10-03 18:56
- ~~设置中可以设置独立资产是否计入资产,每一个资产、账号、债务都可以设置是否计入资产~~ — 已完成并验证:2026-10-03 18:56
- ~~独立资产中贵金属支持估价,更新方式和汇率一致~~ — 已完成并验证:2026-10-03 18:56
- ~~贵金属为克数,不用设置价格,可以设置买入价格(当设置了之后会显示盈亏)~~ — 已完成并通过真实 REST/MCP 测试:2026-10-03 22:40(UTC+8);浏览器登录验证待授权,见 docs/update-metal-grams-2026-10-03.md
- ~~所有MySQL数据表要补充注释~~ — 已完成并验证:2026-10-03 22:40(UTC+8),17 张表、159 个字段,包括 Prisma 迁移表。
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。