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
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<!--
|
||||
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
|
||||
tree — country > province > city > district — with the top level a heading
|
||||
that cannot be picked. A top-level row with nothing under it stays pickable,
|
||||
so a city entered without a parent is never unreachable.
|
||||
`parentPicker` is the «والد» field of the city form: every row is pickable
|
||||
there except the city being edited and its own descendants.
|
||||
-->
|
||||
<a-tree-select
|
||||
:value="value ?? undefined"
|
||||
:tree-data="treeData"
|
||||
show-search
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
tree-node-filter-prop="title"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
|
||||
class="w-full"
|
||||
@update:value="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface CityRow {
|
||||
id: string;
|
||||
title: string;
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
value: string;
|
||||
key: string;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string | null;
|
||||
cities: CityRow[];
|
||||
parentPicker?: boolean;
|
||||
/** parentPicker: the city being edited — it and its subtree are not offered. */
|
||||
excludeId?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', v: string | undefined): void;
|
||||
(e: 'change', v: string | undefined): void;
|
||||
}>();
|
||||
|
||||
const treeData = computed<Node[]>(() => {
|
||||
const ids = new Set(props.cities.map((c) => c.id));
|
||||
const kids = new Map<string | null, CityRow[]>();
|
||||
for (const c of props.cities) {
|
||||
const p = c.parent && ids.has(c.parent) && c.parent !== c.id ? c.parent : null;
|
||||
if (!kids.has(p)) kids.set(p, []);
|
||||
kids.get(p)!.push(c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const build = (parent: string | null, depth: number): Node[] =>
|
||||
(kids.get(parent) ?? [])
|
||||
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
|
||||
.map((c) => {
|
||||
seen.add(c.id);
|
||||
const children = build(c.id, depth + 1);
|
||||
const node: Node = { value: c.id, key: c.id, title: c.title };
|
||||
if (children.length) node.children = children;
|
||||
if (!props.parentPicker && depth === 0 && children.length) node.disabled = true;
|
||||
return node;
|
||||
});
|
||||
return build(null, 0);
|
||||
});
|
||||
|
||||
function onChange(v: string | undefined) {
|
||||
emit('update:value', v ?? undefined);
|
||||
emit('change', v ?? undefined);
|
||||
}
|
||||
</script>
|
||||
@@ -72,13 +72,10 @@
|
||||
«شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228).
|
||||
-->
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
<CityTreeSelect
|
||||
:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
option-filter-prop="label"
|
||||
:cities="cities"
|
||||
placeholder="شهر را انتخاب نمایید"
|
||||
:options="cities.map((c) => ({ value: c.id, label: c.title }))"
|
||||
@change="onCityChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -117,6 +114,7 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -147,7 +145,7 @@ const form = reactive<Record<string, any>>({
|
||||
});
|
||||
|
||||
/** Cities for the «شهر» select and the commission autofill. */
|
||||
const cities = ref<Array<{ id: string; title: string; percent?: number | null }>>([]);
|
||||
const cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
|
||||
const err = reactive<Record<string, string>>({});
|
||||
|
||||
/** Snapshot of the server's record, captured after the edit form is populated. */
|
||||
@@ -180,7 +178,7 @@ function onAvatar(file: Record<string, unknown>) {
|
||||
* `if (!percent)`, so a city percent of 0 also falls through to the aux
|
||||
* lookup. Replicated rather than "fixed" (D4).
|
||||
*/
|
||||
async function onCityChange(value: string) {
|
||||
async function onCityChange(value: string | undefined) {
|
||||
form.city_id = value;
|
||||
if (!value) return;
|
||||
|
||||
@@ -246,6 +244,7 @@ onMounted(async () => {
|
||||
cities.value = (data.rows ?? []).map((c: any) => ({
|
||||
id: c.id,
|
||||
title: c.title ?? c.name,
|
||||
parent: c.parent ?? null,
|
||||
percent: c.percent === null || c.percent === undefined ? null : Number(c.percent),
|
||||
}));
|
||||
} catch (e) {
|
||||
|
||||
@@ -98,17 +98,7 @@
|
||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
||||
<div class="grid-2">
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
v-model:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="انتخاب کنید"
|
||||
:filter-option="filterCity"
|
||||
>
|
||||
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
|
||||
</a-form-item>
|
||||
<a-form-item label="آدرس">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
@@ -211,17 +201,7 @@
|
||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
||||
<div class="grid-2">
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
v-model:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="انتخاب کنید"
|
||||
:filter-option="filterCity"
|
||||
>
|
||||
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
|
||||
</a-form-item>
|
||||
<a-form-item label="آدرس">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
@@ -269,6 +249,7 @@ import { message } from 'ant-design-vue';
|
||||
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open?: boolean;
|
||||
@@ -299,7 +280,7 @@ const router = useRouter();
|
||||
|
||||
const id = asModal ? props.id : (route.params.id as string | undefined);
|
||||
const loading = ref(false);
|
||||
const cities = ref<{ id: string; title: string }[]>([]);
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null; percent?: unknown }[]>([]);
|
||||
const reagent = ref<{ id: string; name: string }[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
@@ -369,8 +350,24 @@ function diffFromInitial(): Record<string, any> {
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterCity(input: string, option: any) {
|
||||
return String(option?.children?.[0]?.children ?? '').includes(input);
|
||||
/**
|
||||
* Legacy getPercent (henza_admin bulkCustomer.vue:260-272): picking a city fills
|
||||
* «درصد» with the city's own percent, else its commission percent — which, as
|
||||
* legacy's _getP, a city without one takes from its parent.
|
||||
*/
|
||||
async function onCityChange(value: string | undefined) {
|
||||
if (!value) return;
|
||||
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
|
||||
if (!Number(percent)) {
|
||||
try {
|
||||
const { data } = await api.get(`cities/get-percent-aux/${value}`);
|
||||
percent = data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
|
||||
@@ -32,17 +32,7 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
v-model:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="انتخاب کنید"
|
||||
:filter-option="filterOption"
|
||||
>
|
||||
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
@@ -57,13 +47,14 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = route.params.id as string | undefined;
|
||||
const loading = ref(false);
|
||||
const cities = ref<{ id: string; title: string }[]>([]);
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
fname: '',
|
||||
@@ -74,10 +65,6 @@ const form = reactive<Record<string, any>>({
|
||||
const err = reactive<Record<string, string>>({});
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
function filterOption(input: string, option: any) {
|
||||
return String(option?.children?.[0]?.children ?? '').includes(input);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
Object.keys(err).forEach((k) => delete err[k]);
|
||||
if (!form.fname) err.fname = REQUIRED;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Ported from legacy henza_admin/src/views/city/CityListModel.js
|
||||
//
|
||||
// Note the 'level' column: legacy declares it but the cities payload has no such
|
||||
// field, so the cell always rendered blank. Preserved — see the port plan.
|
||||
// Legacy's 'level' column always rendered blank (the payload had no such
|
||||
// field); it is the parent city here, now that cities are a tree (PERS-02).
|
||||
import XTbl, { Xtc } from '../../components/x-table/model';
|
||||
|
||||
export function buildCityListModel(): XTbl {
|
||||
const tbl = new XTbl('admin/misc/cities/list', 'لیست شهر ها');
|
||||
tbl.add(new Xtc('title', 'نام شهر').filterKey('name').filterText());
|
||||
tbl.add(new Xtc('level', 'سطح').filterNumber().noSort());
|
||||
tbl.add(new Xtc('parent_title', 'والد').renderSlot('parent_title').noSort());
|
||||
tbl.add(new Xtc('percent', 'درصد مشتری عمده').renderSlot('percent').noSort());
|
||||
tbl.add(new Xtc('percent_aux', 'درصد کارشناس فروش').renderSlot('percent_aux').noSort());
|
||||
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/city/cityList.vue.
|
||||
The two percent columns are edited in a modal that legacy loads through two
|
||||
separate GETs (get-percent / get-percent-aux) before opening.
|
||||
The edit modal shows the city's OWN percents. Legacy filled it from
|
||||
get-percent / get-percent-aux, which return the parent's percent when the
|
||||
city has none — so saving silently copied the parent's onto the child.
|
||||
-->
|
||||
<div id="page-user-list">
|
||||
<a-card title="لیست شهر ها">
|
||||
@@ -11,6 +12,10 @@
|
||||
<ListPageHeader rid="cities.create" add-label="افزودن شهر" @add="addOpen = true" />
|
||||
</template>
|
||||
|
||||
<template #parent_title="{ text }">
|
||||
{{ text ?? '—' }}
|
||||
</template>
|
||||
|
||||
<template #percent="{ text }">
|
||||
{{ text ?? '-' }}
|
||||
</template>
|
||||
@@ -43,6 +48,15 @@
|
||||
<a-form-item label="نام شهر">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect
|
||||
v-model:value="form.parent"
|
||||
:cities="cities"
|
||||
parent-picker
|
||||
:exclude-id="tempRow?.id"
|
||||
placeholder="والد را انتخاب کنید"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
|
||||
</a-form-item>
|
||||
@@ -68,6 +82,7 @@ import { buildCityListModel } from './CityListModel';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { useDeleteHandler } from '../../composables/useDeleteHandler';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
@@ -77,26 +92,20 @@ const citiesTable = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const addOpen = ref(false);
|
||||
const visible = ref(false);
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const form = reactive<Record<string, any>>({ title: '', percent: null, percent_aux: null });
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
/**
|
||||
* legacy edit(): fetches the two percents separately before opening the modal.
|
||||
* Both are already on the row here, but the legacy calls are kept so the two
|
||||
* endpoints stay exercised and the values are always current.
|
||||
*/
|
||||
async function edit(row: Record<string, any>) {
|
||||
tempRow.value = row;
|
||||
form.title = row.title ?? '';
|
||||
form.parent = row.parent ?? undefined;
|
||||
form.percent = row.percent === null || row.percent === undefined ? null : Number(row.percent);
|
||||
form.percent_aux = row.percent_aux === null || row.percent_aux === undefined ? null : Number(row.percent_aux);
|
||||
try {
|
||||
const [p, pa] = await Promise.all([
|
||||
api.get(`cities/get-percent/${row.id}`),
|
||||
api.get(`cities/get-percent-aux/${row.id}`),
|
||||
]);
|
||||
form.percent = p.data === null ? null : Number(p.data);
|
||||
form.percent_aux = pa.data === null ? null : Number(pa.data);
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
form.percent = row.percent === null ? null : Number(row.percent);
|
||||
form.percent_aux = row.percent_aux === null ? null : Number(row.percent_aux);
|
||||
console.log(e);
|
||||
}
|
||||
visible.value = true;
|
||||
}
|
||||
@@ -105,6 +114,7 @@ async function save() {
|
||||
try {
|
||||
await api.put(`cities/${tempRow.value?.id}`, {
|
||||
title: form.title,
|
||||
parent: form.parent ?? null,
|
||||
percent: form.percent,
|
||||
percent_aux: form.percent_aux,
|
||||
});
|
||||
|
||||
@@ -22,8 +22,14 @@
|
||||
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="استان">
|
||||
<a-input v-model:value="form.province" />
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</BaseFormModal>
|
||||
@@ -36,8 +42,14 @@
|
||||
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="استان">
|
||||
<a-input v-model:value="form.province" />
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
|
||||
@@ -47,11 +59,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import BaseFormModal from '../../components/common/BaseFormModal.vue';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Present -> rendered as a modal by the parent list page. */
|
||||
@@ -71,7 +84,20 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const form = reactive<Record<string, any>>({ title: '', province: '' });
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
async function loadCities() {
|
||||
try {
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
// Fresh each time the modal opens, so a city added a moment ago can be a parent.
|
||||
if (asModal) watch(() => props.open, (o) => o && loadCities(), { immediate: true });
|
||||
else onMounted(loadCities);
|
||||
const err = reactive<Record<string, string>>({});
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -82,9 +108,11 @@ async function handleSubmit() {
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
await api.post('cities', form);
|
||||
await api.post('cities', { ...form, parent: form.parent ?? null });
|
||||
message.info('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (asModal) {
|
||||
// Keep the parent: cities are usually added several under the same one.
|
||||
Object.assign(form, { title: '', percent: null, percent_aux: null });
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -125,7 +125,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
|---|---|---|---|---|---|
|
||||
| PRICE-01 | ⏸️ out of scope (was Critical) | TGT `apps/api/src/modules/admin-products/admin-products.service.ts:188-208`; `apps/admin-panel/src/views/apps/product/ProductList.vue` | **No product price is computed or shown anywhere in the admin panel.** The reference appends `price` and `price_with_discount` to every variant (`ProductVariant.php:115-119`), sets the live gold rate before listing (`AdminController.php:97-98`), and renders `{{ item.price }}` in three places in its product list (`ProductList.vue:283,287,301`). The target's `variantRow()` returns neither field, nothing fetches a gold price for the admin list, and `computeVariantPrice()` — ported and unit-tested today — is called from nowhere. | `grep computeVariantPrice src/ --include=*.ts` matches only its own file and spec. | ⏸️ **Stopped by D16** — price display is retail-storefront, out of scope. `variant-price.ts` and its 11 tests are KEPT but unused. Nothing needed reverting: the function was never called. Making-charge fields stay in scope. |
|
||||
| PRICE-02 | Info | REF `app/Models/ProductVariant.php:163` vs TGT `admin-products.service.ts:206-208` | `rial_pay` (a flat rial charge per variant) appears only in the **superseded, commented-out** formula, not the live one at `:183-190`. The target returns null rather than faking it, which matches the live reference. Recorded so nobody "restores" it from the dead code. | Read both. | ✅ No action — matches. |
|
||||
| PERM-01 | Medium | TGT `prisma/seed.ts`; `apps/admin-panel/src/views/user/permissionConst.ts` | `dashboard.omde` and `dashboard.tak` are seeded into the `permission` table but are **absent from the permission UI tree**, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses `dashboard.view`). The DB holds **178** distinct rids against the UI's **174**. | `SELECT rid FROM permission GROUP BY rid` → 178, including all three dashboard rids. | Open — the fix removes permission rows, which is a data deletion and needs the owner's approval first. |
|
||||
| PERM-01 | Medium | TGT `prisma/seed.ts`; `apps/admin-panel/src/views/user/permissionConst.ts` | `dashboard.omde` and `dashboard.tak` are seeded into the `permission` table but are **absent from the permission UI tree**, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses `dashboard.view`). The DB holds **178** distinct rids against the UI's **174**. | `SELECT rid FROM permission GROUP BY rid` → 178, including all three dashboard rids. | ✅ **FIXED 2026-10-08** (owner approved the deletion). Migration `20261008160000_dashboard_permission_merge` gives `dashboard.view` to every type holding `dashboard` / `dashboard.omde` / `dashboard.tak`, then deletes those rows. **Also found:** the import dropped these three as unknown rids, so legacy types 102, 104, 105, 108 and 135 lost the dashboard — `people.ts` RENAMED_RIDS now maps them to `dashboard.view`. The other 45 legacy rids the target lacks are tree parents or rids no legacy code reads. |
|
||||
|
||||
## Delivery-mode items (D19/D20, 2026-09-22)
|
||||
|
||||
@@ -134,7 +134,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. |
|
||||
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. |
|
||||
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
|
||||
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | Structural change; decide with the owner during the cities screen (Phase 1c). |
|
||||
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | ✅ **FIXED 2026-10-08** (owner: as legacy). `City.parentId` (self relation, Restrict) replaces `province`; migration `20261008170000_city_tree` turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (`CityTreeSelect.vue`: top level with children not selectable); city form/grid have «والد». **Also restored:** legacy `_getP` — a city without its own percent takes its ancestor's (`getCityPercent`); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused. |
|
||||
| **PERS-03** | Low — **column added 2026-10-07 (`Customer.birthDate`, 541 legacy dates imported); form field still missing** | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. |
|
||||
| **PERF-01** | Low | TGT `admin-customers.service.ts` `listReagents()` | The per-agent gold-sold total runs **one `order.aggregate` per row** inside `Promise.all` — an N+1 on every page of the sales-expert grid. Harmless at the current agent count. | `listReagents()` `rows.map(async ...)` | Replace with a single `groupBy` over the referring agent. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user