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
@@ -0,0 +1,14 @@
-- PERM-01 (owner approval 2026-10-08): legacy's two dashboard permissions
-- «آمار عمده فروشی» (dashboard.omde) and «آمار تک فروشی» (dashboard.tak), and
-- their tree parent "dashboard", became the single "dashboard.view" when the two
-- dashboards became one. A database seeded before that change still holds the
-- old rows: nothing reads them and the permission screen cannot show or revoke
-- them. Whoever held one keeps the dashboard through "dashboard.view", then the
-- old rows go. Data only, idempotent.
INSERT INTO "permission" ("rid", "userTypeId")
SELECT DISTINCT 'dashboard.view', "userTypeId"
FROM "permission"
WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak')
ON CONFLICT DO NOTHING;
DELETE FROM "permission" WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak');
@@ -0,0 +1,33 @@
-- PERS-02 (owner approval 2026-10-08): cities become a tree again, as in legacy
-- (country > province > city > district, henza `cities.parent`). The flat
-- `province` label is turned into parent rows, nothing is lost, then dropped.
ALTER TABLE "City" ADD COLUMN "parentId" TEXT;
-- One province row per distinct label, named as legacy names them
-- («استان تهران»), unless a city row already carries that name.
INSERT INTO "City" ("id", "name")
SELECT 'cprov' || substr(md5(p.pname), 1, 20), p.pname
FROM (
SELECT DISTINCT
CASE WHEN btrim("province") LIKE 'استان%' THEN btrim("province") ELSE 'استان ' || btrim("province") END AS pname
FROM "City"
WHERE "province" IS NOT NULL AND btrim("province") <> ''
) p
WHERE NOT EXISTS (SELECT 1 FROM "City" c WHERE c."name" = p.pname);
UPDATE "City" c
SET "parentId" = (
SELECT p."id" FROM "City" p
WHERE p."id" <> c."id"
AND p."name" = CASE WHEN btrim(c."province") LIKE 'استان%' THEN btrim(c."province") ELSE 'استان ' || btrim(c."province") END
ORDER BY p."id"
LIMIT 1
)
WHERE c."province" IS NOT NULL AND btrim(c."province") <> '';
ALTER TABLE "City" DROP COLUMN "province";
CREATE INDEX "City_parentId_idx" ON "City"("parentId");
ALTER TABLE "City" ADD CONSTRAINT "City_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+8 -1
View File
@@ -118,10 +118,15 @@ enum LedgerSourceType {
// Identity
// ─────────────────────────────────────────────────────────────────────────
/// Legacy `cities` is a tree (country > province > city > district) and so is
/// this: a city hangs under its `parent`. In the admin pickers a top-level row
/// (the country) is a heading, not selectable (henza_admin citiesModel.js:38).
model City {
id String @id @default(cuid())
name String
province String?
parentId String?
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Restrict)
children City[] @relation("CityTree")
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
percent Decimal? @db.Decimal(6, 3)
@@ -131,6 +136,8 @@ model City {
branches Branch[]
preOrders PreOrder[]
salesExpertProfiles SalesExpertProfile[]
@@index([parentId])
}
model AdminUser {
+15 -4
View File
@@ -113,11 +113,22 @@ async function main() {
}
// ── Cities ────────────────────────────────────────────────────────────
// A tree, as legacy's: country > province > city. The country is a heading
// in the admin pickers; provinces and cities are selectable.
const city = (id: string, name: string, parentId: string | null) =>
prisma.city.upsert({ where: { id }, update: {}, create: { id, name, parentId } });
await city('city-iran', 'ایران', null);
await Promise.all([
city('city-prov-tehran', 'استان تهران', 'city-iran'),
city('city-prov-khorasan-razavi', 'استان خراسان رضوی', 'city-iran'),
city('city-prov-isfahan', 'استان اصفهان', 'city-iran'),
city('city-prov-east-azarbaijan', 'استان آذربایجان شرقی', 'city-iran'),
]);
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
prisma.city.upsert({ where: { id: 'city-tehran' }, update: {}, create: { id: 'city-tehran', name: 'تهران', province: 'تهران' } }),
prisma.city.upsert({ where: { id: 'city-mashhad' }, update: {}, create: { id: 'city-mashhad', name: 'مشهد', province: 'خراسان رضوی' } }),
prisma.city.upsert({ where: { id: 'city-isfahan' }, update: {}, create: { id: 'city-isfahan', name: 'اصفهان', province: 'اصفهان' } }),
prisma.city.upsert({ where: { id: 'city-tabriz' }, update: {}, create: { id: 'city-tabriz', name: 'تبریز', province: 'آذربایجان شرقی' } }),
city('city-tehran', 'تهران', 'city-prov-tehran'),
city('city-mashhad', 'مشهد', 'city-prov-khorasan-razavi'),
city('city-isfahan', 'اصفهان', 'city-prov-isfahan'),
city('city-tabriz', 'تبریز', 'city-prov-east-azarbaijan'),
]);
// ── Branches & workshop ──────────────────────────────────────────────
+43 -14
View File
@@ -29,19 +29,9 @@ export async function cities(ctx: Ctx) {
WHERE deleted_at IS NULL
OR id IN (SELECT city_id FROM customers WHERE deleted_at IS NULL AND city_id IS NOT NULL)`,
);
const all = new Map((await ctx.q<Row>('SELECT id, title, parent, level FROM cities')).map((r) => [String(r.id), r]));
// Legacy is a tree (country > province > city > district); the target City is
// flat with a `province` label, taken from the level-1 ancestor.
const provinceOf = (r: Row): string | null => {
let cur: Row | undefined = r;
for (let guard = 0; cur && guard < 10; guard++) {
if (int(cur.level) === 1) return cur === r ? null : text(cur.title);
cur = all.get(String(cur.parent));
}
return null;
};
// Legacy is a tree (country > province > city > district) and so is the
// target (PERS-02): rows first, then each one's parent once all are mapped.
const existing = await ctx.db.city.findMany();
const byKey = new Map(existing.map((c) => [matchKey(c.name), c.id]));
@@ -53,7 +43,6 @@ export async function cities(ctx: Ctx) {
}
const data = {
name,
province: provinceOf(r),
percent: r.percent === null ? null : percent(r.percent),
percentAux: r.percent_aux === null ? null : percent(r.percent_aux),
};
@@ -77,6 +66,32 @@ export async function cities(ctx: Ctx) {
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
byKey.set(matchKey(name), res.id);
}
// Parents. A row the import created takes legacy's parent; a matched row
// keeps the parent it already has (fill-empty, as every other field). A
// parent that was not imported (deleted in legacy) leaves the row on top.
const parentOf = new Map((await ctx.db.city.findMany({ select: { id: true, parentId: true } })).map((c) => [c.id, c.parentId]));
for (const r of rows) {
const id = ctx.map.get('city', r.id);
const parentId = r.parent === null ? null : ctx.map.get('city', r.parent);
if (!id || !parentId || parentId === id) continue;
if (ctx.map.how('city', r.id) !== 'created' && parentOf.get(id)) continue;
if (parentOf.get(id) === parentId) continue;
// Never close a loop: `id` must not be an ancestor of its new parent.
let cur: string | null | undefined = parentId;
let loop = false;
for (let guard = 0; cur && guard < 50; guard++) {
if (cur === id) loop = true;
cur = parentOf.get(cur);
}
if (loop) {
ctx.report.note(S, `legacy city ${r.id}: parent ${r.parent} would make a loop — left as is`);
continue;
}
await ctx.db.city.update({ where: { id }, data: { parentId } });
parentOf.set(id, parentId);
ctx.report.count(S, 'parent set');
}
}
/** Target City id for an exact (normalised) name match, or null. */
@@ -98,6 +113,18 @@ async function cityByName(ctx: Ctx, name: string | null): Promise<string | null>
*/
const SAME_ROLE: ReadonlyMap<number, number> = new Map([[85, 85]]);
/**
* Legacy rids the target renamed (PERM-01): the wholesale and retail dashboards
* («آمار عمده فروشی» dashboard.omde, «آمار تک فروشی» dashboard.tak) and their
* tree parent became the one dashboard behind "dashboard.view". Without this a
* role that saw a dashboard in legacy would lose it.
*/
const RENAMED_RIDS: ReadonlyMap<string, string> = new Map([
['dashboard', 'dashboard.view'],
['dashboard.omde', 'dashboard.view'],
['dashboard.tak', 'dashboard.view'],
]);
/** AdminRole is a label only — every permission comes from the user type. */
function adminRoleFor(legacyUt: number | null): AdminRole {
if (legacyUt === 85 || legacyUt === 104 || legacyUt === 105) return AdminRole.SUPER_ADMIN; // مدیر · مدیر اصلی · مدیران
@@ -146,7 +173,9 @@ export async function roles(ctx: Ctx) {
created = true;
}
const legacyRids = perms.filter((p) => Number(p.ut) === legacyId).map((p) => String(p.rid));
const legacyRids = [
...new Set(perms.filter((p) => Number(p.ut) === legacyId).map((p) => RENAMED_RIDS.get(String(p.rid)) ?? String(p.rid))),
];
if (created || ctx.map.how('ut', legacyId) === 'created') {
// A role the import created gets legacy's permissions, limited to rids the
// target ACL knows (the storefront's are out of scope, D13).
@@ -16,7 +16,16 @@ export class AdminCitiesController {
@HttpCode(200)
async list() {
const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } });
// legacy shape: { id, title }
return { rows: cities.map((c) => ({ id: c.id, title: c.name, province: c.province })) };
// legacy shape: the cities row — { id, title, parent, percent, percent_aux }.
// `parent` lets the admin pickers draw legacy's tree (PERS-02).
return {
rows: cities.map((c) => ({
id: c.id,
title: c.name,
parent: c.parentId,
percent: c.percent,
percent_aux: c.percentAux,
})),
};
}
}
@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AdminMiscService } from './admin-misc.service';
/** Cities as a tree (PERS-02): legacy's percent inheritance and loop guard. */
describe('AdminMiscService — city tree', () => {
type Row = { id: string; parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null };
const D = (v: number) => new Prisma.Decimal(v);
let rows: Map<string, Row>;
let service: AdminMiscService;
let update: jest.Mock;
beforeEach(() => {
// ایران > استان تهران > تهران > تهران شمال
rows = new Map(
[
{ id: 'iran', parentId: null, percent: null, percentAux: null },
{ id: 'prov', parentId: 'iran', percent: D(10.12), percentAux: D(10.12) },
{ id: 'teh', parentId: 'prov', percent: D(0), percentAux: D(8) },
{ id: 'north', parentId: 'teh', percent: null, percentAux: null },
].map((r) => [r.id, r]),
);
update = jest.fn(async ({ where, data }) => ({ ...rows.get(where.id), ...data }));
const prisma = {
city: {
findUnique: jest.fn(async ({ where }) => rows.get(where.id) ?? null),
update,
},
};
service = new AdminMiscService(prisma as never, {} as never);
});
it('a city without its own percent takes the nearest ancestor’s (legacy _getP)', async () => {
expect(String(await service.getCityPercent('north', true))).toBe('8');
// 0 counts as "none", as legacy's `> 0`: تهران has 0, so استان تهران's.
expect(String(await service.getCityPercent('north', false))).toBe('10.12');
});
it('0 when no ancestor has one', async () => {
expect(await service.getCityPercent('iran', false)).toBe(0);
});
it('refuses to put a city under its own descendant', async () => {
await expect(service.updateCity('teh', { parent: 'north' })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.updateCity('teh', { parent: 'teh' })).rejects.toBeInstanceOf(BadRequestException);
expect(update).not.toHaveBeenCalled();
});
it('moves a city and clears a parent', async () => {
await service.updateCity('north', { parent: 'prov' });
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: 'prov' } });
await service.updateCity('north', { parent: '' });
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: null } });
});
it('refuses an unknown parent', async () => {
await expect(service.updateCity('north', { parent: 'nope' })).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -35,7 +35,7 @@ export class AdminMiscController {
@Post('cities')
@HttpCode(200)
@RequirePermission('cities.create')
createCity(@Body() data: { title?: string; name?: string; province?: string }) {
createCity(@Body() data: Record<string, never>) {
return this.misc.createCity(data);
}
@@ -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 ────────────────────────────────────────────────────────────