feat: create precious metals by grams and show optional purchase profit
This commit is contained in:
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 '已执行步骤数量';
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user