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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user