Files
tehran_taj/apps/api/src/modules/admin-documents/admin-documents.service.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:52:33 +03:30

467 lines
17 KiB
TypeScript

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';
import { brandName } from '../../common/brand';
/**
* 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, postedByAdmin: 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; postedByAdmin: 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.postedByAdmin ? e.postedByAdmin.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, postedByAdmin: 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,
postedByAdminId: 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
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
: `${brandName()}\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,
}));
}
}