feat: add CategoryImportController for bulk JSON import and export of categories

- Implemented export functionality to retrieve all rows from specified category tables.
- Developed import functionality with strict validation and referential integrity checks.
- Added error handling for various import scenarios including invalid formats and duplicate entries.
- Introduced tests for import functionality to ensure correct behavior and validation.
This commit is contained in:
hamed
2026-06-30 21:51:06 +03:30
parent 803196108c
commit 22937dfa56
22 changed files with 2473 additions and 835 deletions
+104 -9
View File
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
PhotoIcon, XMarkIcon, ArrowDownTrayIcon,
PhotoIcon, XMarkIcon, ArrowDownTrayIcon, ArrowUpTrayIcon, ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -21,6 +21,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
interface ImportError { row?: number; field?: string; message: string }
interface TabConfig {
key: TabKey;
label: string;
@@ -131,14 +133,21 @@ async function exportJson(url: string, filename: string, token: string | null) {
// ── Tab sub-component wrapper ──────────────────────────────────────────────────
function TabActions({ label, onClick, exportUrl, exportFile }: {
function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel, onImported }: {
label: string;
onClick: () => void;
exportUrl: string;
exportFile: string;
bundle: TabKey;
entityLabel: string;
onImported: () => void;
}) {
const token = useAuthStore((s) => s.token);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [errors, setErrors] = useState<ImportError[] | null>(null);
const [pendingItems, setPendingItems] = useState<unknown[] | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
setExporting(true);
@@ -151,8 +160,62 @@ function TabActions({ label, onClick, exportUrl, exportFile }: {
}
};
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
let parsed: unknown;
try {
parsed = JSON.parse(await file.text());
} catch {
setErrors([{ message: 'فایل یک JSON معتبر نیست' }]);
return;
}
let items: unknown[] | null = null;
if (Array.isArray(parsed)) items = parsed;
else if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { items?: unknown[] }).items)) {
items = (parsed as { items: unknown[] }).items;
}
if (!items || items.length === 0) {
setErrors([{ message: 'فایل باید یک آرایه‌ی غیرخالی از رکوردها باشد' }]);
return;
}
setPendingItems(items);
};
const doImport = async () => {
if (!pendingItems) return;
setImporting(true);
try {
const res = await fetch(`/api/v1/admin/categories/${bundle}/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(pendingItems),
});
const json = await res.json().catch(() => ({}));
if (res.ok && json.success) {
toast.success(`${json.data?.imported ?? 0} رکورد با موفقیت وارد شد`);
setPendingItems(null);
onImported();
} else {
setErrors(json.errors?.length ? json.errors : [{ message: json.errors?.[0]?.message ?? 'خطا در ورود اطلاعات' }]);
setPendingItems(null);
}
} catch {
setErrors([{ message: 'خطا در ارتباط با سرور' }]);
setPendingItems(null);
} finally {
setImporting(false);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}>
<input ref={fileRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} />
<button onClick={() => fileRef.current?.click()} className="btn ghost sm">
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
ورود JSON
</button>
<button onClick={handleExport} disabled={exporting} className="btn ghost sm">
<ArrowDownTrayIcon style={{ width: 15, height: 15 }} />
{exporting ? 'در حال دانلود...' : 'خروجی JSON'}
@@ -160,6 +223,38 @@ function TabActions({ label, onClick, exportUrl, exportFile }: {
<button onClick={onClick} className="btn primary sm">
<PlusIcon style={{ width: 15, height: 15 }} /> {label}
</button>
<ConfirmDialog
open={!!pendingItems}
title={`ورود ${entityLabel} از فایل`}
message={`این عملیات همه‌ی رکوردهای فعلی این بخش را حذف و با ${pendingItems?.length ?? 0} رکورد فایل جایگزین می‌کند. این کار بازگشت‌ناپذیر است. ادامه می‌دهید؟`}
confirmLabel="حذف و جایگزینی"
danger
loading={importing}
onConfirm={doImport}
onCancel={() => setPendingItems(null)}
/>
<Modal
open={!!errors}
title="فایل وارد نشد — خطاهای اعتبارسنجی"
size="md"
onClose={() => setErrors(null)}
footer={<button onClick={() => setErrors(null)} className="btn ghost sm">بستن</button>}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--danger)' }}>
<ExclamationTriangleIcon style={{ width: 18, height: 18, flexShrink: 0 }} />
<span style={{ fontSize: 13 }}>به دلیل خطاهای زیر هیچ تغییری اعمال نشد. فایل را اصلاح و دوباره تلاش کنید.</span>
</div>
<div style={{ maxHeight: 360, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{(errors ?? []).map((er, idx) => (
<div key={idx} style={{ fontSize: 12, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2)', display: 'flex', gap: 8 }}>
{er.field && <span className="chip" style={{ flexShrink: 0 }}>{er.field}</span>}
<span style={{ color: 'var(--text-2)' }}>{er.message}</span>
</div>
))}
</div>
</Modal>
</div>
);
}
@@ -228,7 +323,7 @@ function ProvincesTab() {
return (
<>
<TabActions label="افزودن استان" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/provinces?limit=9999" exportFile="state.json" />
<TabActions label="افزودن استان" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/provinces/export" exportFile="state.json" bundle="provinces" entityLabel="استان‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-provinces'] })} />
<DataTable<Province> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در استان‌ها..." emptyMessage="هیچ استانی یافت نشد"
actions={(p) => (
<>
@@ -388,7 +483,7 @@ function CitiesTab() {
return (
<>
<TabActions label="افزودن شهر" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/cities?limit=9999" exportFile="city.json" />
<TabActions label="افزودن شهر" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/cities/export" exportFile="city.json" bundle="cities" entityLabel="شهرها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-cities'] })} />
<DataTable<City> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد"
actions={(c) => (
<>
@@ -548,7 +643,7 @@ function SpecialtiesTab() {
return (
<>
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/specialties?limit=9999" exportFile="specialties.json" />
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/specialties/export" exportFile="specialties.json" bundle="specialties" entityLabel="تخصص‌ها" onImported={() => { qc.invalidateQueries({ queryKey: ['admin-specialties'] }); qc.invalidateQueries({ queryKey: ['admin-specialties-roots'] }); }} />
<DataTable<SpecialtyFull> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصص‌ها..." emptyMessage="هیچ تخصصی یافت نشد"
actions={(s) => (
<>
@@ -669,7 +764,7 @@ function DoctorServicesTab() {
return (
<>
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/doctor-services?limit=9999" exportFile="doctor-services.json" />
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/doctor_services/export" exportFile="doctor-services.json" bundle="doctor_services" entityLabel="خدمات پزشک" onImported={() => qc.invalidateQueries({ queryKey: ['admin-doctor-services'] })} />
<DataTable<DoctorService> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد"
actions={(s) => (
<>
@@ -795,7 +890,7 @@ function InsurancesTab() {
return (
<>
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} exportUrl="/api/v1/admin/insurances?limit=9999" exportFile="insurances.json" />
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/insurances/export" exportFile="insurances.json" bundle="insurances" entityLabel="بیمه‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-insurances'] })} />
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمه‌ها..." emptyMessage="هیچ بیمه‌ای یافت نشد"
actions={(i) => (
<>
@@ -909,7 +1004,7 @@ function TagsTab() {
return (
<>
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/tags?limit=9999" exportFile="tags.json" />
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/tags/export" exportFile="tags.json" bundle="tags" entityLabel="تگ‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-tags'] })} />
<DataTable<Tag> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگ‌ها..." emptyMessage="هیچ تگی یافت نشد"
actions={(t) => (
<>
+113
View File
@@ -0,0 +1,113 @@
# Category Import / Export API
> **Prefix:** `/api/v1/admin/categories/{bundle}`
Bulk JSON export and import for the admin **«دسته‌بندی‌ها»** page (`CategoriesPage`),
backed by `App\Category\Controller\CategoryImportController`.
`{bundle}` is one of: `provinces`, `cities`, `specialties`, `doctor_services`,
`insurances`, `tags`.
**Permission:** `ROLE_ADMIN` (whole controller).
Each bundle maps to one table:
| bundle | table | has `slug` | has `weight` | references |
|---|---|:---:|:---:|---|
| `provinces` | `provinces` | ❌ | ✅ | — |
| `cities` | `cities` | ❌ | ✅ | `province_id` → provinces, `representation_id` → representations |
| `specialties` | `specialties` | ✅ | ✅ | `parent_id` → specialties (same file) |
| `doctor_services` | `doctor_services` | ✅ | ✅ | `specialty_id` → specialties |
| `insurances` | `insurances` | ❌ | ❌ | `type` ∈ {`basic`,`supplementary`} |
| `tags` | `tags` | ✅ | ❌ | — |
---
## GET `/api/v1/admin/categories/{bundle}/export`
Complete export of a bundle — **every** row, no pagination cap. Returns the raw
table columns (the same keys an import expects), so export → import is lossless.
> ⚠️ Do **not** use the paginated admin list endpoints (e.g. `/api/v1/admin/specialties?limit=9999`)
> as an export source for import: those cap at 100 rows, and feeding a truncated
> file into the wipe+replace import below deletes every row beyond the first 100.
### Response `200`
```json
{
"success": true,
"data": [
{ "id": 1, "uuid": "…", "name": "قلب", "slug": "ghalb", "status": 1, "weight": 0, "parent_id": null }
]
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_BUNDLE_UNKNOWN` | 404 | Unknown bundle |
---
## POST `/api/v1/admin/categories/{bundle}/import`
**Wipe + full replace** of the bundle's table from the uploaded JSON, inside a
single transaction (FK checks disabled during the swap). Raw `id` values from the
file are preserved so relations stay intact.
Validation is **strict and two-phase**: the whole file is validated first; if
**any** row is invalid, nothing is written and every error is returned. Unknown
extra keys (e.g. `province_name` produced by export) are ignored.
### Request Body (`application/json`)
A non-empty array of records, or `{ "items": [ … ] }`. Max **5000** rows.
```json
[
{ "id": 1, "name": "قلب", "slug": "ghalb", "status": 1, "weight": 0, "parent_id": null },
{ "id": 2, "name": "آریتمی", "slug": "arr", "status": 1, "weight": 1, "parent_id": 1 }
]
```
### Per-row validation rules
| Field | Rule |
|---|---|
| `id` | required, positive integer, unique within the file |
| `name` | required, non-empty string ≤ 255 |
| `status` | `0` or `1` (default `1`) |
| `weight` | integer ≥ 0 — bundles with a weight column only |
| `slug` | required, ≤ 255, unique within file — `specialties`/`doctor_services`/`tags` |
| `type` | `basic` or `supplementary``insurances` only |
| `uuid` | optional; generated if missing |
| `parent_id` | `specialties`: null, or an `id` present **in the same file** |
| `specialty_id` | `doctor_services`: null, or an existing `specialties.id` |
| `province_id` / `representation_id` | `cities`: null, or an existing id in the referenced table |
### Response `200`
```json
{ "success": true, "data": { "imported": 128 } }
```
### Response `422` (validation failed — nothing written)
```json
{
"success": false,
"data": null,
"errors": [
{ "row": 2, "field": "parent_id", "message": "ردیف 2: والد با شناسه 999 در همین فایل وجود ندارد" }
]
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_BUNDLE_UNKNOWN` | 404 | Unknown bundle |
| `ERR_IMPORT_FORMAT` | 422 | Body is not a non-empty array of records |
| `ERR_IMPORT_TOO_LARGE` | 422 | More than 5000 rows |
| (field errors) | 422 | One entry per failed row/field; no rows written |
| `ERR_IMPORT_FAILED` | 500 | DB error during replace (transaction rolled back) |
+7
View File
@@ -144,3 +144,10 @@ Delete a doctor service.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Service not found |
---
## Bulk import / export
Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
+7
View File
@@ -456,3 +456,10 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` سرویس یافت نشد · `403 ERR_FORBIDDEN_001` سرویس متعلق به شما نیست.
> منطق resolve: `TenantInsuranceService::coverageRuleForService()` ابتدا **پرچم `ServiceItem.insurance_covered`** را چک می‌کند؛ اگر این خدمت «شامل بیمه» نباشد، بدون توجه به override یا قرارداد، `CoverageRule::notCovered()` برمی‌گردد (gate نهایی). سپس override خدمت بررسی می‌شود؛ اگر `covered=false` → `notCovered()`؛ در غیر این صورت فیلدهای null از قرارداد پر می‌شوند. این `CoverageRule` ورودی `BillingCalculator` است و سهم بیمه‌ی هر `InvoiceItem` را تعیین می‌کند؛ همان سهم‌ها در `ClaimService::createFromInvoice()` به `ClaimItem` (مطالبات بیمه) تبدیل می‌شوند. پنل ادمین این endpoint را از مودال «پوشش بیمه» در صفحه سرویس‌های کلینیک فراخوانی می‌کند.
---
## Bulk import / export
Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
+7
View File
@@ -247,3 +247,10 @@ Legacy endpoint that proxies to the new endpoints.
> ⚠️ Response is **triple-nested**: `data?.data?.data ?? []`
> Note: `categorys` (not `categories`) is intentional — legacy route name.
---
## Bulk import / export
Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
+7
View File
@@ -193,3 +193,10 @@ Delete a specialty.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Specialty not found |
---
## Bulk import / export
Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
+7
View File
@@ -130,3 +130,10 @@ Delete a tag.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Tag not found |
---
## Bulk import / export
Full-table JSON export and strict wipe+replace import for this category live under
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
+1 -13
View File
@@ -653,27 +653,15 @@
"651": "Community 651",
"652": "Community 652",
"653": "Community 653",
"654": "Community 654",
"655": "Community 655",
"656": "Community 656",
"657": "Community 657",
"658": "Community 658",
"659": "Community 659",
"660": "Community 660",
"661": "Community 661",
"662": "Community 662",
"663": "Community 663",
"664": "Community 664",
"665": "Community 665",
"666": "Community 666",
"667": "Community 667",
"668": "Community 668",
"669": "Community 669",
"670": "Community 670",
"671": "Community 671",
"672": "Community 672",
"673": "Community 673",
"674": "Community 674",
"675": "Community 675",
"676": "Community 676"
"672": "Community 672"
}
+103 -151
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-06-29)
# Graph Report - clinicpro (2026-06-30)
## Corpus Check
- 654 files · ~450,645 words
- 657 files · ~453,738 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 8274 nodes · 11437 edges · 677 communities (549 shown, 128 thin omitted)
- 8314 nodes · 11496 edges · 665 communities (540 shown, 125 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 264 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `830f7e8d`
- Built from commit: `80319610`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -655,32 +655,20 @@
- [[_COMMUNITY_Community 651|Community 651]]
- [[_COMMUNITY_Community 652|Community 652]]
- [[_COMMUNITY_Community 653|Community 653]]
- [[_COMMUNITY_Community 654|Community 654]]
- [[_COMMUNITY_Community 655|Community 655]]
- [[_COMMUNITY_Community 656|Community 656]]
- [[_COMMUNITY_Community 657|Community 657]]
- [[_COMMUNITY_Community 658|Community 658]]
- [[_COMMUNITY_Community 659|Community 659]]
- [[_COMMUNITY_Community 660|Community 660]]
- [[_COMMUNITY_Community 661|Community 661]]
- [[_COMMUNITY_Community 662|Community 662]]
- [[_COMMUNITY_Community 663|Community 663]]
- [[_COMMUNITY_Community 664|Community 664]]
- [[_COMMUNITY_Community 665|Community 665]]
- [[_COMMUNITY_Community 666|Community 666]]
- [[_COMMUNITY_Community 667|Community 667]]
- [[_COMMUNITY_Community 668|Community 668]]
- [[_COMMUNITY_Community 669|Community 669]]
- [[_COMMUNITY_Community 671|Community 671]]
- [[_COMMUNITY_Community 672|Community 672]]
- [[_COMMUNITY_Community 673|Community 673]]
- [[_COMMUNITY_Community 674|Community 674]]
- [[_COMMUNITY_Community 675|Community 675]]
- [[_COMMUNITY_Community 676|Community 676]]
## God Nodes (most connected - your core abstractions)
1. `BaseController` - 74 edges
2. `ApiTestCase` - 70 edges
1. `BaseController` - 76 edges
2. `ApiTestCase` - 72 edges
3. `api` - 55 edges
4. `UserProfile` - 52 edges
5. `Clinic` - 50 edges
@@ -693,55 +681,55 @@
## Surprising Connections (you probably didn't know these)
- `ServiceTariffModal()` --calls--> `formatNumber()` [EXTRACTED]
assets/admin/components/ServiceTariffModal.tsx → assets/admin/lib/utils.ts
- `Pagination()` --calls--> `formatNumber()` [EXTRACTED]
assets/admin/components/ui/Pagination.tsx → assets/admin/lib/utils.ts
- `SettlementsPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/SettlementsPage.tsx → assets/admin/lib/utils.ts
- `LogsPage()` --calls--> `formatDateTime()` [EXTRACTED]
assets/admin/pages/LogsPage.tsx → assets/admin/lib/utils.ts
- `PersianDatePicker()` --calls--> `formatDate()` [EXTRACTED]
assets/admin/components/ui/PersianDatePicker.tsx → assets/admin/lib/utils.ts
- `SmsPage()` --calls--> `formatDateTime()` [EXTRACTED]
assets/admin/pages/SmsPage.tsx → assets/admin/lib/utils.ts
- `PrivateRoute()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/App.tsx → assets/admin/stores/authStore.ts
- `PublicRoute()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/App.tsx → assets/admin/stores/authStore.ts
## Import Cycles
- None detected.
## Communities (677 total, 128 thin omitted)
## Communities (665 total, 125 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
Nodes (43): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, AddForm, addSchema (+35 more)
Cohesion: 0.04
Nodes (46): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, NewAppointmentModal(), LogoUploadField() (+38 more)
### Community 1 - "Community 1"
Cohesion: 0.03
Nodes (41): AddressData, AddrForm, addrSchema, AVATAR_COLORS, BookingMeta, CityOpt, DateOverrideData, DEFAULT_BOOKING_META (+33 more)
### Community 2 - "Community 2"
Cohesion: 0.06
Nodes (29): Contract, InsuranceOption, KIND_LABEL, get, api, ApiError, getToken(), refreshOnce() (+21 more)
Cohesion: 0.10
Nodes (19): get, BeforeInstallPromptEvent, usePwaInstall(), api, ApiError, getToken(), refreshOnce(), request() (+11 more)
### Community 3 - "Community 3"
Cohesion: 0.05
Nodes (42): ACTION_HEADERS, ALL_ACTIONS, ClinicDoctor, CreateForm, createSchema, DEFAULT_PERMISSIONS, PERMISSION_LABELS, PermSection (+34 more)
Nodes (43): PaginatedResponse, formatDate(), STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary, STATUS_FILTERS (+35 more)
### Community 4 - "Community 4"
Cohesion: 0.08
Nodes (5): Doctor, Collection, self, User, WeeklySchedule
Cohesion: 0.05
Nodes (11): DoctorServiceController, DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule (+3 more)
### Community 5 - "Community 5"
Cohesion: 0.07
Nodes (3): UserProfile, self, User
### Community 6 - "Community 6"
Cohesion: 0.12
Nodes (12): SettlementController, SettlementRepository, WalletTransactionRepository, Settlement, JsonResponse, Request, User, ManagerRegistry (+4 more)
Cohesion: 0.17
Nodes (8): SettlementController, SettlementRepository, Settlement, JsonResponse, Request, User, ManagerRegistry, User
### Community 7 - "Community 7"
Cohesion: 0.07
Nodes (5): Clinic, Collection, Doctor, self, User
### Community 8 - "Community 8"
Cohesion: 0.07
Nodes (28): useSubscription(), AdminLayout(), avatarBg(), buildSections(), HUES, Props, ROLE_LABELS, Section (+20 more)
Cohesion: 0.08
Nodes (30): useSubscription(), AdminLayout(), avatarBg(), buildSections(), HUES, Props, ROLE_LABELS, Section (+22 more)
### Community 9 - "Community 9"
Cohesion: 0.04
@@ -752,16 +740,16 @@ Cohesion: 0.04
Nodes (46): Clinic Address Management, Clinic API, DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`, `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`, Errors, Errors, Errors, Errors (+38 more)
### Community 11 - "Community 11"
Cohesion: 0.08
Nodes (21): PaginatedResponse, STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary, LEVEL_FILTER_OPTIONS, LEVEL_META (+13 more)
Cohesion: 0.06
Nodes (35): FreeVisitPrice(), Pricing, usePaymentConfig(), ApiResponse, formatRial(), InsurancePricingPage(), FinancialSummary, MonthlyEntry (+27 more)
### Community 12 - "Community 12"
Cohesion: 0.05
Nodes (44): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+36 more)
### Community 13 - "Community 13"
Cohesion: 0.06
Nodes (37): cn(), formatDate(), formatDateTime(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput() (+29 more)
Cohesion: 0.08
Nodes (28): cn(), formatDateTime(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput(), toDate() (+20 more)
### Community 14 - "Community 14"
Cohesion: 0.10
@@ -772,8 +760,8 @@ Cohesion: 0.10
Nodes (13): PaymentController, MockGateway, SepGateway, JsonResponse, Payment, PaymentGatewayInterface, Request, Response (+5 more)
### Community 16 - "Community 16"
Cohesion: 0.07
Nodes (7): PatientSession, SmsWallet, Appointment, Collection, PatientRecord, self, SessionService
Cohesion: 0.09
Nodes (6): PatientSession, Appointment, Collection, PatientRecord, self, SessionService
### Community 17 - "Community 17"
Cohesion: 0.05
@@ -784,20 +772,20 @@ Cohesion: 0.05
Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
### Community 19 - "Community 19"
Cohesion: 0.20
Nodes (8): AdminUser, ChangeRoleModal(), getPrimaryRole(), HUES_LIST, ROLE_META, ROLE_TABS, RoleBadge(), UserStats
Cohesion: 0.07
Nodes (21): Contract, InsuranceOption, KIND_LABEL, ALL_STATUSES, AppointmentDetailPage(), FormData, schema, AdminUser (+13 more)
### Community 20 - "Community 20"
Cohesion: 0.15
Nodes (3): AdminApiController, JsonResponse, Request
### Community 21 - "Community 21"
Cohesion: 0.04
Nodes (49): FreeVisitPrice(), Pricing, formatNumber(), formatRial(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent (+41 more)
Cohesion: 0.05
Nodes (34): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+26 more)
### Community 22 - "Community 22"
Cohesion: 0.11
Nodes (14): RatingController, Like, Rate, LikeRepository, RateRepository, JsonResponse, Request, User (+6 more)
Cohesion: 0.15
Nodes (9): RatingController, Like, LikeRepository, JsonResponse, Request, User, Comment, ManagerRegistry (+1 more)
### Community 23 - "Community 23"
Cohesion: 0.05
@@ -825,7 +813,7 @@ Nodes (4): Appointment, Doctor, self, User
### Community 29 - "Community 29"
Cohesion: 0.06
Nodes (33): DELETE `/api/v1/admin/city/{id}`, DELETE `/api/v1/admin/province/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/cities`, GET `/api/v1/admin/provinces` (+25 more)
Nodes (34): Bulk import / export, DELETE `/api/v1/admin/city/{id}`, DELETE `/api/v1/admin/province/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/cities` (+26 more)
### Community 30 - "Community 30"
Cohesion: 0.08
@@ -848,8 +836,8 @@ Cohesion: 0.06
Nodes (34): require, doctrine/doctrine-bundle, doctrine/doctrine-migrations-bundle, doctrine/orm, ext-ctype, ext-iconv, lexik/jwt-authentication-bundle, nelmio/api-doc-bundle (+26 more)
### Community 35 - "Community 35"
Cohesion: 0.07
Nodes (27): Errors, Errors, Errors, Errors, Errors, Errors, FinancialBreakdown (لاگ مالی), GET `/api/v1/settlement` (+19 more)
Cohesion: 0.06
Nodes (32): Errors, Errors, Errors, Errors, Errors, Errors, Errors, FinancialBreakdown (لاگ مالی) (+24 more)
### Community 36 - "Community 36"
Cohesion: 0.06
@@ -872,8 +860,8 @@ Cohesion: 0.09
Nodes (4): User, PasswordAuthenticatedUserInterface, self, UserInterface
### Community 41 - "Community 41"
Cohesion: 0.03
Nodes (56): usePaymentConfig(), CityForm, citySchema, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm, provinceSchema (+48 more)
Cohesion: 0.04
Nodes (41): CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, ProvinceForm, provinceSchema, ServiceForm (+33 more)
### Community 42 - "Community 42"
Cohesion: 0.07
@@ -904,8 +892,8 @@ Cohesion: 0.11
Nodes (4): Payment, Appointment, self, User
### Community 49 - "Community 49"
Cohesion: 0.09
Nodes (18): AppointmentsPage(), BookingSlot, CancelledBadge(), DateNavigator(), EMPTY_ARR, getPersianWeekDay(), navBtnSx, NewAppointmentModal() (+10 more)
Cohesion: 0.07
Nodes (20): AppointmentsPage(), BookingSlot, CancelledBadge(), DateNavigator(), EMPTY_ARR, getPersianWeekDay(), navBtnSx, SessionGroup (+12 more)
### Community 50 - "Community 50"
Cohesion: 0.07
@@ -957,7 +945,7 @@ Nodes (22): Billing API — صورتحساب (فاز ۴ سیستم صورتحس
### Community 62 - "Community 62"
Cohesion: 0.08
Nodes (23): DELETE `/api/v1/admin/specialty/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/specialties`, GET `/api/v1/specialties`, GET `/api/v1/specialties/doctor-counts` (+15 more)
Nodes (20): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope, Bulk import / export, DELETE `/api/v1/admin/specialty/{id}`, Errors (+12 more)
### Community 63 - "Community 63"
Cohesion: 0.22
@@ -971,6 +959,10 @@ Nodes (4): SmsWalletController, JsonResponse, Request, User
Cohesion: 0.08
Nodes (24): 2. کاربر (User), 4. 🔵 `POST` verify code, 5. 🔵 `POST` send code, 6. 🔵 `POST` register, 7. 🔴 `DELETE` delete user, 8. 🟡 `PATCH` patch, 9. 🟢 `GET` list secretary, Request Body (+16 more)
### Community 66 - "Community 66"
Cohesion: 0.07
Nodes (4): ClinicStaff, SmsSettings, self, self
### Community 67 - "Community 67"
Cohesion: 0.11
Nodes (4): Invoice, Collection, InvoiceItem, self
@@ -1007,6 +999,10 @@ Nodes (6): DoctorController, Doctor, DoctorAddress, JsonResponse, Request, User
Cohesion: 0.16
Nodes (5): DoctorSecretary, Clinic, Doctor, self, User
### Community 77 - "Community 77"
Cohesion: 0.10
Nodes (13): Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL, STATUS_FILTERS, STATUS_META, IbanItem (+5 more)
### Community 78 - "Community 78"
Cohesion: 0.12
Nodes (3): SubscriptionPeriod, self, SubscriptionPlan
@@ -1020,8 +1016,8 @@ Cohesion: 0.09
Nodes (22): dependencies, @ckeditor/ckeditor5-build-classic, @ckeditor/ckeditor5-react, @fontsource/vazirmatn, @heroicons/react, @hookform/resolvers, jalaali-js, leaflet (+14 more)
### Community 82 - "Community 82"
Cohesion: 0.06
Nodes (12): RepositoryClassMappingTest, KernelTestCase, LoggerInterface, DbLogger, KavehNegarProvider, RanginehProvider, ApiIrService, SmsService (+4 more)
Cohesion: 0.05
Nodes (14): CategoryImportTest, Connection, RepositoryClassMappingTest, KernelTestCase, LoggerInterface, DbLogger, KavehNegarProvider, RanginehProvider (+6 more)
### Community 83 - "Community 83"
Cohesion: 0.09
@@ -1036,8 +1032,8 @@ Cohesion: 0.07
Nodes (29): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, core-js, @hotwired/stimulus, jsdom (+21 more)
### Community 86 - "Community 86"
Cohesion: 0.06
Nodes (15): AppointmentExpiryServiceTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemDeleteCleanupTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, TenantInsuranceCleanupTest, KernelBrowser (+7 more)
Cohesion: 0.07
Nodes (13): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser, CommentPaginationTest (+5 more)
### Community 87 - "Community 87"
Cohesion: 0.10
@@ -1061,7 +1057,7 @@ Nodes (20): DELETE `/api/v1/admin/users/{uuid}`, Errors, Errors, GET `/api/v1/ad
### Community 92 - "Community 92"
Cohesion: 0.10
Nodes (19): DELETE `/api/v1/admin/doctor-service/{id}`, Doctor Service API, Errors, Errors, Errors, Errors, GET `/api/v1/admin/doctor-services`, GET `/api/v1/doctor-services` (+11 more)
Nodes (20): Bulk import / export, DELETE `/api/v1/admin/doctor-service/{id}`, Doctor Service API, Errors, Errors, Errors, Errors, GET `/api/v1/admin/doctor-services` (+12 more)
### Community 93 - "Community 93"
Cohesion: 0.10
@@ -1072,8 +1068,8 @@ Cohesion: 0.10
Nodes (20): 12. تنظیمات نوبت — تعطیلات, 76. 🔴 `DELETE` delete, 77. 🔵 `POST` post, 78. 🟡 `PATCH` patch, 79. 🔴 `DELETE` delete, 80. 🟢 `GET` get, Request Body, Request Body (+12 more)
### Community 95 - "Community 95"
Cohesion: 0.15
Nodes (4): ClinicSubscription, Payment, SubscriptionPeriod, SubscriptionPlan
Cohesion: 0.09
Nodes (7): ClinicSubscription, TenantInsuranceService, CoverageRule, TenantInsurance, Payment, SubscriptionPeriod, SubscriptionPlan
### Community 96 - "Community 96"
Cohesion: 0.18
@@ -1100,8 +1096,8 @@ Cohesion: 0.10
Nodes (19): Backend — endpoint اشتراک, Frontend — App.tsx, Frontend — authStore, Frontend — Sidebar, اعمال محدودیت‌های اشتراک در پنل ادمین (Frontend Gate), ترتیب اجرا, زمینه, فایل‌های مرتبط (+11 more)
### Community 102 - "Community 102"
Cohesion: 0.11
Nodes (18): DELETE `/api/v1/admin/tag/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/tags`, GET `/api/v1/tags`, PATCH `/api/v1/admin/tag/{id}` (+10 more)
Cohesion: 0.10
Nodes (19): Bulk import / export, DELETE `/api/v1/admin/tag/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/tags`, GET `/api/v1/tags` (+11 more)
### Community 103 - "Community 103"
Cohesion: 0.35
@@ -1332,8 +1328,8 @@ Cohesion: 0.13
Nodes (13): Architecture, Auth, Backend (PHP/Symfony), Backend — `src/`, Category / Bundle system, Commands, Database, First-time setup (+5 more)
### Community 163 - "Community 163"
Cohesion: 0.13
Nodes (14): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, license (+6 more)
Cohesion: 0.11
Nodes (18): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, extra (+10 more)
### Community 164 - "Community 164"
Cohesion: 0.29
@@ -1404,8 +1400,8 @@ Cohesion: 0.23
Nodes (3): WeeklySchedule, Doctor, self
### Community 182 - "Community 182"
Cohesion: 0.15
Nodes (11): emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema, PLAN_DISPLAY, PlanForm, planSchema (+3 more)
Cohesion: 0.13
Nodes (13): emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema, PLAN_DISPLAY, PlanForm, planSchema (+5 more)
### Community 183 - "Community 183"
Cohesion: 0.14
@@ -1569,7 +1565,7 @@ Nodes (6): AbstractAuthenticator, Passport, PasswordAuthenticator, Request, Resp
### Community 228 - "Community 228"
Cohesion: 0.17
Nodes (11): DELETE `/api/v1/admin/insurance/{id}`, DELETE `/api/v1/insurance/{id}`, EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه, GET `/api/v1/insurance/{id}`, GET `/api/v1/insurance-pricing`, Insurance API, Response `200`, Response `200` (+3 more)
Nodes (12): Bulk import / export, DELETE `/api/v1/admin/insurance/{id}`, DELETE `/api/v1/insurance/{id}`, EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه, GET `/api/v1/insurance/{id}`, GET `/api/v1/insurance-pricing`, Insurance API, Response `200` (+4 more)
### Community 229 - "Community 229"
Cohesion: 0.15
@@ -1632,8 +1628,8 @@ Cohesion: 0.32
Nodes (3): SubscriptionService, ClinicSubscription, Payment
### Community 245 - "Community 245"
Cohesion: 0.26
Nodes (3): TenantInsuranceService, CoverageRule, TenantInsurance
Cohesion: 0.13
Nodes (12): AdminUserDetail, AVATAR_COLORS, EditForm, editSchema, GENDER_LABELS, getPrimaryRole(), MARITAL_LABELS, MEDICAL_SECTIONS (+4 more)
### Community 246 - "Community 246"
Cohesion: 0.17
@@ -1740,8 +1736,8 @@ Cohesion: 0.18
Nodes (10): Endpoint های موجود که تغییر می‌کنند, GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX, GET /api/v1/dashboard/clinic, GET /api/v1/dashboard/doctor, تسک ۱۶: داشبورد هوشمند — چارت + فیلتر زمانی, توضیح, زمان تخمینی, فیلتر بازه زمانی (+2 more)
### Community 274 - "Community 274"
Cohesion: 0.28
Nodes (5): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope
Cohesion: 0.22
Nodes (3): CategoryImportController, JsonResponse, Request
### Community 275 - "Community 275"
Cohesion: 0.38
@@ -1856,8 +1852,8 @@ Cohesion: 0.12
Nodes (16): آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify با Docker Compose, زمینه, فایل‌های مرتبط, نکات مهم (محدودیت‌ها و edge caseها), هدف, وظایف, ۱. ساخت `Dockerfile` چندمرحله‌ای, ۱۰. ساخت راهنمای `docs/deploy/coolify.md` (+8 more)
### Community 304 - "Community 304"
Cohesion: 0.22
Nodes (9): 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 36. 🟢 `GET` get my rate, 6. کلینیک (Clinic), هدرهای اضافی, هدرهای اضافی, پاسخ‌ها, پاسخ‌ها (+1 more)
Cohesion: 0.04
Nodes (56): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 31. 🟡 `PATCH` patch, 33. 🔵 `POST` image_clinic, 34. 🔵 `POST` image logo, 36. 🟢 `GET` get my rate, 37. 🔵 `POST` post, 39. 🟡 `PATCH` Comment confirmation (+48 more)
### Community 306 - "Community 306"
Cohesion: 0.07
@@ -1908,8 +1904,8 @@ Cohesion: 0.36
Nodes (3): TariffRepository, ManagerRegistry, Tariff
### Community 318 - "Community 318"
Cohesion: 0.13
Nodes (7): EntityInsurancePricing, EntityInsurancePricingRepository, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, ManagerRegistry, TenantServiceCoverage
Cohesion: 0.15
Nodes (5): EntityInsurancePricing, TenantInsuranceCleanupTest, EntityInsurancePricingRepository, TenantInsuranceCleanupService, ManagerRegistry
### Community 319 - "Community 319"
Cohesion: 0.39
@@ -1984,8 +1980,8 @@ Cohesion: 0.39
Nodes (5): JsonContains, FunctionNode, Node, Parser, SqlWalker
### Community 340 - "Community 340"
Cohesion: 0.43
Nodes (5): BeforeInstallPromptEvent, usePwaInstall(), PwaInstallBanner(), detectIOS(), PwaLoginCard()
Cohesion: 0.24
Nodes (5): WalletTransactionRepository, WalletTransactionsPaginationTest, ManagerRegistry, User, WalletTransaction
### Community 341 - "Community 341"
Cohesion: 0.12
@@ -2020,8 +2016,8 @@ Cohesion: 0.39
Nodes (3): ProvinceRepository, ManagerRegistry, Province
### Community 349 - "Community 349"
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
Cohesion: 0.20
Nodes (10): Category Import / Export API, Errors, Errors, GET `/api/v1/admin/categories/{bundle}/export`, Per-row validation rules, POST `/api/v1/admin/categories/{bundle}/import`, Request Body (`application/json`), Response `200` (+2 more)
### Community 350 - "Community 350"
Cohesion: 0.43
@@ -2032,8 +2028,8 @@ Cohesion: 0.50
Nodes (4): UserActiveContextRepository, ManagerRegistry, User, UserActiveContext
### Community 352 - "Community 352"
Cohesion: 0.39
Nodes (3): DoctorService, DoctorServiceRepository, ManagerRegistry
Cohesion: 0.38
Nodes (5): Rate, RateRepository, Doctor, ManagerRegistry, User
### Community 354 - "Community 354"
Cohesion: 0.36
@@ -2203,10 +2199,6 @@ Nodes (6): ClaimAmountBoundsTest, ClaimsListNPlusOneTest, ClaimItem, Claim, Doct
Cohesion: 0.33
Nodes (4): initiate(), verify(), PaymentInitResult, PaymentVerifyResult
### Community 399 - "Community 399"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260619121047
### Community 405 - "Community 405"
Cohesion: 0.47
Nodes (3): CommissionService, Payment, Representation
@@ -2216,8 +2208,8 @@ Cohesion: 0.17
Nodes (11): دیپلوی ClinicPro روی Coolify (Docker Compose), رفع اشکال, مراحل دیپلوی, معماری دیپلوی, نکات عملیاتی, چند دامنه فرانت‌اند (مهم), ۱. ساخت Resource در Coolify, ۲. اختصاص دامنه (+3 more)
### Community 452 - "Community 452"
Cohesion: 0.07
Nodes (19): ApiResponse, ClinicAddress, ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST, INV_STATUS_MAP (+11 more)
Cohesion: 0.10
Nodes (12): ClinicAddress, ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST, INV_STATUS_MAP, IRAN_CENTER (+4 more)
### Community 456 - "Community 456"
Cohesion: 0.29
@@ -2331,10 +2323,6 @@ Nodes (5): Errors, GET `/api/v1/representation/dashboard/summary`, GET `/api/v1/
Cohesion: 0.40
Nodes (5): Admin Endpoints, GET /api/v1/admin/sms/settings/review, GET /api/v1/admin/sms/wallet-report, POST /api/v1/admin/sms/settings/{id}/approve, POST /api/v1/admin/sms/settings/{id}/reject
### Community 491 - "Community 491"
Cohesion: 0.40
Nodes (5): 31. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 492 - "Community 492"
Cohesion: 0.40
Nodes (5): 32. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
@@ -2347,10 +2335,6 @@ Nodes (9): Backend Audit Backlog — ClinicPro, ☐ CRITICAL, ✅ DONE (committe
Cohesion: 0.50
Nodes (4): Error Codes, POST `/api/v1/user/reset-password`, Request Body, Response `200`
### Community 495 - "Community 495"
Cohesion: 0.40
Nodes (5): 41. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 496 - "Community 496"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/send-code`, Request Body, Response `200`
@@ -2627,6 +2611,10 @@ Nodes (15): دیپلوی ClinicPro (Symfony) روی لیارا با Docker, زم
Cohesion: 0.50
Nodes (4): API های جدید که باید ساخته شوند, API های موجود (استفاده کن، تغییر نده), تغییر روی API موجود, وضعیت فعلی API (مهم — قبل از پیاده‌سازی بخوان)
### Community 583 - "Community 583"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609134112, Schema, Version20260628165710
### Community 584 - "Community 584"
Cohesion: 0.50
Nodes (3): Entity: UserProfile, ساختار فایل‌ها, معماری — تسک ۰۳: ماژول پروفایل کاربر
@@ -2685,7 +2673,7 @@ Nodes (3): POST `/api/v1/admin/sms/template/{uuid}/approve`, Request Body (`appl
### Community 602 - "Community 602"
Cohesion: 0.40
Nodes (5): Errors, Path Parameters, POST `/api/v1/settlement/{uuid}/approve`, Request Body (`application/json`), Response `200`
Nodes (5): Errors, PATCH `/api/v1/admin/specialty/{id}`, Path Parameters, Request Body (all optional), Response `200`
### Community 603 - "Community 603"
Cohesion: 0.67
@@ -2708,13 +2696,17 @@ Cohesion: 0.39
Nodes (3): ClinicStaffRepository, ClinicStaff, ManagerRegistry
### Community 608 - "Community 608"
Cohesion: 0.40
Nodes (5): 37. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/admin/specialty`, Request Body (`application/json`), Response `201`
### Community 612 - "Community 612"
Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.16
Nodes (5): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
### Community 631 - "Community 631"
Cohesion: 0.50
Nodes (4): DELETE `/api/v1/doctor/{uuid}`, Errors, Path Parameters, Response `200`
@@ -2779,26 +2771,6 @@ Nodes (4): GET `/api/v1/admin/pre-registrations`, POST `/api/v1/admin/pre-regist
Cohesion: 0.50
Nodes (4): 38. 🟢 `GET` Unapproved comments, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 654 - "Community 654"
Cohesion: 0.50
Nodes (4): 39. 🟡 `PATCH` Comment confirmation, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 655 - "Community 655"
Cohesion: 0.50
Nodes (4): 40. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 656 - "Community 656"
Cohesion: 0.50
Nodes (4): 44. 🟢 `GET` list comment, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 657 - "Community 657"
Cohesion: 0.50
Nodes (4): 45. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 658 - "Community 658"
Cohesion: 0.50
Nodes (4): 46. 🟡 `PATCH` patch, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 659 - "Community 659"
Cohesion: 0.50
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
@@ -2815,53 +2787,33 @@ Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظ
Cohesion: 0.50
Nodes (4): Application Logs, GET `/api/v1/admin/logs`, Query Parameters, Response `200`
### Community 664 - "Community 664"
Cohesion: 0.67
Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
### Community 665 - "Community 665"
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
### Community 667 - "Community 667"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/payments`, Payment Management, Query Parameters, Response `200`
### Community 671 - "Community 671"
Cohesion: 0.67
Nodes (3): 34. 🔵 `POST` image logo, هدرهای اضافی, پاسخ‌ها
### Community 672 - "Community 672"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/settlements`, Query Parameters, Response `200`, Settlement Management
### Community 674 - "Community 674"
Cohesion: 0.50
Nodes (4): extra, symfony, allow-contrib, require
### Community 676 - "Community 676"
Cohesion: 0.67
Nodes (3): 29. 🟢 `GET` clinic list 🆕, پارامترهای Query, پاسخ‌ها
## Knowledge Gaps
- **3550 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3545 more)
- **3563 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3558 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **128 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **125 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 300`, `Community 301`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 349`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.027) - this node is a cross-community bridge._
- **Why does `AppointmentRepository` connect `Community 119` to `Community 53`?**
_High betweenness centrality (0.022) - this node is a cross-community bridge._
- **Why does `Version20260614181657` connect `Community 431` to `Community 399`?**
- **Why does `BaseController` connect `Community 108` to `Community 4`, `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 274`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 300`, `Community 301`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.037) - this node is a cross-community bridge._
- **Why does `Version20260614181657` connect `Community 431` to `Community 583`?**
_High betweenness centrality (0.017) - this node is a cross-community bridge._
- **Why does `Version20260625150304` connect `Community 457` to `Community 583`?**
_High betweenness centrality (0.017) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `TenantInsurance` to the rest of the system?**
_3550 weakly-connected nodes found - possible documentation gaps or missing edges._
_3563 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.047107014848950336 - nodes in this community are weakly interconnected._
_Cohesion score 0.04471153846153846 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
_Cohesion score 0.028985507246376812 - nodes in this community are weakly interconnected._
- **Should `Community 2` be split into smaller, more focused modules?**
_Cohesion score 0.05656108597285068 - nodes in this community are weakly interconnected._
_Cohesion score 0.10037878787878787 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_api_category_import_md", "label": "category-import.md", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L1"}, {"id": "api_category_import_category_import_export_api", "label": "Category Import / Export API", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L1"}, {"id": "api_category_import_get_api_v1_admin_categories_bundle_export", "label": "GET `/api/v1/admin/categories/{bundle}/export`", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L26"}, {"id": "api_category_import_response_200", "label": "Response `200`", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L35"}, {"id": "api_category_import_errors", "label": "Errors", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L45"}, {"id": "api_category_import_post_api_v1_admin_categories_bundle_import", "label": "POST `/api/v1/admin/categories/{bundle}/import`", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L54"}, {"id": "api_category_import_request_body_application_json", "label": "Request Body (`application/json`)", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L64"}, {"id": "api_category_import_per_row_validation_rules", "label": "Per-row validation rules", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L74"}, {"id": "api_category_import_response_200_88", "label": "Response `200`", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L88"}, {"id": "api_category_import_response_422_validation_failed_nothing_written", "label": "Response `422` (validation failed \u2014 nothing written)", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L93"}, {"id": "api_category_import_errors_104", "label": "Errors", "file_type": "document", "source_file": "docs/api/category-import.md", "source_location": "L104"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_api_category_import_md", "target": "api_category_import_category_import_export_api", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L1", "weight": 1.0}, {"source": "api_category_import_category_import_export_api", "target": "api_category_import_get_api_v1_admin_categories_bundle_export", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L26", "weight": 1.0}, {"source": "api_category_import_get_api_v1_admin_categories_bundle_export", "target": "api_category_import_response_200", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L35", "weight": 1.0}, {"source": "api_category_import_get_api_v1_admin_categories_bundle_export", "target": "api_category_import_errors", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L45", "weight": 1.0}, {"source": "api_category_import_category_import_export_api", "target": "api_category_import_post_api_v1_admin_categories_bundle_import", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L54", "weight": 1.0}, {"source": "api_category_import_post_api_v1_admin_categories_bundle_import", "target": "api_category_import_request_body_application_json", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L64", "weight": 1.0}, {"source": "api_category_import_post_api_v1_admin_categories_bundle_import", "target": "api_category_import_per_row_validation_rules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L74", "weight": 1.0}, {"source": "api_category_import_post_api_v1_admin_categories_bundle_import", "target": "api_category_import_response_200_88", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L88", "weight": 1.0}, {"source": "api_category_import_post_api_v1_admin_categories_bundle_import", "target": "api_category_import_response_422_validation_failed_nothing_written", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L93", "weight": 1.0}, {"source": "api_category_import_post_api_v1_admin_categories_bundle_import", "target": "api_category_import_errors_104", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/api/category-import.md", "source_location": "L104", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1657 -649
View File
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -190,8 +190,8 @@
"semantic_hash": ""
},
"assets/admin/pages/CategoriesPage.tsx": {
"mtime": 1782042929.639692,
"ast_hash": "d6aa06ef7578f2601fede2a8c77dceb1",
"mtime": 1782838987.6521325,
"ast_hash": "e4e8f39e7e7a03b947ce2883ed595a37",
"semantic_hash": ""
},
"assets/admin/pages/ClaimsPage.tsx": {
@@ -2390,8 +2390,8 @@
"semantic_hash": ""
},
"docs/api/doctor-service.md": {
"mtime": 1781158861.7234077,
"ast_hash": "3f0d0549bcfcf6163d2762e85c3f3776",
"mtime": 1782839237.8086488,
"ast_hash": "3c3f4da25682c21bf769a2c170b9dc6f",
"semantic_hash": ""
},
"docs/api/doctor.md": {
@@ -2400,13 +2400,13 @@
"semantic_hash": ""
},
"docs/api/insurance.md": {
"mtime": 1782728407.109222,
"ast_hash": "05f4fa9a2628811066a8f2cb90b6db4d",
"mtime": 1782839237.8089066,
"ast_hash": "66c99e8593c6e48fbbb065610b626e68",
"semantic_hash": ""
},
"docs/api/location.md": {
"mtime": 1781158896.6071815,
"ast_hash": "428efca3871276f0e7db9de61c32768c",
"mtime": 1782839237.808474,
"ast_hash": "9db8856184fd38eed6f377ac425584fa",
"semantic_hash": ""
},
"docs/api/patient.md": {
@@ -2445,8 +2445,8 @@
"semantic_hash": ""
},
"docs/api/specialty.md": {
"mtime": 1781636247.0762212,
"ast_hash": "fdaed926f0d4c12f795071556c662070",
"mtime": 1782839237.8082483,
"ast_hash": "45c0b1034a9e6bd3ea4973796b5538c1",
"semantic_hash": ""
},
"docs/api/staff.md": {
@@ -2460,8 +2460,8 @@
"semantic_hash": ""
},
"docs/api/tag.md": {
"mtime": 1781158875.2883966,
"ast_hash": "b727bc06ebe9ef4a464192be385b38f8",
"mtime": 1782839237.8090699,
"ast_hash": "0f848b663c3d80081a71fb46b8088c87",
"semantic_hash": ""
},
"docs/api/user-profile.md": {
@@ -3413,5 +3413,20 @@
"mtime": 1782749549.9584448,
"ast_hash": "6e005bb338617608a0441724126a5bd3",
"semantic_hash": ""
},
"src/Category/Controller/CategoryImportController.php": {
"mtime": 1782839057.0287304,
"ast_hash": "f4d8651689e73ca956bc2a9570bba695",
"semantic_hash": ""
},
"tests/Category/CategoryImportTest.php": {
"mtime": 1782839178.8459258,
"ast_hash": "203f5500749a42f0be4f33dc2e3dbb1f",
"semantic_hash": ""
},
"docs/api/category-import.md": {
"mtime": 1782839220.8153825,
"ast_hash": "da0c24b873a9f46c97cd8760ac4eeba5",
"semantic_hash": ""
}
}
@@ -0,0 +1,326 @@
<?php
namespace App\Category\Controller;
use App\Shared\Controller\BaseController;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
use OpenApi\Attributes as OA;
/**
* Bulk JSON import for the admin "دسته‌بندی‌ها" page (all six tabs).
*
* Consumes the exact JSON produced by each tab's "خروجی JSON" export
* (an array of objects shaped like the entity's toArray()).
*
* Behaviour:
* - Strict two-phase validation: the WHOLE file is validated first; if any
* row is invalid, nothing is written and every error is returned (HTTP 422).
* - Wipe + full replace: on success the target table is emptied and rebuilt
* from the file, preserving the raw `id` of each row so relations
* (parent_id / province_id / specialty_id) stay intact.
* - The whole replace runs inside one transaction with FK checks disabled.
*/
#[OA\Tag(name: 'Categories')]
#[IsGranted('ROLE_ADMIN')]
class CategoryImportController extends BaseController
{
/** bundle => table name */
private const TABLES = [
'provinces' => 'provinces',
'cities' => 'cities',
'specialties' => 'specialties',
'doctor_services' => 'doctor_services',
'insurances' => 'insurances',
'tags' => 'tags',
];
public function __construct(
private readonly Connection $db,
) {}
/**
* Complete export of a bundle EVERY row, no pagination cap.
*
* The page's old "خروجی JSON" button pointed at the admin list endpoint,
* which silently caps at 100 rows. Feeding that truncated file back into the
* wipe+replace import deletes the tail. This endpoint returns the full table
* so the export/import round-trip is lossless.
*/
#[Route('/api/v1/admin/categories/{bundle}/export', methods: ['GET'])]
public function export(string $bundle): JsonResponse
{
$table = self::TABLES[$bundle] ?? null;
if ($table === null) {
return $this->error('ERR_BUNDLE_UNKNOWN', 'نوع دسته‌بندی نامعتبر است', 404, 'bundle');
}
$rows = $this->db->fetchAllAssociative('SELECT * FROM ' . $table . ' ORDER BY id ASC');
// normalize numeric columns so the JSON matches the entity shape
foreach ($rows as &$r) {
foreach (['id', 'status', 'weight', 'parent_id', 'specialty_id', 'province_id', 'representation_id'] as $intCol) {
if (array_key_exists($intCol, $r) && $r[$intCol] !== null) {
$r[$intCol] = (int) $r[$intCol];
}
}
}
unset($r);
return $this->success($rows);
}
#[Route('/api/v1/admin/categories/{bundle}/import', methods: ['POST'])]
public function import(string $bundle, Request $request): JsonResponse
{
$table = self::TABLES[$bundle] ?? null;
if ($table === null) {
return $this->error('ERR_BUNDLE_UNKNOWN', 'نوع دسته‌بندی نامعتبر است', 404, 'bundle');
}
$payload = json_decode($request->getContent(), true);
// accept both a raw array and { "items": [...] }
if (is_array($payload) && isset($payload['items']) && is_array($payload['items'])) {
$payload = $payload['items'];
}
if (!is_array($payload) || $payload === [] || array_keys($payload) !== range(0, count($payload) - 1)) {
return $this->error('ERR_IMPORT_FORMAT', 'فایل باید یک آرایه‌ی غیرخالی از رکوردها باشد', 422, 'file');
}
if (count($payload) > 5000) {
return $this->error('ERR_IMPORT_TOO_LARGE', 'حداکثر ۵۰۰۰ رکورد در هر import مجاز است', 422, 'file');
}
// ── Phase 1: strict validation ───────────────────────────────────────
$errors = [];
$rows = []; // normalized, ready-to-insert column maps
$seenId = []; // id => row index (uniqueness within file)
$seenSlug = []; // slug => row index
foreach ($payload as $i => $raw) {
$rowNo = $i + 1;
$add = function (string $field, string $message) use (&$errors, $rowNo): void {
$errors[] = ['row' => $rowNo, 'field' => $field, 'message' => "ردیف {$rowNo}: {$message}"];
};
if (!is_array($raw) || array_keys($raw) === range(0, count($raw) - 1)) {
$add('-', 'ساختار رکورد نامعتبر است');
continue;
}
// id — required, positive int, unique in file (relations rely on it)
$id = $raw['id'] ?? null;
if (!self::isPositiveInt($id)) {
$add('id', 'شناسه (id) باید عدد صحیح مثبت باشد');
} elseif (isset($seenId[(int) $id])) {
$add('id', "شناسه {$id} تکراری است (ردیف {$seenId[(int) $id]})");
} else {
$seenId[(int) $id] = $rowNo;
}
// name — required non-empty string ≤255
$name = $raw['name'] ?? null;
if (!is_string($name) || trim($name) === '') {
$add('name', 'نام الزامی است');
} elseif (mb_strlen($name) > 255) {
$add('name', 'نام نباید بیش از ۲۵۵ نویسه باشد');
}
// status — 0 or 1
$status = self::normInt($raw['status'] ?? 1);
if ($status === null || !in_array($status, [0, 1], true)) {
$add('status', 'وضعیت باید ۰ یا ۱ باشد');
}
$row = [
'id' => self::isPositiveInt($id) ? (int) $id : null,
'uuid' => (is_string($raw['uuid'] ?? null) && $raw['uuid'] !== '') ? mb_substr($raw['uuid'], 0, 36) : Uuid::v4()->toRfc4122(),
'name' => is_string($name) ? trim($name) : '',
'status' => $status ?? 1,
];
// weight — int ≥0 (only bundles whose table has a weight column)
if (in_array($bundle, ['provinces', 'cities', 'specialties', 'doctor_services'], true)) {
$weight = self::normInt($raw['weight'] ?? 0);
if ($weight === null || $weight < 0) {
$add('weight', 'ترتیب نمایش باید عدد صحیح نامنفی باشد');
}
$row['weight'] = $weight ?? 0;
}
// slug — required for bundles that have it, unique in file
if (in_array($bundle, ['specialties', 'doctor_services', 'tags'], true)) {
$slug = $raw['slug'] ?? null;
if (!is_string($slug) || trim($slug) === '') {
$add('slug', 'slug الزامی است');
} elseif (mb_strlen($slug) > 255) {
$add('slug', 'slug نباید بیش از ۲۵۵ نویسه باشد');
} else {
$slug = trim($slug);
if (isset($seenSlug[$slug])) {
$add('slug', "slug «{$slug}» تکراری است (ردیف {$seenSlug[$slug]})");
} else {
$seenSlug[$slug] = $rowNo;
}
$row['slug'] = $slug;
}
}
// bundle-specific columns
switch ($bundle) {
case 'specialties':
$row['parent_id'] = self::nullableId($raw['parent_id'] ?? null, $add, 'parent_id');
break;
case 'doctor_services':
$row['specialty_id'] = self::nullableId($raw['specialty_id'] ?? null, $add, 'specialty_id');
break;
case 'insurances':
$type = $raw['type'] ?? null;
if (!in_array($type, ['basic', 'supplementary'], true)) {
$add('type', 'نوع بیمه باید basic یا supplementary باشد');
}
$row['type'] = is_string($type) ? $type : 'basic';
$row['logo_url'] = self::optStr($raw['logo_url'] ?? null);
break;
case 'cities':
$row['province_id'] = self::nullableId($raw['province_id'] ?? null, $add, 'province_id');
$row['representation_id'] = self::nullableId($raw['representation_id'] ?? null, $add, 'representation_id');
$row['contact_phone'] = self::optStr($raw['contact_phone'] ?? null);
$row['email'] = self::optStr($raw['email'] ?? null);
$row['description'] = self::optStr($raw['description'] ?? null);
$row['slogan'] = self::optStr($raw['slogan'] ?? null);
$row['domain'] = self::optStr($raw['domain'] ?? null);
$row['keywords'] = self::optStr($raw['keywords'] ?? null);
$row['footer_description'] = self::optStr($raw['footer_description'] ?? null);
$row['logo_url'] = self::optStr($raw['logo_url'] ?? null);
$sm = $raw['social_media'] ?? null;
$row['social_media'] = ($sm === null || $sm === '') ? null : (is_string($sm) ? $sm : json_encode($sm, JSON_UNESCAPED_UNICODE));
break;
}
$rows[] = $row;
}
// referential integrity (only meaningful once ids are collected)
$this->validateReferences($bundle, $rows, $seenId, $errors);
if ($errors !== []) {
return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422);
}
// ── Phase 2: wipe + replace inside a transaction ─────────────────────
$this->db->beginTransaction();
try {
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
$this->db->executeStatement('DELETE FROM ' . $table);
foreach ($rows as $row) {
$this->db->insert($table, $row);
}
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
try { $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); } catch (\Throwable) {}
return $this->error('ERR_IMPORT_FAILED', 'خطا در ذخیره‌سازی: ' . $e->getMessage(), 500);
}
return $this->success(['imported' => count($rows)]);
}
/**
* Cross-row / cross-table reference checks.
* - specialties.parent_id must point to an id present in the same file.
* - cities.province_id / cities.representation_id / doctor_services.specialty_id
* must exist in their (un-wiped) reference tables.
*/
private function validateReferences(string $bundle, array $rows, array $seenId, array &$errors): void
{
$err = function (int $rowNo, string $field, string $message) use (&$errors): void {
$errors[] = ['row' => $rowNo, 'field' => $field, 'message' => "ردیف {$rowNo}: {$message}"];
};
if ($bundle === 'specialties') {
foreach ($rows as $idx => $r) {
$p = $r['parent_id'] ?? null;
if ($p !== null && !isset($seenId[$p])) {
$err($idx + 1, 'parent_id', "والد با شناسه {$p} در همین فایل وجود ندارد");
}
}
return;
}
if ($bundle === 'doctor_services') {
$valid = $this->existingIds('specialties');
foreach ($rows as $idx => $r) {
$s = $r['specialty_id'] ?? null;
if ($s !== null && !isset($valid[$s])) {
$err($idx + 1, 'specialty_id', "تخصص با شناسه {$s} در سیستم وجود ندارد");
}
}
return;
}
if ($bundle === 'cities') {
$validProv = $this->existingIds('provinces');
$validRep = $this->existingIds('representations');
foreach ($rows as $idx => $r) {
$pv = $r['province_id'] ?? null;
if ($pv !== null && !isset($validProv[$pv])) {
$err($idx + 1, 'province_id', "استان با شناسه {$pv} وجود ندارد");
}
$rp = $r['representation_id'] ?? null;
if ($rp !== null && !isset($validRep[$rp])) {
$err($idx + 1, 'representation_id', "نماینده با شناسه {$rp} وجود ندارد");
}
}
}
}
/** @return array<int,true> set of existing ids in a table */
private function existingIds(string $table): array
{
$ids = $this->db->fetchFirstColumn('SELECT id FROM ' . $table);
$set = [];
foreach ($ids as $id) {
$set[(int) $id] = true;
}
return $set;
}
private static function isPositiveInt(mixed $v): bool
{
return (is_int($v) || (is_string($v) && ctype_digit($v))) && (int) $v > 0;
}
private static function normInt(mixed $v): ?int
{
if (is_int($v)) return $v;
if (is_string($v) && preg_match('/^-?\d+$/', $v)) return (int) $v;
return null;
}
/** Validate an optional FK-style id: null stays null, otherwise must be positive int. */
private static function nullableId(mixed $v, callable $add, string $field): ?int
{
if ($v === null || $v === '' || $v === 0 || $v === '0') return null;
if (!self::isPositiveInt($v)) {
$add($field, "{$field} باید عدد صحیح مثبت یا خالی باشد");
return null;
}
return (int) $v;
}
private static function optStr(mixed $v): ?string
{
if ($v === null) return null;
if (!is_string($v)) return null;
$v = trim($v);
return $v === '' ? null : $v;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Tests\Category;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Connection;
/**
* POST /api/v1/admin/categories/{bundle}/import bulk wipe+replace import.
*
* Guards the two behaviours the feature promises:
* - strict validation: an invalid file is rejected whole, the table is untouched;
* - referential integrity: self/cross references must resolve before any write.
*
* Runs against db_test (never reset), so every assertion is made against the
* table the import owns, and the destructive cases verify the row count is the
* SAME before and after a rejected import.
*/
class CategoryImportTest extends ApiTestCase
{
private function db(): Connection
{
return static::getContainer()->get(Connection::class);
}
private function rowCount(string $table): int
{
return (int) $this->db()->fetchOne('SELECT COUNT(*) FROM ' . $table);
}
public function testValidImportReplacesTheWholeTable(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$body = $this->authJson('POST', '/api/v1/admin/categories/tags/import', $admin, [
['id' => 90001, 'name' => 'تگ یک', 'slug' => 'imp-tag-1', 'status' => 1],
['id' => 90002, 'name' => 'تگ دو', 'slug' => 'imp-tag-2', 'status' => 0],
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame(2, $body['data']['imported'] ?? null);
// wipe+replace: the table now holds exactly the imported rows
$this->assertSame(2, $this->rowCount('tags'));
$this->assertSame('imp-tag-1', $this->db()->fetchOne('SELECT slug FROM tags WHERE id = 90001'));
}
public function testInvalidFileIsRejectedAndNothingIsWritten(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
// seed a known state, then attempt a bad import — count must not change
$this->authJson('POST', '/api/v1/admin/categories/tags/import', $admin, [
['id' => 91001, 'name' => 'پایه', 'slug' => 'seed-tag', 'status' => 1],
]);
$before = $this->rowCount('tags');
$body = $this->authJson('POST', '/api/v1/admin/categories/tags/import', $admin, [
['id' => 1, 'name' => 'خوب', 'slug' => 'ok', 'status' => 1],
['id' => 1, 'name' => '', 'slug' => 'ok', 'status' => 9], // dup id, empty name, dup slug, bad status
]);
$this->assertSame(422, $this->responseCode());
$this->assertNotEmpty($body['errors']);
$this->assertSame($before, $this->rowCount('tags')); // table untouched
}
public function testSpecialtyParentMustExistWithinTheFile(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$body = $this->authJson('POST', '/api/v1/admin/categories/specialties/import', $admin, [
['id' => 1, 'name' => 'ریشه', 'slug' => 'imp-root', 'status' => 1, 'weight' => 0, 'parent_id' => null],
['id' => 2, 'name' => 'فرزند', 'slug' => 'imp-child', 'status' => 1, 'weight' => 0, 'parent_id' => 9999],
]);
$this->assertSame(422, $this->responseCode());
$fields = array_column($body['errors'], 'field');
$this->assertContains('parent_id', $fields);
}
public function testNonAdminIsForbidden(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', '/api/v1/admin/categories/tags/import', $user, [
['id' => 1, 'name' => 'x', 'slug' => 'x', 'status' => 1],
]);
$this->assertSame(403, $this->responseCode());
}
public function testUnknownBundleIs404(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('POST', '/api/v1/admin/categories/nope/import', $admin, [
['id' => 1, 'name' => 'x', 'slug' => 'x', 'status' => 1],
]);
$this->assertSame(404, $this->responseCode());
}
}