feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
|
||||
* CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`,
|
||||
* `GET cash/{id}`, `DELETE cash/{id}`).
|
||||
*
|
||||
* A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted
|
||||
* gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the
|
||||
* existing LedgerSourceType enum:
|
||||
*
|
||||
* 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750)
|
||||
* 2 حواله -> REPAYMENT_TRANSFER
|
||||
* 3 چک -> REPAYMENT_CHEQUE
|
||||
* 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram)
|
||||
*
|
||||
* Posting delegates to LedgerService, which owns the karat and price-per-gram
|
||||
* conversions and the confirm/balance rules. Re-implementing them here would let
|
||||
* the admin path drift from the customer-facing one.
|
||||
*/
|
||||
const TYPE_BY_CODE: Record<number, LedgerSourceType> = {
|
||||
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
2: LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
3: LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
4: LedgerSourceType.REPAYMENT_CASH,
|
||||
};
|
||||
|
||||
/** The instrument name LedgerService.postGoldRepayment expects. */
|
||||
const INSTRUMENT_BY_TYPE: Record<string, 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'> = {
|
||||
REPAYMENT_MELTED_GOLD: 'MELTED_GOLD',
|
||||
REPAYMENT_TRANSFER: 'TRANSFER',
|
||||
REPAYMENT_CHEQUE: 'CHEQUE',
|
||||
REPAYMENT_CASH: 'CASH',
|
||||
};
|
||||
|
||||
const CODE_BY_TYPE: Partial<Record<LedgerSourceType, number>> = {
|
||||
REPAYMENT_MELTED_GOLD: 1,
|
||||
REPAYMENT_TRANSFER: 2,
|
||||
REPAYMENT_CHEQUE: 3,
|
||||
REPAYMENT_CASH: 4,
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<LedgerSourceType, string> = {
|
||||
ORDER_STOCK: 'سفارش',
|
||||
ORDER_PRE_ORDER: 'سفارش ساختنی',
|
||||
REPAYMENT_MELTED_GOLD: 'آب شده',
|
||||
REPAYMENT_TRANSFER: 'حواله',
|
||||
REPAYMENT_CHEQUE: 'چک',
|
||||
REPAYMENT_CASH: 'پولی',
|
||||
WALLET_BONUS: 'هدیه کیف پول',
|
||||
ADJUSTMENT: 'اصلاح',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminDocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly ledger: LedgerService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'sourceType',
|
||||
'currency',
|
||||
'direction',
|
||||
'amount',
|
||||
'confirmed',
|
||||
'createdAt',
|
||||
'karatPerMille',
|
||||
'labName',
|
||||
'customer.fullName',
|
||||
// legacy filter keys
|
||||
'type_str',
|
||||
'ayar',
|
||||
'lab',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
type_str: 'sourceType',
|
||||
ayar: 'karatPerMille',
|
||||
lab: 'labName',
|
||||
caj: 'createdAt',
|
||||
uaj: 'createdAt',
|
||||
'customer.name': 'customer.fullName',
|
||||
value: 'amount',
|
||||
price: 'priceRial',
|
||||
original_value: 'originalValue',
|
||||
account_name: 'accountName',
|
||||
bank_name: 'bankName',
|
||||
to: 'transferTo',
|
||||
recive_date: 'receivedAt',
|
||||
price_per_gram: 'pricePerGramRialAtEntry',
|
||||
ang: 'hallmark',
|
||||
};
|
||||
|
||||
/** Legacy `POST cash/table` — the documents grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'sourceType' && mapped.value !== undefined) {
|
||||
mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
if (mapped.field === 'confirmed' && mapped.value !== undefined) {
|
||||
mapped.value = String(mapped.value) === '1';
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['note', 'customer.fullName', 'labName'],
|
||||
allowed: AdminDocumentsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The grid is the REPAYMENT document list; order-generated debt entries are
|
||||
// not documents an operator files, so they are excluded as legacy's separate
|
||||
// `cash` table naturally did.
|
||||
const scoped: Prisma.LedgerEntryWhereInput = {
|
||||
AND: [
|
||||
where as Prisma.LedgerEntryWhereInput,
|
||||
{ sourceType: { in: Object.values(TYPE_BY_CODE) } },
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.ledgerEntry.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
}),
|
||||
this.prisma.ledgerEntry.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved so the ported table model binds unchanged. */
|
||||
private toRow(
|
||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: e.id,
|
||||
type: CODE_BY_TYPE[e.sourceType] ?? null,
|
||||
type_str: TYPE_LABELS[e.sourceType],
|
||||
sourceType: e.sourceType,
|
||||
currency: e.currency,
|
||||
direction: e.direction,
|
||||
// legacy `value` is the normalized final weight, `original_value` the raw one
|
||||
value: e.amount,
|
||||
original_value: e.originalValue,
|
||||
price: e.priceRial,
|
||||
price_per_gram: e.pricePerGramRialAtEntry,
|
||||
ayar: e.karatPerMille,
|
||||
lab: e.labName,
|
||||
ang: e.hallmark,
|
||||
account_name: e.accountName,
|
||||
bank_name: e.bankName,
|
||||
to: e.transferTo,
|
||||
recive_date: e.receivedAt,
|
||||
note: e.note,
|
||||
// legacy grid renders `confirmed` through a slot using 0/1
|
||||
confirmed: e.confirmed ? 1 : 0,
|
||||
confirmedAt: e.confirmedAt,
|
||||
customer: {
|
||||
id: e.customer.id,
|
||||
name: e.customer.fullName,
|
||||
fname: e.customer.fullName,
|
||||
lname: '',
|
||||
mobile: e.customer.mobile,
|
||||
},
|
||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
||||
caj: e.createdAt,
|
||||
uaj: e.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET cash/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
});
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
return this.toRow(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash` — file a repayment document.
|
||||
*
|
||||
* The gram/karat/price conversions are LedgerService's job; this method only
|
||||
* translates the legacy form shape and attaches the instrument metadata.
|
||||
*/
|
||||
async create(data: Record<string, unknown>, actorAdminUserId: string) {
|
||||
const typeCode = Number(data.type ?? 2);
|
||||
const sourceType = TYPE_BY_CODE[typeCode];
|
||||
if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است');
|
||||
|
||||
const customerId = String(data.customer_id ?? '');
|
||||
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
|
||||
// LedgerService takes the legacy instrument name and the RAW figure, then
|
||||
// applies the karat / price-per-gram conversion itself.
|
||||
const instrument = INSTRUMENT_BY_TYPE[sourceType];
|
||||
|
||||
// Which field carries the raw figure depends on the instrument, exactly as
|
||||
// the legacy form did: cash posts a Rial price, melted gold posts the
|
||||
// pre-normalization weight, transfer/cheque post grams directly.
|
||||
const rawValue =
|
||||
instrument === 'CASH'
|
||||
? String(data.price ?? 0)
|
||||
: instrument === 'MELTED_GOLD'
|
||||
? String(data.original_value ?? data.value ?? 0)
|
||||
: String(data.value ?? 0);
|
||||
|
||||
const entry = await this.ledger.postGoldRepayment({
|
||||
customerId,
|
||||
instrument,
|
||||
rawValue,
|
||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||
note: (data.note as string) ?? undefined,
|
||||
postedByAdminUserId: actorAdminUserId,
|
||||
});
|
||||
|
||||
// Attach the instrument metadata the ledger service does not model.
|
||||
await this.prisma.ledgerEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: this.metadataPatch(data),
|
||||
});
|
||||
|
||||
return this.findOne(entry.id);
|
||||
}
|
||||
|
||||
private metadataPatch(data: Record<string, unknown>): Prisma.LedgerEntryUpdateInput {
|
||||
const patch: Prisma.LedgerEntryUpdateInput = {};
|
||||
if (data.lab !== undefined) patch.labName = (data.lab as string) || null;
|
||||
if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null;
|
||||
if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null;
|
||||
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
|
||||
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
|
||||
if (data.original_value !== undefined && data.original_value !== null) {
|
||||
patch.originalValue = new Prisma.Decimal(String(data.original_value));
|
||||
}
|
||||
if (data.price !== undefined && data.price !== null) {
|
||||
patch.priceRial = new Prisma.Decimal(String(data.price));
|
||||
}
|
||||
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `PUT cash/{id}` — edit a document.
|
||||
*
|
||||
* Only metadata and the note are editable. Changing the AMOUNT of a confirmed
|
||||
* entry would silently rewrite a customer's debt, so that is refused: the
|
||||
* correct accounting action is a reversing adjustment, not an edit. Legacy
|
||||
* allowed it. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
const amountChanged =
|
||||
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
|
||||
|
||||
if (existing.confirmed && amountChanged) {
|
||||
throw new BadRequestException(
|
||||
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const patch = this.metadataPatch(data);
|
||||
if (data.note !== undefined) patch.note = (data.note as string) || null;
|
||||
if (!existing.confirmed && amountChanged) {
|
||||
patch.amount = new Prisma.Decimal(String(data.value));
|
||||
}
|
||||
|
||||
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
|
||||
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
|
||||
* calling this same endpoint.
|
||||
*
|
||||
* Confirming delegates to LedgerService so the customer balance moves through
|
||||
* the one code path that owns it. Un-confirming reverses that same amount
|
||||
* rather than editing the balance directly, so the two stay consistent.
|
||||
*/
|
||||
async toggleConfirm(id: string, adminUserId: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
if (!e.confirmed) {
|
||||
await this.ledger.confirmEntry(id, adminUserId);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Un-confirm: undo this entry's effect on the cached balance, then clear the
|
||||
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
|
||||
// amount back.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delta =
|
||||
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
|
||||
const field =
|
||||
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id: e.customerId },
|
||||
data: { [field]: { increment: delta } },
|
||||
});
|
||||
await tx.ledgerEntry.update({
|
||||
where: { id },
|
||||
data: { confirmed: false, confirmedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE cash/{id}`.
|
||||
*
|
||||
* A confirmed entry has already moved the customer's balance; deleting it
|
||||
* would leave that balance overstated with no trace. Refused — legacy allowed
|
||||
* it. Unconfirmed entries never touched a balance, so they delete cleanly.
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
if (e.confirmed) {
|
||||
throw new BadRequestException(
|
||||
'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.',
|
||||
);
|
||||
}
|
||||
await this.prisma.ledgerEntry.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
typeOptions() {
|
||||
return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({
|
||||
value: Number(code),
|
||||
title: TYPE_LABELS[sourceType],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Wallet ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST wallet` grid — the list of granted wallet bonuses. */
|
||||
async listWallet(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['customer.fullName'],
|
||||
allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'],
|
||||
defaultOrderBy: { grantedAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.walletBonus.findMany({
|
||||
where: where as Prisma.WalletBonusWhereInput,
|
||||
orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true },
|
||||
}),
|
||||
this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((w) => ({
|
||||
id: w.id,
|
||||
value: w.goldGrams,
|
||||
grams: w.goldGrams,
|
||||
is_active: w.isActive,
|
||||
consumed: Boolean(w.consumedAt),
|
||||
caj: w.grantedAt,
|
||||
expires_at: w.expiresAt,
|
||||
customer: {
|
||||
id: w.customer.id,
|
||||
name: w.customer.fullName,
|
||||
mobile: w.customer.mobile,
|
||||
},
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST
|
||||
* counterparts — the wallet settings screen (views/manage/wallet.vue).
|
||||
* Stored in the Setting key/value table that replaces legacy `config_static`.
|
||||
*/
|
||||
async getSetting(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? {};
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: Record<string, unknown>) {
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: value as Prisma.InputJsonValue },
|
||||
create: { key, value: value as Prisma.InputJsonValue },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
/** The customer picker used by the document form — legacy `GET query/customer`. */
|
||||
async queryCustomers() {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true },
|
||||
});
|
||||
return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user