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 ──────────────────────────────────────────────