diff --git a/apps/api/prisma/migrations/20261003120000_metal_cost/migration.sql b/apps/api/prisma/migrations/20261003120000_metal_cost/migration.sql new file mode 100644 index 0000000..2941821 --- /dev/null +++ b/apps/api/prisma/migrations/20261003120000_metal_cost/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `Position` ADD COLUMN `metalCostPerGram` DECIMAL(24,12) NULL COMMENT '买入时每克实物成本,原币;空值表示不计算盈亏'; diff --git a/apps/api/prisma/migrations/20261003130000_migration_comments/migration.sql b/apps/api/prisma/migrations/20261003130000_migration_comments/migration.sql new file mode 100644 index 0000000..2084602 --- /dev/null +++ b/apps/api/prisma/migrations/20261003130000_migration_comments/migration.sql @@ -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 '已执行步骤数量'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d78bc40..9b90188 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/scripts/check-comments.cjs b/apps/api/scripts/check-comments.cjs index c21fa0b..70d18d8 100644 --- a/apps/api/scripts/check-comments.cjs +++ b/apps/api/scripts/check-comments.cjs @@ -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()); diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index bf7659b..506b9d2 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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(); 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), diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index 01f7de4..00eddf4 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -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(); + 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, }; diff --git a/apps/api/src/mcp/catalogue.ts b/apps/api/src/mcp/catalogue.ts index a656239..8a41cfa 100644 --- a/apps/api/src/mcp/catalogue.ts +++ b/apps/api/src/mcp/catalogue.ts @@ -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), diff --git a/apps/api/src/metals.ts b/apps/api/src/metals.ts index f374526..9b9ea55 100644 --- a/apps/api/src/metals.ts +++ b/apps/api/src/metals.ts @@ -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); } diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 286817d..805fabc 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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, + } : {}), }, }); diff --git a/apps/api/test/inclusion-metals.test.ts b/apps/api/test/inclusion-metals.test.ts index 35155a4..5b015b4 100644 --- a/apps/api/test/inclusion-metals.test.ts +++ b/apps/api/test/inclusion-metals.test.ts @@ -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(); + } +}); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index a7dad92..5928501 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -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 }); diff --git a/apps/api/test/metals.test.ts b/apps/api/test/metals.test.ts index aa8195c..87cbe63 100644 --- a/apps/api/test/metals.test.ts +++ b/apps/api/test/metals.test.ts @@ -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' }, + ); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 20efc1f..d716381 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { )} - {!overview.complete && ( + {!!overview.missing.length && (
{tr('缺少')} {overview.missing.join('、')} {tr('到')} @@ -1039,6 +1043,12 @@ export default function App() {
)} + {!!overview.missingValuations?.length && ( +
+ {tr('待估值')}:{overview.missingValuations.map((p) => p.name).join('、')} ·{' '} + {tr('缺少报价,当前总额不完整。')} +
+ )}