feat(cities): cities are a tree again, as legacy's; old dashboard permissions merged (PERS-02, PERM-01)

Cities (owner: as legacy)
- City.parentId (self relation) replaces the flat `province` label; the
  migration turns each label into a parent row («استان …») first, so nothing
  is lost.
- Admin pickers are legacy's tree (CityTreeSelect): the top level, the
  country, is a heading. The city form and grid get «والد».
- Restored legacy _getP: a city without its own percent takes its
  ancestor's. Restored the omde-customer form's city → percent autofill.
- Import copies legacy parents; delete refuses a city with children; a
  parent loop is refused.

Permissions (owner approved the deletion)
- dashboard / dashboard.omde / dashboard.tak rows become dashboard.view,
  then are deleted.
- Import maps them the same way: legacy types 102, 104, 105, 108 and 135
  had lost the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-08 16:31:12 +03:30
co-authored by Claude Opus 5.5
parent fbe7b98ada
commit fbe4f38e06
17 changed files with 467 additions and 126 deletions
@@ -26,14 +26,17 @@ export class AdminMiscService {
) {}
// ── 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', 'province'],
allowed: ['id', 'name', 'province', 'percent', 'percentAux'],
searchable: ['name'],
allowed: ['id', 'name', 'parentId', 'percent', 'percentAux'],
defaultOrderBy: { name: 'asc' },
});
@@ -43,7 +46,10 @@ export class AdminMiscService {
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
skip,
take,
include: { _count: { select: { customers: true, branches: true } } },
include: {
parent: { select: { id: true, name: true } },
_count: { select: { customers: true, branches: true, children: true } },
},
}),
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
]);
@@ -53,11 +59,13 @@ export class AdminMiscService {
id: c.id,
title: c.name,
name: c.name,
province: c.province,
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,
@@ -66,33 +74,82 @@ export class AdminMiscService {
);
}
/** 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 } });
/**
* 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;
}
/** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */
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; province?: string; percent?: unknown; percent_aux?: unknown },
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.CityUpdateInput = {};
const patch: Prisma.CityUncheckedUpdateInput = {};
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));
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 });
}
@@ -100,16 +157,19 @@ export class AdminMiscService {
/**
* Legacy `DELETE cities/{id}`.
*
* Refuses while customers or branches still point at the city — legacy deleted
* regardless, orphaning those rows.
* 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 } } },
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(
@@ -120,11 +180,28 @@ export class AdminMiscService {
return { success: true };
}
/** Legacy `GET cities/get-percent/{id}` — returns the bare number. */
/**
* 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) {
const c = await this.prisma.city.findUnique({ where: { id } });
if (!c) throw new NotFoundException('شهر یافت نشد');
return aux ? c.percentAux : c.percent;
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 ────────────────────────────────────────────────────────────