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

+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,
}
: {}),
},
});