Files
tehran_tala/apps/api/src/modules/admin-documents/admin-documents.service.ts
T

466 lines
17 KiB
TypeScript
Raw Normal View History

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 { SmsService } from '../sms/sms.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 readonly sms: SmsService,
) {}
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,
// D0 — real name parts
fname: e.customer.firstName ?? e.customer.fullName,
lname: e.customer.lastName ?? '',
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);
this.notifyDocumentConfirm(e.customerId, true);
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 },
});
});
this.notifyDocumentConfirm(e.customerId, false);
return this.findOne(id);
}
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
const text = confirmed
? 'هنزا گلد\nسند مالی شما توسط مدیر سیستم تایید شد.'
: 'هنزا گلد\nسند مالی شما توسط مدیر سیستم رد شد.';
this.prisma.customer
.findUnique({ where: { id: customerId }, select: { mobile: true } })
.then((c) => {
if (c) this.sms.send(c.mobile, text);
})
.catch(() => {});
}
/**
* 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, firstName: true, lastName: true, mobile: true },
});
// D0 — real name parts.
return rows.map((c) => ({
id: c.id,
name: c.fullName,
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
}));
}
}