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:
co-authored by
Claude Opus 5.5
parent
fbe7b98ada
commit
fbe4f38e06
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user