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>
633 lines
23 KiB
TypeScript
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 ?? [];
|
|
}
|
|
}
|