Files
tehran_tala/apps/api/src/modules/admin-misc/admin-misc.service.ts
T
s.esmaeiliandClaude Opus 5.5 2d74f53d0a feat(admin): D16 (b)(c) — Excel order-item report, document-confirm SMS, %name in bulk SMS
Finished from a parallel session that expired (owner: «بساز»).

- RPT-01: GET admin/report/order-items — legacy report/order_item's
  32-column Excel (order items, one row per unit, plus financial
  documents), RTL, Jalali dates. The «گزارش تجمیعی» screen gets
  «دریافت فایل اکسل». Review fixes:
  - weight: computedWeightGrams is already one unit's weight; legacy
    divided it by qty again, under-reporting «بدهکار» on multi-unit
    lines. Not repeated.
  - «درصد»: the final percent (wage − discount), as legacy.
  - dates: the end date covers its whole day; a bad date is a 400.
- SMS-01: confirming or rejecting a financial document texts the customer,
  as legacy CashController::toggleConfiremCash.
- SMS-02: bulk SMS substitutes %name per recipient and is actually sent
  through the configured gateway (D26 relay), no longer only logged.
- Docs: 01-ADMIN-MATRIX and D16-BC-GAP-SUMMARY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:34:45 +03:30

633 lines
23 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { MessageAudience, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
* views/newsletters, views/statics and views/manage/*:
*
* cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id}
* sms/table, sms
* notify, notify/customer, notify/{id}, notify/read/{id}
* ddf -> the newsletter list (legacy's own opaque name)
* log/list/customer, log/ago/{id}
* const, const/{key}, const/by-id/{id}
* dashboard/link
*/
@Injectable()
export class AdminMiscService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sms: SmsService,
) {}
// ── Cities ─────────────────────────────────────────────────────────
//
// Legacy cities are a tree (country > province > city > district) and so are
// the target's again (PERS-02): each city may hang under a `parent`.
/** Legacy `POST cities/list` grid. */
async listCities(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name'],
allowed: ['id', 'name', 'parentId', 'percent', 'percentAux'],
defaultOrderBy: { name: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.city.findMany({
where: where as Prisma.CityWhereInput,
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
skip,
take,
include: {
parent: { select: { id: true, name: true } },
_count: { select: { customers: true, branches: true, children: true } },
},
}),
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
]);
return this.table.respond(
rows.map((c) => ({
id: c.id,
title: c.name,
name: c.name,
parent: c.parentId,
parent_title: c.parent?.name ?? null,
percent: c.percent,
percent_aux: c.percentAux,
customers_count: c._count.customers,
branches_count: c._count.branches,
children_count: c._count.children,
})),
total,
query,
page,
limit,
);
}
/**
* The parent a create/update asks for: `parent` (legacy's field name) or
* `parent_id`; empty means top level. Refuses an unknown city, the city
* itself, or one of its own descendants (that would make a loop).
*/
private async resolveCityParent(
data: { parent?: unknown; parent_id?: unknown },
selfId?: string,
): Promise<string | null | undefined> {
const raw = data.parent !== undefined ? data.parent : data.parent_id;
if (raw === undefined) return undefined;
if (raw === null || String(raw).trim() === '') return null;
const parentId = String(raw).trim();
// Walk up from the requested parent; meeting selfId means a loop.
let cur: string | null = parentId;
for (let guard = 0; cur && guard < 50; guard++) {
if (cur === selfId) throw new BadRequestException('شهر نمی‌تواند زیرمجموعه‌ی خودش باشد.');
const row: { parentId: string | null } | null = await this.prisma.city.findUnique({
where: { id: cur },
select: { parentId: true },
});
if (!row) {
if (cur === parentId) throw new BadRequestException('والد انتخاب‌شده پیدا نشد.');
break;
}
cur = row.parentId;
}
return parentId;
}
private static cityPercent(v: unknown): Prisma.Decimal | null | undefined {
if (v === undefined) return undefined;
if (v === null || String(v).trim() === '') return null;
try {
return new Prisma.Decimal(String(v));
} catch {
throw new BadRequestException('درصد باید عدد باشد.');
}
}
/** Legacy `POST cities` — name, parent and the two percents. */
async createCity(data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown }) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
const parentId = await this.resolveCityParent(data);
return this.prisma.city.create({
data: {
name,
parentId: parentId ?? null,
percent: AdminMiscService.cityPercent(data.percent) ?? null,
percentAux: AdminMiscService.cityPercent(data.percent_aux) ?? null,
},
});
}
/** Legacy `PUT cities/{id}` — name, parent and the two percents. */
async updateCity(
id: string,
data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown },
) {
const existing = await this.prisma.city.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شهر یافت نشد');
const patch: Prisma.CityUncheckedUpdateInput = {};
if (data.title !== undefined || data.name !== undefined) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
patch.name = name;
}
const parentId = await this.resolveCityParent(data, id);
if (parentId !== undefined) patch.parentId = parentId;
const percent = AdminMiscService.cityPercent(data.percent);
if (percent !== undefined) patch.percent = percent;
const percentAux = AdminMiscService.cityPercent(data.percent_aux);
if (percentAux !== undefined) patch.percentAux = percentAux;
return this.prisma.city.update({ where: { id }, data: patch });
}
/**
* Legacy `DELETE cities/{id}`.
*
* Refuses while customers, branches or child cities still point at the city —
* legacy deleted regardless, orphaning those rows.
*/
async removeCity(id: string) {
const c = await this.prisma.city.findUnique({
where: { id },
include: { _count: { select: { customers: true, branches: true, addresses: true, children: true } } },
});
if (!c) throw new NotFoundException('شهر یافت نشد');
if (c._count.children > 0) {
throw new BadRequestException(`حذف این شهر ممکن نیست، چون ${c._count.children} شهر زیرمجموعه‌ی آن است.`);
}
const refs = c._count.customers + c._count.branches + c._count.addresses;
if (refs > 0) {
throw new BadRequestException(
`حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`,
);
}
await this.prisma.city.delete({ where: { id } });
return { success: true };
}
/**
* Legacy `GET cities/get-percent/{id}` — returns the bare number. As legacy's
* CitiesController::_getP: a city without its own percent (null or 0) takes
* its parent's, and so on up; 0 when no ancestor has one.
*/
async getCityPercent(id: string, aux: boolean) {
let cur: string | null = id;
for (let guard = 0; cur && guard < 50; guard++) {
const c: { parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null } | null =
await this.prisma.city.findUnique({
where: { id: cur },
select: { parentId: true, percent: true, percentAux: true },
});
if (!c) {
if (cur === id) throw new NotFoundException('شهر یافت نشد');
break;
}
const v = aux ? c.percentAux : c.percent;
if (v !== null && v.gt(0)) return v;
cur = c.parentId;
}
return 0;
}
// ── SMS ────────────────────────────────────────────────────────────
/** Legacy `POST sms/table` — the sent-message log. */
async listSms(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['body', 'recipients'],
allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.smsMessage.findMany({
where: where as Prisma.SmsMessageWhereInput,
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
skip,
take,
include: { sentByAdminUser: true },
}),
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
]);
return this.table.respond(
rows.map((s) => ({
id: s.id,
type: s.audience,
text: s.body,
mobile: s.recipients,
sent_count: s.sentCount,
sender: s.sentByAdminUser?.fullName ?? null,
caj: s.createdAt,
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST sms` — resolves the audience, substitutes `%name` per
* recipient (legacy SmsController::batch + replaceName), delivers via the
* configured gateway (D26 relay), and records the send.
*/
async sendSms(
data: { type?: string | number; mobile?: string; text?: string },
actorId: string,
) {
const body = String(data.text ?? '').trim();
if (!body) throw new BadRequestException('متن پیامک الزامی است');
const audience = this.resolveAudience(data.type);
const recipients = await this.resolveRecipients(audience, data.mobile);
const needsNameSub = body.includes('%name');
const customersByMobile = needsNameSub
? await this.customerNamesByMobile(recipients)
: new Map<string, string>();
for (const mobile of recipients) {
const text = needsNameSub
? body.replace(/%name/g, customersByMobile.get(mobile) ?? 'مشتری عزیز')
: body;
this.sms.send(mobile, text, { audience });
}
return this.prisma.smsMessage.create({
data: {
audience,
recipients: recipients.join(','),
body,
sentCount: recipients.length,
sentByAdminUserId: actorId,
},
});
}
private async customerNamesByMobile(mobiles: string[]): Promise<Map<string, string>> {
const rows = await this.prisma.customer.findMany({
where: { mobile: { in: mobiles } },
select: { mobile: true, fullName: true },
});
const map = new Map<string, string>();
for (const r of rows) {
if (r.fullName) map.set(r.mobile, r.fullName + ' عزیز');
}
return map;
}
/**
* Legacy's numeric audience codes, read off views/sms/create.vue where each
* option is gated by its own rid:
* -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent
* 2 sms.create.omde | 9 sms.create.multiple
* (there is no option for sms.create.single-customer "tak" in the legacy
* markup even though the rid exists — preserved as unreachable.)
*/
private resolveAudience(type?: string | number): MessageAudience {
const byCode: Record<string, MessageAudience> = {
'-1': MessageAudience.SINGLE_NUMBER,
'0': MessageAudience.ALL_CUSTOMERS,
'1': MessageAudience.SALES_AGENTS,
'2': MessageAudience.WHOLESALE_CUSTOMERS,
'9': MessageAudience.MULTIPLE_NUMBERS,
};
const byName: Record<string, MessageAudience> = {
single: MessageAudience.SINGLE_NUMBER,
multiple: MessageAudience.MULTIPLE_NUMBERS,
all: MessageAudience.ALL_CUSTOMERS,
omde: MessageAudience.WHOLESALE_CUSTOMERS,
tak: MessageAudience.RETAIL_CUSTOMERS,
reagent: MessageAudience.SALES_AGENTS,
};
const key = String(type ?? '');
return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER;
}
/** Expands an audience into the actual phone numbers it covers. */
private async resolveRecipients(
audience: MessageAudience,
explicit?: string,
): Promise<string[]> {
if (
audience === MessageAudience.SINGLE_NUMBER ||
audience === MessageAudience.MULTIPLE_NUMBERS
) {
const list = String(explicit ?? '')
.split(/[,\s\n]+/)
.map((x) => x.trim())
.filter(Boolean);
if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است');
return list;
}
if (audience === MessageAudience.SALES_AGENTS) {
const rows = await this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// The customer audiences. WHOLESALE/RETAIL had a Customer.type column in
// legacy that the schema redesign removed, so both currently resolve to all
// active customers — the same gap recorded for the omde/tak lists.
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// ── Notifications ──────────────────────────────────────────────────
/** Legacy `POST notify` — broadcast to an audience. */
async createNotification(
data: { title?: string; text?: string; body?: string; type?: string | number },
actorId: string,
) {
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است');
if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است');
const audience = this.resolveAudience(data.type);
const customers = await this.prisma.customer.findMany({
where: { isActive: true },
select: { id: true },
});
return this.prisma.notification.create({
data: {
title,
body,
audience,
sentByAdminUserId: actorId,
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
},
include: { _count: { select: { recipients: true } } },
});
}
/** Legacy `POST notify/customer` — send to one named customer. */
async notifyCustomer(
data: { customer_id?: string; title?: string; text?: string; body?: string },
actorId: string,
) {
const customerId = String(data.customer_id ?? '');
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است');
return this.prisma.notification.create({
data: {
title,
body,
audience: MessageAudience.SINGLE_NUMBER,
sentByAdminUserId: actorId,
recipients: { create: [{ customerId }] },
},
});
}
/** Legacy `GET notify/{id}` — one notification with its delivery state. */
async findNotification(id: string) {
const n = await this.prisma.notification.findUnique({
where: { id },
include: {
sentByAdminUser: true,
recipients: { include: { customer: true } },
},
});
if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد');
return {
id: n.id,
title: n.title,
body: n.body,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
caj: n.createdAt,
recipients: n.recipients.map((r) => ({
id: r.id,
customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile },
read_at: r.readAt,
is_read: Boolean(r.readAt),
})),
read_count: n.recipients.filter((r) => r.readAt).length,
total_count: n.recipients.length,
};
}
/** Legacy `POST notify/list` — the notification log. */
async listNotifications(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'body'],
allowed: ['id', 'title', 'body', 'audience', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.notification.findMany({
where: where as Prisma.NotificationWhereInput,
orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput,
skip,
take,
include: {
sentByAdminUser: true,
_count: { select: { recipients: true } },
},
}),
this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }),
]);
return this.table.respond(
rows.map((n) => ({
id: n.id,
title: n.title,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
total_count: n._count.recipients,
caj: n.createdAt,
})),
total,
query,
page,
limit,
);
}
/** Legacy `GET notify/read/{id}` — mark one recipient row read. */
async markNotificationRead(recipientId: string) {
const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
if (!r) throw new NotFoundException('یافت نشد');
if (r.readAt) return r;
return this.prisma.notificationRecipient.update({
where: { id: recipientId },
data: { readAt: new Date() },
});
}
// ── Newsletter ─────────────────────────────────────────────────────
/** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */
async listNewsletter(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['email', 'mobile'],
allowed: ['id', 'email', 'mobile', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.newsletterSubscriber.findMany({
where: where as Prisma.NewsletterSubscriberWhereInput,
orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput,
skip,
take,
}),
this.prisma.newsletterSubscriber.count({
where: where as Prisma.NewsletterSubscriberWhereInput,
}),
]);
return this.table.respond(
rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })),
total,
query,
page,
limit,
);
}
// ── Activity log ("آمار") ─────────────────────────────────────────
/** Legacy `GET log/list/customer` — customers that have any activity. */
async logCustomers() {
const rows = await this.prisma.customer.findMany({
where: { activityLogs: { some: {} } },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true },
});
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
}
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
async customerLog(customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const rows = await this.prisma.customerActivityLog.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows.map((l) => ({
id: l.id,
action: l.action,
detail: l.detail,
caj: l.createdAt,
}));
}
// ── Settings ("مدیریت ثابت ها") ───────────────────────────────────
/** Legacy `GET const/{key}`. */
async getConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
return row?.value ?? null;
}
/** Legacy `POST const` / `PUT const/{key}`. */
async setConst(key: string, value: 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;
}
async listConsts() {
// The accounting integration's rows (connection, encrypted token, worker
// lease) have their own `config.accounting` screen and never appear here
// (D27); the single-key routes refuse them in the controller.
const rows = await this.prisma.setting.findMany({
where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] },
orderBy: { key: 'asc' },
});
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
}
async removeConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
if (!row) throw new NotFoundException('تنظیم یافت نشد');
await this.prisma.setting.delete({ where: { key } });
return { success: true };
}
// ── Brands (managed from the same "ثابت ها" screen) ───────────────
async createBrand(title: string) {
if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است');
return this.prisma.brand.create({ data: { title: title.trim() } });
}
async updateBrand(id: string, title: string) {
const b = await this.prisma.brand.findUnique({ where: { id } });
if (!b) throw new NotFoundException('برند یافت نشد');
return this.prisma.brand.update({ where: { id }, data: { title } });
}
async removeBrand(id: string) {
const b = await this.prisma.brand.findUnique({
where: { id },
include: { _count: { select: { products: true } } },
});
if (!b) throw new NotFoundException('برند یافت نشد');
if (b._count.products > 0) {
throw new BadRequestException(
`حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`,
);
}
await this.prisma.brand.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */
async dashboardLinks() {
const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } });
return row?.value ?? [];
}
}