2026-09-13 21:02:25 +03:30
|
|
|
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 type { TableQueryDto } from '../admin-table/table-query.dto';
|
2026-10-06 16:48:36 +03:30
|
|
|
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
|
2026-09-13 21:02:25 +03:30
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
// ── Cities ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** 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', 'province'],
|
|
|
|
|
allowed: ['id', 'name', 'province', '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: { _count: { select: { customers: true, branches: 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,
|
|
|
|
|
province: c.province,
|
|
|
|
|
percent: c.percent,
|
|
|
|
|
percent_aux: c.percentAux,
|
|
|
|
|
customers_count: c._count.customers,
|
|
|
|
|
branches_count: c._count.branches,
|
|
|
|
|
})),
|
|
|
|
|
total,
|
|
|
|
|
query,
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Legacy `POST cities`. */
|
|
|
|
|
async createCity(data: { title?: string; name?: string; province?: string }) {
|
|
|
|
|
const name = String(data.title ?? data.name ?? '').trim();
|
|
|
|
|
if (!name) throw new BadRequestException('نام شهر الزامی است');
|
|
|
|
|
return this.prisma.city.create({ data: { name, province: data.province ?? null } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */
|
|
|
|
|
async updateCity(
|
|
|
|
|
id: string,
|
|
|
|
|
data: { title?: string; name?: string; province?: string; percent?: unknown; percent_aux?: unknown },
|
|
|
|
|
) {
|
|
|
|
|
const existing = await this.prisma.city.findUnique({ where: { id } });
|
|
|
|
|
if (!existing) throw new NotFoundException('شهر یافت نشد');
|
|
|
|
|
|
|
|
|
|
const patch: Prisma.CityUpdateInput = {};
|
|
|
|
|
if (data.title !== undefined || data.name !== undefined) {
|
|
|
|
|
patch.name = String(data.title ?? data.name);
|
|
|
|
|
}
|
|
|
|
|
if (data.province !== undefined) patch.province = data.province ?? null;
|
|
|
|
|
if (data.percent !== undefined) {
|
|
|
|
|
patch.percent = data.percent === null ? null : new Prisma.Decimal(String(data.percent));
|
|
|
|
|
}
|
|
|
|
|
if (data.percent_aux !== undefined) {
|
|
|
|
|
patch.percentAux =
|
|
|
|
|
data.percent_aux === null ? null : new Prisma.Decimal(String(data.percent_aux));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.prisma.city.update({ where: { id }, data: patch });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Legacy `DELETE cities/{id}`.
|
|
|
|
|
*
|
|
|
|
|
* Refuses while customers or branches 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 } } },
|
|
|
|
|
});
|
|
|
|
|
if (!c) throw new NotFoundException('شهر یافت نشد');
|
|
|
|
|
|
|
|
|
|
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. */
|
|
|
|
|
async getCityPercent(id: string, aux: boolean) {
|
|
|
|
|
const c = await this.prisma.city.findUnique({ where: { id } });
|
|
|
|
|
if (!c) throw new NotFoundException('شهر یافت نشد');
|
|
|
|
|
return aux ? c.percentAux : c.percent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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` — records the send.
|
|
|
|
|
*
|
|
|
|
|
* NOTE no SMS gateway is wired up: legacy handed the message to an external
|
|
|
|
|
* provider, and this port has no credentials for one. The message is stored
|
|
|
|
|
* with the audience it resolved to, so the log is accurate, but nothing is
|
|
|
|
|
* actually transmitted. Flagged in docs/ADMIN-PORT-PLAN.md.
|
|
|
|
|
*/
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
return this.prisma.smsMessage.create({
|
|
|
|
|
data: {
|
|
|
|
|
audience,
|
|
|
|
|
recipients: recipients.join(','),
|
|
|
|
|
body,
|
|
|
|
|
sentCount: recipients.length,
|
|
|
|
|
sentByAdminUserId: actorId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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() {
|
2026-10-06 16:48:36 +03:30
|
|
|
// 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: { NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } },
|
|
|
|
|
orderBy: { key: 'asc' },
|
|
|
|
|
});
|
2026-09-13 21:02:25 +03:30
|
|
|
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 ?? [];
|
|
|
|
|
}
|
|
|
|
|
}
|