feat: move brand name to one config and redesign the accounting card

اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 13:31:20 +03:30
co-authored by Claude Haiku 5.5
parent 5d2a1efb0c
commit 532ceee189
100 changed files with 1870 additions and 579 deletions
@@ -9,6 +9,8 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
import { brandName, brandSiteUrl } from '../../common/brand';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
@@ -137,12 +139,13 @@ export class AdminCustomersService {
private readonly table: TableService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
private readonly accountingConfig: AccountingConfigService,
) {}
/**
* The welcome SMS the reference sends after every new customer is created
* (AdminController.php:378-387), with the brand and the PWA link changed to
* منتزر; same line order. The commented-out Android/iOS lines are not sent
* BRAND_NAME / BRAND_SITE_URL (common/brand.ts); same line order. The commented-out Android/iOS lines are not sent
* there and are not sent here.
*
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
@@ -153,11 +156,11 @@ export class AdminCustomersService {
*/
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
const body =
'منتزر\n' +
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
`نام کاربری : ${mobile}\n` +
'لینک pwa \n' +
'https://bnk.montzar.ir';
brandSiteUrl();
void this.sms.send(mobile, body).catch((e: unknown) => {
// send() is documented never to throw; this is belt-and-braces so an
@@ -182,6 +185,8 @@ export class AdminCustomersService {
'goldCreditLimitGrams',
'goldDebtGrams',
'fiatDebtRial',
'accountingGoldBalance',
'accountingRialBalance',
'referredByAgent.fullName',
// legacy filter keys, accepted so the ported table models work unchanged
'name',
@@ -205,6 +210,8 @@ export class AdminCustomersService {
gram_level: 'goldCreditLimitGrams',
remain: 'goldDebtGrams',
rial_remain: 'fiatDebtRial',
acc_remain: 'accountingGoldBalance',
acc_rial_remain: 'accountingRialBalance',
caj: 'createdAt',
uaj: 'updatedAt',
'reagent.name': 'referredByAgent.fullName',
@@ -289,7 +296,7 @@ export class AdminCustomersService {
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
orderBy: AdminCustomersService.nullsLast(orderBy) as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
@@ -297,7 +304,61 @@ export class AdminCustomersService {
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
const linked = await this.accountingLinked(rows.map((r) => r.id));
return this.table.respond(
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
total,
query,
page,
limit,
);
}
/**
* The accounting balance is null for a customer not linked to the software,
* and Postgres puts nulls FIRST on a descending sort — the biggest debtors
* would sit under every unlinked customer.
*/
private static nullsLast(orderBy: Record<string, unknown>) {
const [field, dir] = Object.entries(orderBy)[0] ?? [];
return field === 'accountingGoldBalance' || field === 'accountingRialBalance'
? { [field]: { sort: dir, nulls: 'last' } }
: orderBy;
}
/**
* D27 — which of these customers are linked to the CONNECTED accounting
* software; null when none is connected (the list then hides its columns).
* The provider is checked because a balance read from a previous provider
* stays on the customer row.
*/
private async accountingLinked(ids: string[]): Promise<Set<string> | null> {
const config = await this.accountingConfig.get();
if (config.provider === 'none' || this.accountingConfig.inactiveReason(config)) return null;
const links = await this.prisma.accountingLink.findMany({
where: { provider: config.provider, entityType: 'CUSTOMER', entityId: { in: ids } },
select: { entityId: true },
});
return new Set(links.map((l) => l.entityId));
}
/** The balance in the accounting software (app convention: positive = owes). */
private static accountingCells(
c: {
id: string;
accountingGoldBalance: Prisma.Decimal | null;
accountingRialBalance: Prisma.Decimal | null;
accountingBalanceAt: Date | null;
},
linked: Set<string> | null,
) {
if (!linked) return {};
const on = linked.has(c.id);
return {
acc_remain: on ? c.accountingGoldBalance : null,
acc_rial_remain: on ? c.accountingRialBalance : null,
acc_at: on ? c.accountingBalanceAt : null,
};
}
/** Legacy field names preserved so the ported table models bind unchanged. */
@@ -1022,6 +1083,10 @@ export class AdminCustomersService {
: VerificationStep.VERIFIED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Reference MajorCustomerController.php:108-110 — accepting step one
// activates the account. An app self-registration is created inactive
// and can sign in only from here on.
...(payload.sts_verify_step_one !== undefined ? { isActive: true } : {}),
};
} else {
data = {