feat: add insurance and location management

- Introduced InsuranceType enum for insurance categorization.
- Created InsuranceRepository for managing insurance entities.
- Developed LocationController for handling provinces and cities, including CRUD operations.
- Implemented City and Province entities with necessary fields and relationships.
- Added CityRepository and ProvinceRepository for database interactions.
- Established Specialty management with SpecialtyController, including CRUD operations.
- Created Specialty and Tag entities with appropriate fields and relationships.
- Implemented TagController for managing tags, including CRUD operations.
- Added TagRepository for database interactions with tags.
This commit is contained in:
hamed
2026-06-10 14:22:26 +03:30
parent 4b8504df91
commit 5066fcbd91
36 changed files with 2937 additions and 1241 deletions
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Representation, Category } from '../types';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Representation, City } from '../types';
import { formatDate, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
@@ -30,11 +30,11 @@ export default function RepresentationDetailPage() {
const [formData, setFormData] = useState({ full_name: '', city_id: '', mobile_number: '', commission_percent: '' });
const citiesQuery = useQuery({
queryKey: ['categories', 'city'],
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>('/api/v1/categorys/city'),
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: Category[] = (citiesQuery.data?.data as any)?.data ?? [];
const cities: City[] = citiesQuery.data?.data ?? [];
const { data, isLoading } = useQuery({
queryKey: ['representation', uuid],
@@ -251,7 +251,7 @@ export default function RepresentationDetailPage() {
>
<option value="">انتخاب شهر</option>
{cities.map((c) => (
<option key={c.id} value={String(c.id)}>{c.label}</option>
<option key={c.id} value={String(c.id)}>{c.name}</option>
))}
</select>
</div>
+5 -5
View File
@@ -8,7 +8,7 @@ import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Representation, Category } from '../types';
import type { Representation, City } from '../types';
import { formatDate, formatRial, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { Column } from '../components/ui/DataTable';
@@ -38,12 +38,12 @@ export default function RepresentationsPage() {
// Load city list for filter and form
const citiesQuery = useQuery({
queryKey: ['categories', 'city'],
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>('/api/v1/categorys/city'),
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: Category[] = (citiesQuery.data?.data as any)?.data ?? [];
const cityOptions = cities.map((c) => ({ value: c.id, label: c.label }));
const cities: City[] = citiesQuery.data?.data ?? [];
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const { data, isLoading } = useQuery({
queryKey: ['representations', page, search, cityFilter],
+51 -19
View File
@@ -165,28 +165,21 @@ export interface SmsLog {
created_at: string;
}
export type CategoryBundle =
| 'state'
| 'city'
| 'specially_doctor'
| 'doctor_services'
| 'insurance_type'
| 'supplementary_insurance'
| 'tag';
export interface Category {
export interface Province {
id: number;
uuid: string;
label: string;
bundle: CategoryBundle;
name: string;
status: number;
weight: number;
parent_id?: number | null;
title?: string | null;
logo_id?: number | null;
// insurance-specific
logo_url?: string | null;
// city-specific
}
export interface City {
id: number;
uuid: string;
name: string;
status: number;
weight: number;
province_id: number | null;
representation_id?: number | null;
contact_phone?: string | null;
email?: string | null;
@@ -195,8 +188,45 @@ export interface Category {
domain?: string | null;
keywords?: string | null;
footer_description?: string | null;
footer_disclaimer?: string | null;
social_media?: Record<string, string> | null;
logo_url?: string | null;
}
export interface SpecialtyFull {
id: number;
uuid: string;
name: string;
slug: string;
status: number;
weight: number;
parent_id: number | null;
}
export interface DoctorService {
id: number;
uuid: string;
name: string;
slug: string;
status: number;
weight: number;
specialty_id: number | null;
}
export interface Insurance {
id: number;
uuid: string;
name: string;
type: 'basic' | 'supplementary';
logo_url: string | null;
status: number;
}
export interface Tag {
id: number;
uuid: string;
name: string;
slug: string;
status: number;
}
export interface Blog {
@@ -251,6 +281,8 @@ export interface SecretaryPermissions {
}
export interface Specialty {
id?: number;
uuid: string;
name: string;
slug?: string;
}
+1 -1
View File
@@ -74,6 +74,6 @@ services:
arguments:
$projectDir: '%kernel.project_dir%'
App\Category\Controller\CategoryController:
App\Insurance\Controller\InsuranceController:
arguments:
$projectDir: '%kernel.project_dir%'
+274
View File
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260610103401 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace polymorphic categories table with domain-specific tables: provinces, cities, specialties, doctor_services, insurances, tags';
}
public function up(Schema $schema): void
{
// ── 1. Create new tables ──────────────────────────────────────────────
$this->addSql('CREATE TABLE provinces (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, status SMALLINT NOT NULL, weight INT NOT NULL, UNIQUE INDEX UNIQ_8C96CC57D17F50A6 (uuid), INDEX idx_provinces_status (status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE cities (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, status SMALLINT NOT NULL, weight INT NOT NULL, representation_id INT DEFAULT NULL, contact_phone VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, slogan VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) DEFAULT NULL, keywords VARCHAR(255) DEFAULT NULL, footer_description LONGTEXT DEFAULT NULL, social_media JSON DEFAULT NULL, logo_url VARCHAR(500) DEFAULT NULL, province_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_D95DB16BD17F50A6 (uuid), INDEX idx_cities_province (province_id), INDEX idx_cities_status (status), INDEX idx_cities_representation (representation_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE specialties (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, status SMALLINT NOT NULL, weight INT NOT NULL, parent_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_410754B0D17F50A6 (uuid), INDEX idx_specialties_status (status), INDEX idx_specialties_parent (parent_id), UNIQUE INDEX uq_specialties_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE doctor_services (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, status SMALLINT NOT NULL, weight INT NOT NULL, specialty_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_DBC6935FD17F50A6 (uuid), INDEX idx_doctor_services_status (status), INDEX idx_doctor_services_specialty (specialty_id), UNIQUE INDEX uq_doctor_services_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE insurances (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL, logo_url VARCHAR(500) DEFAULT NULL, status SMALLINT NOT NULL, UNIQUE INDEX UNIQ_6400CC1FD17F50A6 (uuid), INDEX idx_insurances_type (type), INDEX idx_insurances_status (status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE tags (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, status SMALLINT NOT NULL, UNIQUE INDEX UNIQ_6FBC9426D17F50A6 (uuid), INDEX idx_tags_status (status), UNIQUE INDEX uq_tags_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE doctor_provinces (doctor_id INT NOT NULL, province_id INT NOT NULL, INDEX IDX_4384ADBC87F4FB17 (doctor_id), INDEX IDX_4384ADBCE946114A (province_id), PRIMARY KEY (doctor_id, province_id)) DEFAULT CHARACTER SET utf8mb4');
// ── 2. FK constraints on new tables ──────────────────────────────────
$this->addSql('ALTER TABLE cities ADD CONSTRAINT FK_D95DB16BE946114A FOREIGN KEY (province_id) REFERENCES provinces (id)');
$this->addSql('ALTER TABLE doctor_services ADD CONSTRAINT FK_DBC6935F9A353316 FOREIGN KEY (specialty_id) REFERENCES specialties (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE doctor_provinces ADD CONSTRAINT FK_4384ADBC87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id)');
$this->addSql('ALTER TABLE doctor_provinces ADD CONSTRAINT FK_4384ADBCE946114A FOREIGN KEY (province_id) REFERENCES provinces (id)');
$this->addSql('ALTER TABLE specialties ADD CONSTRAINT FK_410754B0727ACA70 FOREIGN KEY (parent_id) REFERENCES specialties (id) ON DELETE SET NULL');
// ── 3. Migrate data: provinces (bundle=state, IDs 131) ───────────────
$this->addSql("
INSERT INTO provinces (id, uuid, name, status, weight)
SELECT id, UUID(), label, status, weight
FROM categories
WHERE bundle = 'state'
");
// ── 4. Migrate data: cities (bundle=city, parent_id → province_id) ───
$this->addSql("
INSERT INTO cities (id, uuid, name, status, weight, province_id,
representation_id, contact_phone, email,
description, slogan, domain, keywords,
footer_description, logo_url)
SELECT id, UUID(), label, status, weight, parent_id,
representation_id, contact_phone, email,
description, slogan, domain, keywords,
footer_description, logo_url
FROM categories
WHERE bundle = 'city'
");
// ── 5. Migrate data: specialties (bundle=specially_doctor) ────────────
$this->addSql("
INSERT INTO specialties (id, uuid, name, slug, status, weight, parent_id)
SELECT id, UUID(), label, CONCAT('specialty-', id), status, weight, NULL
FROM categories
WHERE bundle = 'specially_doctor'
");
// ── 6. Migrate data: doctor_services (bundle=doctor_services) ─────────
$this->addSql("
INSERT INTO doctor_services (id, uuid, name, slug, status, weight, specialty_id)
SELECT id, UUID(), label, CONCAT('service-', id), status, weight, NULL
FROM categories
WHERE bundle = 'doctor_services'
");
// ── 7. Migrate data: insurances (basic + supplementary) ───────────────
$this->addSql("
INSERT INTO insurances (id, uuid, name, type, logo_url, status)
SELECT id, UUID(), label, 'basic', logo_url, status
FROM categories
WHERE bundle = 'insurance_type'
");
$this->addSql("
INSERT INTO insurances (id, uuid, name, type, logo_url, status)
SELECT id, UUID(), label, 'supplementary', logo_url, status
FROM categories
WHERE bundle = 'supplementary_insurance'
");
// ── 8. Migrate data: tags (bundle=tag) ────────────────────────────────
$this->addSql("
INSERT INTO tags (id, uuid, name, slug, status)
SELECT id, UUID(), label, CONCAT('tag-', id), status
FROM categories
WHERE bundle = 'tag'
");
// ── 9. Migrate doctor_states → doctor_provinces ───────────────────────
$this->addSql("
INSERT INTO doctor_provinces (doctor_id, province_id)
SELECT doctor_id, category_id
FROM doctor_states
");
// ── 10. Drop old doctor_states table ─────────────────────────────────
$this->addSql('ALTER TABLE doctor_states DROP FOREIGN KEY `FK_955E47C012469DE2`');
$this->addSql('ALTER TABLE doctor_states DROP FOREIGN KEY `FK_955E47C087F4FB17`');
$this->addSql('DROP TABLE doctor_states');
// ── 11. clinics: state_id → province_id ──────────────────────────────
$this->addSql('DROP INDEX idx_clinics_state ON clinics');
$this->addSql('ALTER TABLE clinics CHANGE state_id province_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX idx_clinics_province ON clinics (province_id)');
// ── 12. clinic_specialties: category_id → specialty_id ───────────────
$this->addSql('ALTER TABLE clinic_specialties DROP FOREIGN KEY `FK_3201DD3712469DE2`');
$this->addSql('DROP INDEX IDX_3201DD3712469DE2 ON clinic_specialties');
$this->addSql('ALTER TABLE clinic_specialties CHANGE category_id specialty_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, specialty_id)');
$this->addSql('ALTER TABLE clinic_specialties ADD CONSTRAINT FK_3201DD379A353316 FOREIGN KEY (specialty_id) REFERENCES specialties (id)');
$this->addSql('CREATE INDEX IDX_3201DD379A353316 ON clinic_specialties (specialty_id)');
// ── 13. clinic_services: category_id → service_id ────────────────────
$this->addSql('ALTER TABLE clinic_services DROP FOREIGN KEY `FK_C00FFAB012469DE2`');
$this->addSql('DROP INDEX IDX_C00FFAB012469DE2 ON clinic_services');
$this->addSql('ALTER TABLE clinic_services CHANGE category_id service_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, service_id)');
$this->addSql('ALTER TABLE clinic_services ADD CONSTRAINT FK_C00FFAB0ED5CA9E6 FOREIGN KEY (service_id) REFERENCES doctor_services (id)');
$this->addSql('CREATE INDEX IDX_C00FFAB0ED5CA9E6 ON clinic_services (service_id)');
// ── 14. clinic_insurances: category_id → insurance_id ────────────────
$this->addSql('ALTER TABLE clinic_insurances DROP FOREIGN KEY `FK_BE9FC89812469DE2`');
$this->addSql('DROP INDEX IDX_BE9FC89812469DE2 ON clinic_insurances');
$this->addSql('ALTER TABLE clinic_insurances CHANGE category_id insurance_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, insurance_id)');
$this->addSql('ALTER TABLE clinic_insurances ADD CONSTRAINT FK_BE9FC898D1E63CD1 FOREIGN KEY (insurance_id) REFERENCES insurances (id)');
$this->addSql('CREATE INDEX IDX_BE9FC898D1E63CD1 ON clinic_insurances (insurance_id)');
// ── 15. doctor_insurances: category_id → insurance_id ────────────────
$this->addSql('ALTER TABLE doctor_insurances DROP FOREIGN KEY `FK_5317E58E12469DE2`');
$this->addSql('DROP INDEX idx_doctor_insurance_cat ON doctor_insurances');
$this->addSql('DROP INDEX idx_doctor_insurance ON doctor_insurances');
$this->addSql('ALTER TABLE doctor_insurances CHANGE category_id insurance_id INT NOT NULL');
$this->addSql('ALTER TABLE doctor_insurances ADD CONSTRAINT FK_5317E58ED1E63CD1 FOREIGN KEY (insurance_id) REFERENCES insurances (id)');
$this->addSql('CREATE INDEX idx_doctor_insurance_ins ON doctor_insurances (insurance_id)');
$this->addSql('CREATE UNIQUE INDEX idx_doctor_insurance ON doctor_insurances (doctor_id, insurance_id)');
// ── 16. doctor_specialties: category_id → specialty_id ───────────────
$this->addSql('ALTER TABLE doctor_specialties DROP FOREIGN KEY `FK_C638E04B12469DE2`');
$this->addSql('DROP INDEX IDX_C638E04B12469DE2 ON doctor_specialties');
$this->addSql('ALTER TABLE doctor_specialties CHANGE category_id specialty_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, specialty_id)');
$this->addSql('ALTER TABLE doctor_specialties ADD CONSTRAINT FK_C638E04B9A353316 FOREIGN KEY (specialty_id) REFERENCES specialties (id)');
$this->addSql('CREATE INDEX IDX_C638E04B9A353316 ON doctor_specialties (specialty_id)');
// ── 17. doctor_expertise: category_id → service_id ───────────────────
$this->addSql('ALTER TABLE doctor_expertise DROP FOREIGN KEY `FK_ED88BE6012469DE2`');
$this->addSql('DROP INDEX IDX_ED88BE6012469DE2 ON doctor_expertise');
$this->addSql('ALTER TABLE doctor_expertise CHANGE category_id service_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, service_id)');
$this->addSql('ALTER TABLE doctor_expertise ADD CONSTRAINT FK_ED88BE60ED5CA9E6 FOREIGN KEY (service_id) REFERENCES doctor_services (id)');
$this->addSql('CREATE INDEX IDX_ED88BE60ED5CA9E6 ON doctor_expertise (service_id)');
// ── 18. doctor_cities: category_id → city_id ─────────────────────────
$this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY `FK_7DC181E612469DE2`');
$this->addSql('DROP INDEX IDX_7DC181E612469DE2 ON doctor_cities');
$this->addSql('ALTER TABLE doctor_cities CHANGE category_id city_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, city_id)');
$this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT FK_7DC181E68BAC62AF FOREIGN KEY (city_id) REFERENCES cities (id)');
$this->addSql('CREATE INDEX IDX_7DC181E68BAC62AF ON doctor_cities (city_id)');
// ── 19. Drop the categories table ─────────────────────────────────────
$this->addSql('DROP TABLE categories');
}
public function down(Schema $schema): void
{
// Recreate categories table
$this->addSql("
CREATE TABLE categories (
id INT AUTO_INCREMENT NOT NULL,
uuid VARCHAR(36) NOT NULL,
bundle VARCHAR(32) NOT NULL,
label VARCHAR(255) DEFAULT NULL,
status SMALLINT NOT NULL,
parent_id INT DEFAULT NULL,
weight INT NOT NULL,
logo_id INT DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
representation_id INT DEFAULT NULL,
contact_phone VARCHAR(255) DEFAULT NULL,
email VARCHAR(255) DEFAULT NULL,
description LONGTEXT DEFAULT NULL,
slogan VARCHAR(255) DEFAULT NULL,
domain VARCHAR(255) DEFAULT NULL,
keywords VARCHAR(255) DEFAULT NULL,
footer_description LONGTEXT DEFAULT NULL,
footer_disclaimer LONGTEXT DEFAULT NULL,
social_media LONGTEXT DEFAULT NULL,
logo_url VARCHAR(500) DEFAULT NULL,
UNIQUE INDEX UNIQ_D95DB16BD17F50A6 (uuid),
INDEX idx_categories_bundle (bundle),
INDEX idx_categories_status (status),
INDEX idx_categories_parent (parent_id),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4
");
// Recreate doctor_states
$this->addSql('CREATE TABLE doctor_states (doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_955E47C012469DE2 (category_id), INDEX IDX_955E47C087F4FB17 (doctor_id), PRIMARY KEY (doctor_id, category_id)) DEFAULT CHARACTER SET utf8mb4');
// Restore pivot table FKs to categories
$this->addSql('ALTER TABLE clinic_insurances DROP FOREIGN KEY FK_BE9FC898D1E63CD1');
$this->addSql('DROP INDEX IDX_BE9FC898D1E63CD1 ON clinic_insurances');
$this->addSql('ALTER TABLE clinic_insurances CHANGE insurance_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, category_id)');
$this->addSql('ALTER TABLE clinic_insurances ADD CONSTRAINT `FK_BE9FC89812469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_BE9FC89812469DE2 ON clinic_insurances (category_id)');
$this->addSql('ALTER TABLE clinic_services DROP FOREIGN KEY FK_C00FFAB0ED5CA9E6');
$this->addSql('DROP INDEX IDX_C00FFAB0ED5CA9E6 ON clinic_services');
$this->addSql('ALTER TABLE clinic_services CHANGE service_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, category_id)');
$this->addSql('ALTER TABLE clinic_services ADD CONSTRAINT `FK_C00FFAB012469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_C00FFAB012469DE2 ON clinic_services (category_id)');
$this->addSql('ALTER TABLE clinic_specialties DROP FOREIGN KEY FK_3201DD379A353316');
$this->addSql('DROP INDEX IDX_3201DD379A353316 ON clinic_specialties');
$this->addSql('ALTER TABLE clinic_specialties CHANGE specialty_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (clinic_id, category_id)');
$this->addSql('ALTER TABLE clinic_specialties ADD CONSTRAINT `FK_3201DD3712469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_3201DD3712469DE2 ON clinic_specialties (category_id)');
$this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY FK_7DC181E68BAC62AF');
$this->addSql('DROP INDEX IDX_7DC181E68BAC62AF ON doctor_cities');
$this->addSql('ALTER TABLE doctor_cities CHANGE city_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, category_id)');
$this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT `FK_7DC181E612469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_7DC181E612469DE2 ON doctor_cities (category_id)');
$this->addSql('ALTER TABLE doctor_expertise DROP FOREIGN KEY FK_ED88BE60ED5CA9E6');
$this->addSql('DROP INDEX IDX_ED88BE60ED5CA9E6 ON doctor_expertise');
$this->addSql('ALTER TABLE doctor_expertise CHANGE service_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, category_id)');
$this->addSql('ALTER TABLE doctor_expertise ADD CONSTRAINT `FK_ED88BE6012469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_ED88BE6012469DE2 ON doctor_expertise (category_id)');
$this->addSql('ALTER TABLE doctor_insurances DROP FOREIGN KEY FK_5317E58ED1E63CD1');
$this->addSql('DROP INDEX idx_doctor_insurance_ins ON doctor_insurances');
$this->addSql('DROP INDEX idx_doctor_insurance ON doctor_insurances');
$this->addSql('ALTER TABLE doctor_insurances CHANGE insurance_id category_id INT NOT NULL');
$this->addSql('ALTER TABLE doctor_insurances ADD CONSTRAINT `FK_5317E58E12469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX idx_doctor_insurance_cat ON doctor_insurances (category_id)');
$this->addSql('CREATE UNIQUE INDEX idx_doctor_insurance ON doctor_insurances (doctor_id, category_id)');
$this->addSql('ALTER TABLE doctor_specialties DROP FOREIGN KEY FK_C638E04B9A353316');
$this->addSql('DROP INDEX IDX_C638E04B9A353316 ON doctor_specialties');
$this->addSql('ALTER TABLE doctor_specialties CHANGE specialty_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, category_id)');
$this->addSql('ALTER TABLE doctor_specialties ADD CONSTRAINT `FK_C638E04B12469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_C638E04B12469DE2 ON doctor_specialties (category_id)');
$this->addSql('DROP INDEX idx_clinics_province ON clinics');
$this->addSql('ALTER TABLE clinics CHANGE province_id state_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX idx_clinics_state ON clinics (state_id)');
$this->addSql('ALTER TABLE doctor_states ADD CONSTRAINT `FK_955E47C012469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('ALTER TABLE doctor_states ADD CONSTRAINT `FK_955E47C087F4FB17` FOREIGN KEY (doctor_id) REFERENCES doctors (id)');
$this->addSql('ALTER TABLE cities DROP FOREIGN KEY FK_D95DB16BE946114A');
$this->addSql('ALTER TABLE doctor_services DROP FOREIGN KEY FK_DBC6935F9A353316');
$this->addSql('ALTER TABLE doctor_provinces DROP FOREIGN KEY FK_4384ADBC87F4FB17');
$this->addSql('ALTER TABLE doctor_provinces DROP FOREIGN KEY FK_4384ADBCE946114A');
$this->addSql('ALTER TABLE specialties DROP FOREIGN KEY FK_410754B0727ACA70');
$this->addSql('DROP TABLE cities');
$this->addSql('DROP TABLE doctor_services');
$this->addSql('DROP TABLE doctor_provinces');
$this->addSql('DROP TABLE insurances');
$this->addSql('DROP TABLE provinces');
$this->addSql('DROP TABLE specialties');
$this->addSql('DROP TABLE tags');
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Finish categories migration:
* - Remove orphan rows from doctor_expertise (bad test data pointing to province IDs)
* - Add FK constraint to doctor_expertise.service_id → doctor_services
* - Migrate doctor_cities.category_id → city_id with FK to cities
* - Drop categories table
*/
final class Version20260610140853 extends AbstractMigration
{
public function getDescription(): string
{
return 'Finish categories migration: fix doctor_expertise orphans, doctor_cities → city_id, drop categories';
}
public function up(Schema $schema): void
{
// Remove rows in doctor_expertise whose service_id doesn't exist in doctor_services
$this->addSql('DELETE FROM doctor_expertise WHERE service_id NOT IN (SELECT id FROM doctor_services)');
// Add FK constraint to doctor_expertise
$this->addSql('ALTER TABLE doctor_expertise ADD CONSTRAINT FK_ED88BE60ED5CA9E6 FOREIGN KEY (service_id) REFERENCES doctor_services (id)');
$this->addSql('CREATE INDEX IDX_ED88BE60ED5CA9E6 ON doctor_expertise (service_id)');
// doctor_cities: category_id → city_id with FK to cities
$this->addSql('DELETE FROM doctor_cities WHERE category_id NOT IN (SELECT id FROM cities)');
$this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY `FK_7DC181E612469DE2`');
$this->addSql('DROP INDEX IDX_7DC181E612469DE2 ON doctor_cities');
$this->addSql('ALTER TABLE doctor_cities CHANGE category_id city_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, city_id)');
$this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT FK_7DC181E68BAC62AF FOREIGN KEY (city_id) REFERENCES cities (id)');
$this->addSql('CREATE INDEX IDX_7DC181E68BAC62AF ON doctor_cities (city_id)');
// Drop the now-unused categories table
$this->addSql('DROP TABLE categories');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE doctor_expertise DROP FOREIGN KEY FK_ED88BE60ED5CA9E6');
$this->addSql('DROP INDEX IDX_ED88BE60ED5CA9E6 ON doctor_expertise');
$this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY FK_7DC181E68BAC62AF');
$this->addSql('DROP INDEX IDX_7DC181E68BAC62AF ON doctor_cities');
$this->addSql('ALTER TABLE doctor_cities CHANGE city_id category_id INT NOT NULL, DROP PRIMARY KEY, ADD PRIMARY KEY (doctor_id, category_id)');
$this->addSql("
CREATE TABLE categories (
id INT AUTO_INCREMENT NOT NULL,
uuid VARCHAR(36) NOT NULL,
bundle VARCHAR(32) NOT NULL,
label VARCHAR(255) DEFAULT NULL,
status SMALLINT NOT NULL,
parent_id INT DEFAULT NULL,
weight INT NOT NULL,
logo_id INT DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
representation_id INT DEFAULT NULL,
contact_phone VARCHAR(255) DEFAULT NULL,
email VARCHAR(255) DEFAULT NULL,
description LONGTEXT DEFAULT NULL,
slogan VARCHAR(255) DEFAULT NULL,
domain VARCHAR(255) DEFAULT NULL,
keywords VARCHAR(255) DEFAULT NULL,
footer_description LONGTEXT DEFAULT NULL,
footer_disclaimer LONGTEXT DEFAULT NULL,
social_media LONGTEXT DEFAULT NULL,
logo_url VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4
");
$this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT `FK_7DC181E612469DE2` FOREIGN KEY (category_id) REFERENCES categories (id)');
$this->addSql('CREATE INDEX IDX_7DC181E612469DE2 ON doctor_cities (category_id)');
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ namespace App\Admin\Controller;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Category\Entity\Category;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
@@ -340,9 +340,9 @@ class AdminApiController extends BaseController
$cityId = $request->query->get('city_id');
$qb = $this->em->createQueryBuilder()
->select('r.id, r.uuid, r.fullName, r.mobileNumber, r.cityId, r.commissionPercent, r.active, r.createdAt, c.label as city_name')
->select('r.id, r.uuid, r.fullName, r.mobileNumber, r.cityId, r.commissionPercent, r.active, r.createdAt, c.name as city_name')
->from(Representation::class, 'r')
->leftJoin(Category::class, 'c', 'WITH', 'c.id = r.cityId')
->leftJoin(City::class, 'c', 'WITH', 'c.id = r.cityId')
->orderBy('r.createdAt', 'DESC');
if ($search !== '') {
+27 -149
View File
@@ -2,164 +2,42 @@
namespace App\Category\Controller;
use App\Category\Entity\Category;
use App\Category\Repository\CategoryRepository;
use App\Category\Service\CategoryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
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;
/**
* Legacy stub — all endpoints migrated to domain-specific controllers:
* Provinces/Cities → App\Location\Controller\LocationController
* Specialties → App\Specialty\Controller\SpecialtyController
* DoctorServices → App\DoctorService\Controller\DoctorServiceController
* Insurances → App\Insurance\Controller\InsuranceController
* Tags → App\Tag\Controller\TagController
*/
class CategoryController extends BaseController
{
public function __construct(
private readonly CategoryRepository $repository,
private readonly CategoryService $service,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
#[Route('/api/v1/categorys/tag', methods: ['GET'])]
public function listTags(): JsonResponse
#[Route('/api/v1/categorys/{bundle}', methods: ['GET'])]
public function legacyList(string $bundle): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('tag')]);
}
$map = [
'state' => '/api/v1/provinces',
'city' => '/api/v1/cities',
'specially_doctor' => '/api/v1/specialties',
'doctor_services' => '/api/v1/doctor-services',
'insurance_type' => '/api/v1/insurances?type=basic',
'supplementary_insurance' => '/api/v1/insurances?type=supplementary',
'tag' => '/api/v1/tags',
];
#[Route('/api/v1/categorys/supplementary_insurance', methods: ['GET'])]
public function listSupplementaryInsurance(): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('supplementary_insurance')]);
}
#[Route('/api/v1/categorys/insurance_type', methods: ['GET'])]
public function listInsuranceType(): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('insurance_type')]);
}
#[Route('/api/v1/categorys/state', methods: ['GET'])]
public function listStates(): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('state')]);
}
#[Route('/api/v1/categorys/city', methods: ['GET'])]
public function listCities(Request $request): JsonResponse
{
$stateId = $request->query->get('state_id');
$parentId = $stateId !== null ? (int) $stateId : null;
return $this->success(['data' => $this->service->listByBundle('city', $parentId)]);
}
#[Route('/api/v1/categorys/specially_doctor', methods: ['GET'])]
public function listSpecialties(): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('specially_doctor')]);
}
#[Route('/api/v1/categorys/doctor_services', methods: ['GET'])]
public function listDoctorServices(): JsonResponse
{
return $this->success(['data' => $this->service->listByBundle('doctor_services')]);
}
#[Route('/api/v1/category', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$bundle = trim($data['bundle'] ?? '');
$label = trim($data['label'] ?? '');
if (!in_array($bundle, Category::BUNDLES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle');
$newUrl = $map[$bundle] ?? null;
if ($newUrl === null) {
return $this->error('ERR_GONE', 'این endpoint حذف شده است', 410);
}
if ($label === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label الزامی است', 422, 'label');
}
$category = $this->service->create($bundle, $label, $data);
return $this->success(['data' => $category->toArray()], 201);
}
#[Route('/api/v1/category/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$category = $this->repository->find($id);
if ($category === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['bundle']) && !in_array($data['bundle'], Category::BUNDLES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle');
}
$category = $this->service->update($category, $data);
return $this->success(['data' => $category->toArray()]);
}
#[Route('/api/v1/category/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$category = $this->repository->find($id);
if ($category === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 404);
}
$this->service->delete($category);
return $this->success(['message' => 'دسته‌بندی با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/category/upload-logo', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function uploadLogo(Request $request): JsonResponse
{
$content = $request->getContent();
$disposition = $request->headers->get('Content-Disposition', '');
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
$filename = $m[1] ?? 'logo.jpg';
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
file_put_contents($tmpPath, $content);
try {
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
$mime = $this->fileValidator->detectMimeType($tmpPath);
$year = date('Y'); $month = date('m');
$dir = $this->projectDir . '/public/uploads/categories/logo/' . $year . '-' . $month;
if (!is_dir($dir)) mkdir($dir, 0755, true);
$storedName = uniqid('', true) . '_' . $safeFilename;
rename($tmpPath, $dir . '/' . $storedName);
$url = '/uploads/categories/logo/' . $year . '-' . $month . '/' . $storedName;
return $this->success([
'url' => $url,
'uuid' => Uuid::v4()->toRfc4122(),
'filename' => $safeFilename,
'filemime' => $mime,
'filesize' => strlen($content),
]);
} catch (\Throwable $e) {
if (file_exists($tmpPath)) unlink($tmpPath);
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
}
return $this->error(
'ERR_MOVED',
sprintf('این endpoint منتقل شده. لطفاً از %s استفاده کنید.', $newUrl),
301
);
}
}
-165
View File
@@ -1,165 +0,0 @@
<?php
namespace App\Category\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'categories')]
#[ORM\Index(columns: ['bundle'], name: 'idx_categories_bundle')]
#[ORM\Index(columns: ['parent_id'], name: 'idx_categories_parent')]
#[ORM\Index(columns: ['status', 'bundle'], name: 'idx_categories_status')]
class Category
{
public const BUNDLES = [
'state', 'city', 'specially_doctor', 'doctor_services',
'insurance_type', 'supplementary_insurance', 'tag',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 32)]
private string $bundle;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $label = null;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
#[ORM\Column(name: 'parent_id', type: 'integer', nullable: true)]
private ?int $parentId = null;
#[ORM\Column(type: 'integer')]
private int $weight = 0;
#[ORM\Column(name: 'logo_id', type: 'integer', nullable: true)]
private ?int $logoId = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
// City-specific fields
#[ORM\Column(name: 'contact_phone', type: 'string', length: 255, nullable: true)]
private ?string $contactPhone = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $slogan = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $domain = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $keywords = null;
#[ORM\Column(name: 'footer_description', type: 'text', nullable: true)]
private ?string $footerDescription = null;
#[ORM\Column(name: 'footer_disclaimer', type: 'text', nullable: true)]
private ?string $footerDisclaimer = null;
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
private ?array $socialMedia = null;
#[ORM\Column(name: 'logo_url', type: 'string', length: 500, nullable: true)]
private ?string $logoUrl = null;
public function __construct(string $bundle, string $label)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->bundle = $bundle;
$this->label = $label;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getBundle(): string { return $this->bundle; }
public function getLabel(): ?string { return $this->label; }
public function getStatus(): int { return $this->status; }
public function getParentId(): ?int { return $this->parentId; }
public function getWeight(): int { return $this->weight; }
public function getLogoId(): ?int { return $this->logoId; }
public function getTitle(): ?string { return $this->title; }
public function getRepresentationId(): ?int { return $this->representationId; }
public function getContactPhone(): ?string { return $this->contactPhone; }
public function getEmail(): ?string { return $this->email; }
public function getDescription(): ?string { return $this->description; }
public function getSlogan(): ?string { return $this->slogan; }
public function getDomain(): ?string { return $this->domain; }
public function getKeywords(): ?string { return $this->keywords; }
public function getFooterDescription(): ?string { return $this->footerDescription; }
public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; }
public function getSocialMedia(): ?array { return $this->socialMedia; }
public function getLogoUrl(): ?string { return $this->logoUrl; }
public function setBundle(string $bundle): self { $this->bundle = $bundle; return $this; }
public function setLabel(?string $label): self { $this->label = $label; return $this; }
public function setStatus(int $status): self { $this->status = $status; return $this; }
public function setParentId(?int $id): self { $this->parentId = $id; return $this; }
public function setWeight(int $weight): self { $this->weight = $weight; return $this; }
public function setLogoId(?int $id): self { $this->logoId = $id; return $this; }
public function setTitle(?string $title): self { $this->title = $title; return $this; }
public function setRepresentationId(?int $id): self { $this->representationId = $id; return $this; }
public function setContactPhone(?string $v): self { $this->contactPhone = $v; return $this; }
public function setEmail(?string $v): self { $this->email = $v; return $this; }
public function setDescription(?string $v): self { $this->description = $v; return $this; }
public function setSlogan(?string $v): self { $this->slogan = $v; return $this; }
public function setDomain(?string $v): self { $this->domain = $v; return $this; }
public function setKeywords(?string $v): self { $this->keywords = $v; return $this; }
public function setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; }
public function setFooterDisclaimer(?string $v): self { $this->footerDisclaimer = $v; return $this; }
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; }
public function setLogoUrl(?string $v): self { $this->logoUrl = $v; return $this; }
public function toArray(): array
{
$data = [
'id' => $this->id,
'uuid' => $this->uuid,
'bundle' => $this->bundle,
'label' => $this->label,
'status' => $this->status,
'weight' => $this->weight,
];
if ($this->parentId !== null) {
$data['parent_id'] = $this->parentId;
}
if ($this->title !== null) {
$data['title'] = $this->title;
}
if (in_array($this->bundle, ['insurance_type', 'supplementary_insurance'], true)) {
$data['logo_url'] = $this->logoUrl;
}
if ($this->bundle === 'city') {
$data['representation_id'] = $this->representationId;
$data['contact_phone'] = $this->contactPhone;
$data['email'] = $this->email;
$data['description'] = $this->description;
$data['slogan'] = $this->slogan;
$data['domain'] = $this->domain;
$data['keywords'] = $this->keywords;
$data['footer_description'] = $this->footerDescription;
$data['social_media'] = $this->socialMedia;
}
return $data;
}
}
@@ -1,48 +0,0 @@
<?php
namespace App\Category\Repository;
use App\Category\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class CategoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Category::class);
}
/** @return Category[] */
public function findByBundle(string $bundle, ?int $parentId = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.bundle = :bundle')
->andWhere('c.status = 1')
->setParameter('bundle', $bundle)
->orderBy('c.weight', 'ASC')
->addOrderBy('c.label', 'ASC');
if ($parentId !== null) {
$qb->andWhere('c.parentId = :parentId')->setParameter('parentId', $parentId);
}
return $qb->getQuery()->getResult();
}
public function save(Category $category, bool $flush = true): void
{
$this->getEntityManager()->persist($category);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Category $category, bool $flush = true): void
{
$this->getEntityManager()->remove($category);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
-99
View File
@@ -1,99 +0,0 @@
<?php
namespace App\Category\Service;
use App\Category\Entity\Category;
use App\Category\Repository\CategoryRepository;
use Psr\Cache\CacheItemPoolInterface;
class CategoryService
{
private const TTL = 3600;
public function __construct(
private readonly CategoryRepository $repository,
private readonly CacheItemPoolInterface $cache,
) {}
/** @return array[] */
public function listByBundle(string $bundle, ?int $parentId = null): array
{
$cacheKey = 'cat_' . $bundle . ($parentId !== null ? '_p' . $parentId : '');
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
return $item->get();
}
$rows = array_map(fn(Category $c) => $c->toArray(), $this->repository->findByBundle($bundle, $parentId));
$item->set($rows)->expiresAfter(self::TTL);
$this->cache->save($item);
return $rows;
}
public function create(string $bundle, string $label, array $extra = []): Category
{
$category = new Category($bundle, $label);
$this->applyExtra($category, $extra);
$this->repository->save($category);
$this->invalidate($bundle);
return $category;
}
public function update(Category $category, array $data): Category
{
$bundle = $data['bundle'] ?? $category->getBundle();
if (isset($data['label'])) $category->setLabel($data['label']);
if (isset($data['status'])) $category->setStatus((int) $data['status']);
if (isset($data['weight'])) $category->setWeight((int) $data['weight']);
if (isset($data['title'])) $category->setTitle($data['title']);
if (isset($data['bundle'])) $category->setBundle($data['bundle']);
if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']);
$this->applyExtra($category, $data);
$this->repository->save($category);
$this->invalidate($bundle);
$this->invalidate($category->getBundle());
return $category;
}
public function delete(Category $category): void
{
$bundle = $category->getBundle();
$this->repository->remove($category);
$this->invalidate($bundle);
}
private function applyExtra(Category $category, array $data): void
{
if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']);
if (array_key_exists('weight', $data)) $category->setWeight((int) $data['weight']);
if (array_key_exists('logo_id', $data)) $category->setLogoId($data['logo_id']);
if (array_key_exists('title', $data)) $category->setTitle($data['title']);
if (array_key_exists('contact_phone', $data)) $category->setContactPhone($data['contact_phone']);
if (array_key_exists('email', $data)) $category->setEmail($data['email']);
if (array_key_exists('description', $data)) $category->setDescription($data['description']);
if (array_key_exists('slogan', $data)) $category->setSlogan($data['slogan']);
if (array_key_exists('domain', $data)) $category->setDomain($data['domain']);
if (array_key_exists('keywords', $data)) $category->setKeywords($data['keywords']);
if (array_key_exists('footer_description', $data)) $category->setFooterDescription($data['footer_description']);
if (array_key_exists('footer_disclaimer', $data)) $category->setFooterDisclaimer($data['footer_disclaimer']);
if (array_key_exists('social_media', $data)) $category->setSocialMedia($data['social_media']);
if (array_key_exists('representation_id', $data)) $category->setRepresentationId($data['representation_id'] !== null ? (int) $data['representation_id'] : null);
if (array_key_exists('logo_url', $data)) $category->setLogoUrl($data['logo_url']);
}
private function invalidate(string $bundle): void
{
$this->cache->deleteItem('cat_' . $bundle);
// Also delete any parent-filtered variants
foreach (range(1, 50) as $id) {
$this->cache->deleteItem('cat_' . $bundle . '_p' . $id);
}
}
}
+38 -30
View File
@@ -4,11 +4,15 @@ namespace App\Clinic\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Category\Repository\CategoryRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\DoctorService\Repository\DoctorServiceRepository;
use App\Insurance\Repository\InsuranceRepository;
use App\Location\Repository\CityRepository;
use App\Location\Repository\ProvinceRepository;
use App\Specialty\Repository\SpecialtyRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
@@ -24,12 +28,16 @@ use Symfony\Component\Uid\Uuid;
class ClinicController extends BaseController
{
public function __construct(
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly CategoryRepository $categoryRepo,
private readonly UserRepository $userRepo,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly SpecialtyRepository $specialtyRepo,
private readonly DoctorServiceRepository $serviceRepo,
private readonly InsuranceRepository $insuranceRepo,
private readonly ProvinceRepository $provinceRepo,
private readonly CityRepository $cityRepo,
private readonly UserRepository $userRepo,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
#[OA\Post(
@@ -399,7 +407,7 @@ class ClinicController extends BaseController
// Location
if (!empty($data['state']) && is_array($data['state'])) {
$clinic->setStateId((int) $data['state'][0]);
$clinic->setProvinceId((int) $data['state'][0]);
}
if (!empty($data['city']) && is_array($data['city'])) {
$clinic->setCityId((int) $data['city'][0]);
@@ -427,10 +435,10 @@ class ClinicController extends BaseController
// ManyToMany: specialties
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
$clinic->getSpecialties()->clear();
foreach ($data['specialties'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$clinic->getSpecialties()->add($cat);
foreach ($data['specialties'] as $id) {
$specialty = $this->specialtyRepo->find((int) $id);
if ($specialty !== null) {
$clinic->getSpecialties()->add($specialty);
}
}
}
@@ -438,10 +446,10 @@ class ClinicController extends BaseController
// ManyToMany: services (doctor_services)
if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) {
$clinic->getServices()->clear();
foreach ($data['doctor_services'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$clinic->getServices()->add($cat);
foreach ($data['doctor_services'] as $id) {
$service = $this->serviceRepo->find((int) $id);
if ($service !== null) {
$clinic->getServices()->add($service);
}
}
}
@@ -449,10 +457,10 @@ class ClinicController extends BaseController
// ManyToMany: insurances
if (array_key_exists('insurance', $data) && is_array($data['insurance'])) {
$clinic->getInsurances()->clear();
foreach ($data['insurance'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$clinic->getInsurances()->add($cat);
foreach ($data['insurance'] as $id) {
$insurance = $this->insuranceRepo->find((int) $id);
if ($insurance !== null) {
$clinic->getInsurances()->add($insurance);
}
}
}
@@ -460,28 +468,28 @@ class ClinicController extends BaseController
private function loadLocationData(Clinic $clinic): array
{
$stateData = [];
$cityData = [];
$provinceData = [];
$cityData = [];
if ($clinic->getStateId() !== null) {
$state = $this->categoryRepo->find($clinic->getStateId());
if ($state !== null) {
$stateData = ['uuid' => $state->getUuid(), 'id' => (string) $state->getId(), 'name' => $state->getLabel()];
if ($clinic->getProvinceId() !== null) {
$province = $this->provinceRepo->find($clinic->getProvinceId());
if ($province !== null) {
$provinceData = ['uuid' => $province->getUuid(), 'id' => (string) $province->getId(), 'name' => $province->getName()];
}
}
if ($clinic->getCityId() !== null) {
$city = $this->categoryRepo->find($clinic->getCityId());
$city = $this->cityRepo->find($clinic->getCityId());
if ($city !== null) {
$cityData = [
'uuid' => $city->getUuid(),
'id' => (string) $city->getId(),
'name' => $city->getLabel(),
'parent' => $city->getParentId() !== null ? (string) $city->getParentId() : null,
'name' => $city->getName(),
'parent' => $city->getProvinceId() !== null ? (string) $city->getProvinceId() : null,
];
}
}
return [$stateData, $cityData];
return [$provinceData, $cityData];
}
private function handleFileUpload(Request $request, string $subDir): JsonResponse
+35 -31
View File
@@ -3,8 +3,10 @@
namespace App\Clinic\Entity;
use App\Auth\Entity\User;
use App\Category\Entity\Category;
use App\Doctor\Entity\Doctor;
use App\DoctorService\Entity\DoctorService;
use App\Insurance\Entity\Insurance;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -14,7 +16,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Table(name: 'clinics')]
#[ORM\Index(columns: ['user_id'], name: 'idx_clinics_owner')]
#[ORM\Index(columns: ['city_id'], name: 'idx_clinics_city')]
#[ORM\Index(columns: ['state_id'], name: 'idx_clinics_state')]
#[ORM\Index(columns: ['province_id'], name: 'idx_clinics_province')]
class Clinic
{
#[ORM\Id]
@@ -56,8 +58,8 @@ class Clinic
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
private ?int $cityId = null;
#[ORM\Column(name: 'state_id', type: 'integer', nullable: true)]
private ?int $stateId = null;
#[ORM\Column(name: 'province_id', type: 'integer', nullable: true)]
private ?int $provinceId = null;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
@@ -82,27 +84,27 @@ class Clinic
)]
private Collection $doctors;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: Specialty::class)]
#[ORM\JoinTable(
name: 'clinic_specialties',
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'specialty_id', referencedColumnName: 'id')]
)]
private Collection $specialties;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: DoctorService::class)]
#[ORM\JoinTable(
name: 'clinic_services',
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'service_id', referencedColumnName: 'id')]
)]
private Collection $services;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: Insurance::class)]
#[ORM\JoinTable(
name: 'clinic_insurances',
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'insurance_id', referencedColumnName: 'id')]
)]
private Collection $insurances;
@@ -130,10 +132,12 @@ class Clinic
public function getLatitude(): ?float { return $this->latitude; }
public function getLongitude(): ?float { return $this->longitude; }
public function getCityId(): ?int { return $this->cityId; }
public function getStateId(): ?int { return $this->stateId; }
public function getProvinceId(): ?int { return $this->provinceId; }
public function getRepresentationId(): ?int { return $this->representationId; }
public function getImagesClinic(): ?array { return $this->imagesClinic; }
public function getClinicLogo(): ?array { return $this->clinicLogo; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function getDoctors(): Collection { return $this->doctors; }
public function getSpecialties(): Collection { return $this->specialties; }
public function getServices(): Collection { return $this->services; }
@@ -148,23 +152,15 @@ class Clinic
public function setLatitude(?float $v): self { $this->latitude = $v; $this->touch(); return $this; }
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
public function setStateId(?int $v): self { $this->stateId = $v; $this->touch(); return $this; }
public function setProvinceId(?int $v): self { $this->provinceId = $v; $this->touch(); return $this; }
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
public function setClinicLogo(?array $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toDetailArray(array $stateData = [], array $cityData = []): array
public function toDetailArray(array $provinceData = [], array $cityData = []): array
{
$formatCat = fn(Category $c) => [
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
];
$formatCatWithParent = fn(Category $c) => [
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null,
];
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -173,14 +169,24 @@ class Clinic
'clinic_logo' => $this->clinicLogo ?? [],
'phone_number' => $this->telephone,
'caption' => $this->info,
'list_bime' => array_map($formatCat, $this->insurances->toArray()),
'specialties' => array_map($formatCatWithParent, $this->specialties->toArray()),
'services' => array_map($formatCat, $this->services->toArray()),
'clinic_specialty' => array_map($formatCatWithParent, $this->specialties->toArray()),
'list_bime' => array_map(fn(Insurance $i) => [
'uuid' => $i->getUuid(), 'id' => (string) $i->getId(), 'name' => $i->getName(),
], $this->insurances->toArray()),
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
'parent' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'services' => array_map(fn(DoctorService $ds) => [
'uuid' => $ds->getUuid(), 'id' => (string) $ds->getId(), 'name' => $ds->getName(),
], $this->services->toArray()),
'clinic_specialty' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
'parent' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'doctors' => $this->doctors->count(),
'doctor_list' => null,
'city' => $cityData ? [$cityData] : [],
'state' => $stateData ? [$stateData] : [],
'state' => $provinceData ? [$provinceData] : [],
'location' => $this->address,
'map' => [
'latitude' => $this->latitude !== null ? (string) $this->latitude : null,
@@ -193,10 +199,6 @@ class Clinic
public function toListArray(): array
{
$formatCat = fn(Category $c) => [
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
];
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -204,7 +206,9 @@ class Clinic
'images_clinic' => $this->imagesClinic ?? [],
'clinic_logo' => $this->clinicLogo ?? [],
'phone_number' => $this->telephone,
'specialties' => array_map($formatCat, $this->specialties->toArray()),
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
], $this->specialties->toArray()),
'doctors' => $this->doctors->count(),
'24_7' => $this->is247,
];
+28 -39
View File
@@ -4,14 +4,17 @@ namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Category\Repository\CategoryRepository;
use App\DoctorService\Repository\DoctorServiceRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Location\Repository\CityRepository;
use App\Location\Repository\ProvinceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use App\Specialty\Repository\SpecialtyRepository;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -25,7 +28,10 @@ class DoctorController extends BaseController
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly DoctorAddressRepository $addressRepo,
private readonly CategoryRepository $categoryRepo,
private readonly SpecialtyRepository $specialtyRepo,
private readonly DoctorServiceRepository $serviceRepo,
private readonly ProvinceRepository $provinceRepo,
private readonly CityRepository $cityRepo,
private readonly UserRepository $userRepo,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
@@ -639,58 +645,41 @@ class DoctorController extends BaseController
$doctor->setImages($data['images']);
}
// Specialties (array of category IDs)
// Specialties
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
$doctor->getSpecialties()->clear();
foreach ($data['specialties'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$doctor->getSpecialties()->add($cat);
}
foreach ($data['specialties'] as $id) {
$s = $this->specialtyRepo->find((int) $id);
if ($s !== null) $doctor->getSpecialties()->add($s);
}
}
// Expertise / doctor_services
if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) {
$doctor->getExpertise()->clear();
foreach ($data['doctor_services'] as $catId) {
$cat = is_numeric($catId)
? $this->categoryRepo->find((int) $catId)
: $this->categoryRepo->findOneBy(['label' => $catId, 'bundle' => 'doctor_services']);
if ($cat !== null) {
$doctor->getExpertise()->add($cat);
}
}
}
if (array_key_exists('expertise', $data) && is_array($data['expertise'])) {
$doctor->getExpertise()->clear();
foreach ($data['expertise'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$doctor->getExpertise()->add($cat);
}
// Services (doctor_services / expertise)
$servicesKey = array_key_exists('doctor_services', $data) ? 'doctor_services'
: (array_key_exists('expertise', $data) ? 'expertise' : null);
if ($servicesKey !== null && is_array($data[$servicesKey])) {
$doctor->getServices()->clear();
foreach ($data[$servicesKey] as $id) {
$ds = $this->serviceRepo->find((int) $id);
if ($ds !== null) $doctor->getServices()->add($ds);
}
}
// States
// Provinces (states)
if (array_key_exists('states', $data) && is_array($data['states'])) {
$doctor->getStates()->clear();
foreach ($data['states'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$doctor->getStates()->add($cat);
}
$doctor->getProvinces()->clear();
foreach ($data['states'] as $id) {
$p = $this->provinceRepo->find((int) $id);
if ($p !== null) $doctor->getProvinces()->add($p);
}
}
// Cities
if (array_key_exists('cities', $data) && is_array($data['cities'])) {
$doctor->getCities()->clear();
foreach ($data['cities'] as $catId) {
$cat = $this->categoryRepo->find((int) $catId);
if ($cat !== null) {
$doctor->getCities()->add($cat);
}
foreach ($data['cities'] as $id) {
$c = $this->cityRepo->find((int) $id);
if ($c !== null) $doctor->getCities()->add($c);
}
}
}
+36 -40
View File
@@ -3,7 +3,10 @@
namespace App\Doctor\Entity;
use App\Auth\Entity\User;
use App\Category\Entity\Category;
use App\DoctorService\Entity\DoctorService;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -72,35 +75,35 @@ class Doctor
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: Specialty::class)]
#[ORM\JoinTable(
name: 'doctor_specialties',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'specialty_id', referencedColumnName: 'id')]
)]
private Collection $specialties;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: DoctorService::class)]
#[ORM\JoinTable(
name: 'doctor_expertise',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'service_id', referencedColumnName: 'id')]
)]
private Collection $expertise;
private Collection $services;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: Province::class)]
#[ORM\JoinTable(
name: 'doctor_states',
name: 'doctor_provinces',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id')]
)]
private Collection $states;
private Collection $provinces;
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\ManyToMany(targetEntity: City::class)]
#[ORM\JoinTable(
name: 'doctor_cities',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
inverseJoinColumns: [new ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id')]
)]
private Collection $cities;
@@ -115,8 +118,8 @@ class Doctor
$this->createdAt = time();
$this->updatedAt = time();
$this->specialties = new ArrayCollection();
$this->expertise = new ArrayCollection();
$this->states = new ArrayCollection();
$this->services = new ArrayCollection();
$this->provinces = new ArrayCollection();
$this->cities = new ArrayCollection();
$this->addresses = new ArrayCollection();
}
@@ -139,8 +142,8 @@ class Doctor
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function getSpecialties(): Collection { return $this->specialties; }
public function getExpertise(): Collection { return $this->expertise; }
public function getStates(): Collection { return $this->states; }
public function getServices(): Collection { return $this->services; }
public function getProvinces(): Collection { return $this->provinces; }
public function getCities(): Collection { return $this->cities; }
public function getAddresses(): Collection { return $this->addresses; }
@@ -176,7 +179,9 @@ class Doctor
'gender' => $this->gender,
'degree' => $this->degree,
'img' => $this->images ?? [],
'specialties' => $this->formatCategories($this->specialties),
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
], $this->specialties->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'free_turn' => 'نوبت آزادی موجود نیست',
@@ -197,36 +202,27 @@ class Doctor
'medical_system_code' => $this->medicalSystemCode,
'detail' => $this->info,
'degree' => $this->degree,
'specialties' => $this->formatCategories($this->specialties),
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'img' => $this->images ?? [],
'expertise' => $this->formatCategories($this->expertise),
'expertise' => array_map(fn(DoctorService $ds) => [
'uuid' => $ds->getUuid(), 'id' => (string) $ds->getId(), 'name' => $ds->getName(),
], $this->services->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'free_turn' => 'نوبت آزادی موجود نیست',
'hours_of_work' => 'برنامه کاری تنظیم نشده',
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
'average_rate' => ['total_rates' => null],
'state' => $this->formatCategories($this->states),
'city' => $this->formatCategoriesWithParent($this->cities),
'state' => array_map(fn(Province $p) => [
'uuid' => $p->getUuid(), 'id' => (string) $p->getId(), 'name' => $p->getName(),
], $this->provinces->toArray()),
'city' => array_map(fn(City $c) => [
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getName(),
'parent' => $c->getProvince() !== null ? (string) $c->getProvince()->getId() : null,
], $this->cities->toArray()),
];
}
private function formatCategories(Collection $collection): array
{
return array_map(fn(Category $c) => [
'uuid' => $c->getUuid(),
'id' => (string) $c->getId(),
'name' => $c->getLabel(),
], $collection->toArray());
}
private function formatCategoriesWithParent(Collection $collection): array
{
return array_map(fn(Category $c) => [
'uuid' => $c->getUuid(),
'id' => (string) $c->getId(),
'name' => $c->getLabel(),
'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null,
], $collection->toArray());
}
}
+2 -2
View File
@@ -33,12 +33,12 @@ class DoctorRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.states', 'st')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->distinct();
if (!empty($filters['state'])) {
$qb->andWhere('st.id = :state')->setParameter('state', (int) $filters['state']);
$qb->andWhere('pr.id = :state')->setParameter('state', (int) $filters['state']);
}
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']);
@@ -0,0 +1,132 @@
<?php
namespace App\DoctorService\Controller;
use App\DoctorService\Entity\DoctorService;
use App\DoctorService\Repository\DoctorServiceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Specialty\Repository\SpecialtyRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DoctorServiceController extends BaseController
{
public function __construct(
private readonly DoctorServiceRepository $repo,
private readonly SpecialtyRepository $specialtyRepo,
) {}
#[Route('/api/v1/doctor-services', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$specialtyId = $request->query->get('specialty_id');
$items = array_map(
fn(DoctorService $ds) => $ds->toArray(),
$this->repo->findActive($specialtyId !== null ? (int) $specialtyId : null)
);
return $this->success(['data' => $items]);
}
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/doctor-service', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$slug = $data['slug'] ?? $this->slugify($name);
$specialty = null;
if (!empty($data['specialty_id'])) {
$specialty = $this->specialtyRepo->find((int) $data['specialty_id']);
}
$service = new DoctorService($name, $slug, $specialty);
if (isset($data['status'])) $service->setStatus((int) $data['status']);
if (isset($data['weight'])) $service->setWeight((int) $data['weight']);
$this->repo->save($service);
return $this->success(['data' => $service->toArray()], 201);
}
#[Route('/api/v1/admin/doctor-service/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$service = $this->repo->find($id);
if ($service === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'خدمت یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $service->setName($data['name']);
if (isset($data['slug'])) $service->setSlug($data['slug']);
if (isset($data['status'])) $service->setStatus((int) $data['status']);
if (isset($data['weight'])) $service->setWeight((int) $data['weight']);
if (array_key_exists('specialty_id', $data)) {
$specialty = $data['specialty_id'] ? $this->specialtyRepo->find((int) $data['specialty_id']) : null;
$service->setSpecialty($specialty);
}
$this->repo->save($service);
return $this->success(['data' => $service->toArray()]);
}
#[Route('/api/v1/admin/doctor-service/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$service = $this->repo->find($id);
if ($service === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'خدمت یافت نشد', 404);
}
$this->repo->remove($service);
return $this->success(['message' => 'خدمت با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/doctor-services', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$specialtyId = $request->query->get('specialty_id');
$qb = $this->repo->createQueryBuilder('ds')
->orderBy('ds.weight', 'ASC')
->addOrderBy('ds.name', 'ASC');
if ($search !== '') {
$qb->andWhere('ds.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($specialtyId !== null && $specialtyId !== '') {
$qb->andWhere('ds.specialty = :sp')->setParameter('sp', (int) $specialtyId);
}
$total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
return $this->paginated(
array_map(fn(DoctorService $ds) => $ds->toArray(), $rows),
(int) $total, $page, $limit
);
}
private function slugify(string $text): string
{
$text = mb_strtolower(trim($text));
$text = preg_replace('/\s+/', '-', $text);
$text = preg_replace('/[^\p{L}\p{N}\-]/u', '', $text);
return $text ?: 'service-' . time();
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\DoctorService\Entity;
use App\DoctorService\Repository\DoctorServiceRepository;
use App\Specialty\Entity\Specialty;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DoctorServiceRepository::class)]
#[ORM\Table(name: 'doctor_services')]
#[ORM\UniqueConstraint(name: 'uq_doctor_services_slug', columns: ['slug'])]
#[ORM\Index(columns: ['status'], name: 'idx_doctor_services_status')]
#[ORM\Index(columns: ['specialty_id'], name: 'idx_doctor_services_specialty')]
class DoctorService
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
#[ORM\Column(type: 'integer')]
private int $weight = 0;
#[ORM\ManyToOne(targetEntity: Specialty::class)]
#[ORM\JoinColumn(name: 'specialty_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Specialty $specialty = null;
public function __construct(string $name, string $slug, ?Specialty $specialty = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->slug = $slug;
$this->specialty = $specialty;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getSlug(): string { return $this->slug; }
public function getStatus(): int { return $this->status; }
public function getWeight(): int { return $this->weight; }
public function getSpecialty(): ?Specialty { return $this->specialty; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setSlug(string $v): self { $this->slug = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function setWeight(int $v): self { $this->weight = $v; return $this; }
public function setSpecialty(?Specialty $v): self { $this->specialty = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
'weight' => $this->weight,
'specialty_id' => $this->specialty?->getId(),
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\DoctorService\Repository;
use App\DoctorService\Entity\DoctorService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DoctorServiceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DoctorService::class);
}
/** @return DoctorService[] */
public function findActive(?int $specialtyId = null): array
{
$qb = $this->createQueryBuilder('ds')
->where('ds.status = 1')
->orderBy('ds.weight', 'ASC')
->addOrderBy('ds.name', 'ASC');
if ($specialtyId !== null) {
$qb->andWhere('ds.specialty = :specialty')->setParameter('specialty', $specialtyId);
}
return $qb->getQuery()->getResult();
}
public function save(DoctorService $service, bool $flush = true): void
{
$this->getEntityManager()->persist($service);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(DoctorService $service, bool $flush = true): void
{
$this->getEntityManager()->remove($service);
if ($flush) $this->getEntityManager()->flush();
}
}
+202 -45
View File
@@ -3,36 +3,196 @@
namespace App\Insurance\Controller;
use App\Auth\Entity\User;
use App\Category\Repository\CategoryRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Insurance\Entity\DoctorInsurance;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Repository\DoctorInsuranceRepository;
use App\Insurance\Repository\InsuranceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class InsuranceController extends BaseController
{
public function __construct(
private readonly DoctorInsuranceRepository $repository,
private readonly InsuranceRepository $insuranceRepo,
private readonly DoctorInsuranceRepository $doctorInsuranceRepo,
private readonly DoctorRepository $doctorRepo,
private readonly CategoryRepository $categoryRepo,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
#[Route('/api/v1/insurance/', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorId = $data['doctor_id'] ?? null;
$categoryId = $data['category_id'] ?? null;
// ── Public list ───────────────────────────────────────────────────────────
if (!$doctorId || !$categoryId) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_id و category_id الزامی است', 422);
#[Route('/api/v1/insurances', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$typeParam = $request->query->get('type');
$type = null;
if ($typeParam !== null) {
$type = InsuranceType::tryFrom($typeParam);
}
$items = array_map(fn(Insurance $i) => $i->toArray(), $this->insuranceRepo->findActive($type));
return $this->success(['data' => $items]);
}
// ── Admin CRUD — Insurance ────────────────────────────────────────────────
#[Route('/api/v1/admin/insurance', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
$typeVal = $data['type'] ?? null;
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$type = InsuranceType::tryFrom((string) $typeVal);
if ($type === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'type باید basic یا supplementary باشد', 422, 'type');
}
$insurance = new Insurance($name, $type);
if (isset($data['logo_url'])) $insurance->setLogoUrl($data['logo_url']);
if (isset($data['status'])) $insurance->setStatus((int) $data['status']);
$this->insuranceRepo->save($insurance);
return $this->success(['data' => $insurance->toArray()], 201);
}
#[Route('/api/v1/admin/insurance/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $insurance->setName($data['name']);
if (isset($data['type'])) {
$type = InsuranceType::tryFrom($data['type']);
if ($type !== null) $insurance->setType($type);
}
if (array_key_exists('logo_url', $data)) $insurance->setLogoUrl($data['logo_url']);
if (isset($data['status'])) $insurance->setStatus((int) $data['status']);
$this->insuranceRepo->save($insurance);
return $this->success(['data' => $insurance->toArray()]);
}
#[Route('/api/v1/admin/insurance/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$this->insuranceRepo->remove($insurance);
return $this->success(['message' => 'بیمه با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/insurances', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$typeParam = $request->query->get('type');
$qb = $this->insuranceRepo->createQueryBuilder('i')->orderBy('i.name', 'ASC');
if ($search !== '') {
$qb->andWhere('i.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($typeParam !== null && $typeParam !== '') {
$qb->andWhere('i.type = :t')->setParameter('t', $typeParam);
}
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
return $this->paginated(
array_map(fn(Insurance $i) => $i->toArray(), $rows),
(int) $total, $page, $limit
);
}
// ── Upload logo ───────────────────────────────────────────────────────────
#[Route('/api/v1/admin/insurance/{id}/upload-logo', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function uploadLogo(int $id, Request $request): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$content = $request->getContent();
$disposition = $request->headers->get('Content-Disposition', '');
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
$filename = $m[1] ?? 'logo.jpg';
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
file_put_contents($tmpPath, $content);
try {
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
$mime = $this->fileValidator->detectMimeType($tmpPath);
$year = date('Y');
$month = date('m');
$dir = $this->projectDir . '/public/uploads/insurances/logo/' . $year . '-' . $month;
if (!is_dir($dir)) mkdir($dir, 0755, true);
$storedName = uniqid('', true) . '_' . $safeFilename;
rename($tmpPath, $dir . '/' . $storedName);
$url = '/uploads/insurances/logo/' . $year . '-' . $month . '/' . $storedName;
$insurance->setLogoUrl($url);
$this->insuranceRepo->save($insurance);
return $this->success([
'url' => $url,
'uuid' => Uuid::v4()->toRfc4122(),
'filename' => $safeFilename,
'filemime' => $mime,
'filesize' => strlen($content),
]);
} catch (\Throwable $e) {
if (file_exists($tmpPath)) unlink($tmpPath);
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
}
}
// ── DoctorInsurance CRUD ──────────────────────────────────────────────────
#[Route('/api/v1/insurance/', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorId = $data['doctor_id'] ?? null;
$insuranceId = $data['insurance_id'] ?? null;
if (!$doctorId || !$insuranceId) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_id و insurance_id الزامی است', 422);
}
$doctor = $this->doctorRepo->find((int) $doctorId);
@@ -40,79 +200,76 @@ class InsuranceController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
// Only the doctor owner or admin can add insurance
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$category = $this->categoryRepo->find((int) $categoryId);
if ($category === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی بیمه یافت نشد', 404);
$insurance = $this->insuranceRepo->find((int) $insuranceId);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
// Check duplicate
$existing = $this->repository->findOneBy(['doctor' => $doctor, 'category' => $category]);
$existing = $this->doctorInsuranceRepo->findOneBy(['doctor' => $doctor, 'insurance' => $insurance]);
if ($existing !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این بیمه قبلاً اضافه شده است', 409);
}
$insurance = new DoctorInsurance($doctor, $category);
$doctorInsurance = new DoctorInsurance($doctor, $insurance);
if (isset($data['price'])) {
$insurance->setPrice((int) $data['price']);
$doctorInsurance->setPrice((int) $data['price']);
}
$this->repository->save($insurance);
return $this->success(['data' => $insurance->toArray()], 201);
$this->doctorInsuranceRepo->save($doctorInsurance);
return $this->success(['data' => $doctorInsurance->toArray()], 201);
}
#[Route('/api/v1/insurance/{id}', methods: ['GET'])]
public function show(int $id): JsonResponse
public function showDoctorInsurance(int $id): JsonResponse
{
$insurance = $this->repository->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
return $this->success(['data' => $insurance->toArray()]);
return $this->success(['data' => $doctorInsurance->toArray()]);
}
#[Route('/api/v1/insurance/{id}', methods: ['PATCH'])]
public function update(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
{
$insurance = $this->repository->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if ($doctorInsurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('price', $data)) {
$insurance->setPrice($data['price'] !== null ? (int) $data['price'] : null);
$doctorInsurance->setPrice($data['price'] !== null ? (int) $data['price'] : null);
}
$this->repository->save($insurance);
return $this->success(['data' => $insurance->toArray()]);
$this->doctorInsuranceRepo->save($doctorInsurance);
return $this->success(['data' => $doctorInsurance->toArray()]);
}
#[Route('/api/v1/insurance/{id}', methods: ['DELETE'])]
public function delete(int $id, #[CurrentUser] User $user): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$insurance = $this->repository->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if ($doctorInsurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$this->repository->remove($insurance);
return $this->success(['message' => 'بیمه با موفقیت حذف شد']);
$this->doctorInsuranceRepo->remove($doctorInsurance);
return $this->success(['message' => 'بیمه پزشک با موفقیت حذف شد']);
}
}
+20 -20
View File
@@ -2,14 +2,14 @@
namespace App\Insurance\Entity;
use App\Category\Entity\Category;
use App\Doctor\Entity\Doctor;
use App\Insurance\Repository\DoctorInsuranceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: DoctorInsuranceRepository::class)]
#[ORM\Table(name: 'doctor_insurances')]
#[ORM\UniqueConstraint(name: 'idx_doctor_insurance', columns: ['doctor_id', 'category_id'])]
#[ORM\Index(columns: ['category_id'], name: 'idx_doctor_insurance_cat')]
#[ORM\UniqueConstraint(name: 'idx_doctor_insurance', columns: ['doctor_id', 'insurance_id'])]
#[ORM\Index(columns: ['insurance_id'], name: 'idx_doctor_insurance_ins')]
class DoctorInsurance
{
#[ORM\Id]
@@ -21,35 +21,35 @@ class DoctorInsurance
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false)]
private Category $category;
#[ORM\ManyToOne(targetEntity: Insurance::class)]
#[ORM\JoinColumn(name: 'insurance_id', referencedColumnName: 'id', nullable: false)]
private Insurance $insurance;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $price = null;
public function __construct(Doctor $doctor, Category $category)
public function __construct(Doctor $doctor, Insurance $insurance)
{
$this->doctor = $doctor;
$this->category = $category;
$this->doctor = $doctor;
$this->insurance = $insurance;
}
public function getId(): ?int { return $this->id; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getCategory(): Category { return $this->category; }
public function getPrice(): ?int { return $this->price; }
public function getId(): ?int { return $this->id; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getInsurance(): Insurance { return $this->insurance; }
public function getPrice(): ?int { return $this->price; }
public function setPrice(?int $v): self { $this->price = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'doctor_id' => $this->doctor->getId(),
'category_id' => $this->category->getId(),
'category_name' => $this->category->getLabel(),
'bundle' => $this->category->getBundle(),
'price' => $this->price,
'id' => $this->id,
'doctor_id' => $this->doctor->getId(),
'insurance_id' => $this->insurance->getId(),
'insurance_name' => $this->insurance->getName(),
'type' => $this->insurance->getType()->value,
'price' => $this->price,
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Repository\InsuranceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: InsuranceRepository::class)]
#[ORM\Table(name: 'insurances')]
#[ORM\Index(columns: ['type'], name: 'idx_insurances_type')]
#[ORM\Index(columns: ['status'], name: 'idx_insurances_status')]
class Insurance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 20, enumType: InsuranceType::class)]
private InsuranceType $type;
#[ORM\Column(name: 'logo_url', type: 'string', length: 500, nullable: true)]
private ?string $logoUrl = null;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
public function __construct(string $name, InsuranceType $type)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->type = $type;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getType(): InsuranceType { return $this->type; }
public function getLogoUrl(): ?string { return $this->logoUrl; }
public function getStatus(): int { return $this->status; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setType(InsuranceType $v): self { $this->type = $v; return $this; }
public function setLogoUrl(?string $v): self { $this->logoUrl = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'type' => $this->type->value,
'logo_url' => $this->logoUrl,
'status' => $this->status,
];
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Insurance\Enum;
enum InsuranceType: string
{
case Basic = 'basic';
case Supplementary = 'supplementary';
public function label(): string
{
return match($this) {
self::Basic => 'بیمه پایه',
self::Supplementary => 'بیمه تکمیلی',
};
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InsuranceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Insurance::class);
}
/** @return Insurance[] */
public function findActive(?InsuranceType $type = null): array
{
$qb = $this->createQueryBuilder('i')
->where('i.status = 1')
->orderBy('i.name', 'ASC');
if ($type !== null) {
$qb->andWhere('i.type = :type')->setParameter('type', $type->value);
}
return $qb->getQuery()->getResult();
}
public function save(Insurance $insurance, bool $flush = true): void
{
$this->getEntityManager()->persist($insurance);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(Insurance $insurance, bool $flush = true): void
{
$this->getEntityManager()->remove($insurance);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,226 @@
<?php
namespace App\Location\Controller;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Location\Repository\CityRepository;
use App\Location\Repository\ProvinceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class LocationController extends BaseController
{
public function __construct(
private readonly ProvinceRepository $provinceRepo,
private readonly CityRepository $cityRepo,
) {}
// ── Public endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/provinces', methods: ['GET'])]
public function provinces(): JsonResponse
{
$items = array_map(fn(Province $p) => $p->toArray(), $this->provinceRepo->findActive());
return $this->success(['data' => $items]);
}
#[Route('/api/v1/cities', methods: ['GET'])]
public function cities(Request $request): JsonResponse
{
$provinceId = $request->query->get('province_id');
$items = array_map(
fn(City $c) => $c->toArray(),
$this->cityRepo->findActive($provinceId !== null ? (int) $provinceId : null)
);
return $this->success(['data' => $items]);
}
// ── Admin CRUD — Province ─────────────────────────────────────────────────
#[Route('/api/v1/admin/province', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function createProvince(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$province = new Province($name);
if (isset($data['status'])) $province->setStatus((int) $data['status']);
if (isset($data['weight'])) $province->setWeight((int) $data['weight']);
$this->provinceRepo->save($province);
return $this->success(['data' => $province->toArray()], 201);
}
#[Route('/api/v1/admin/province/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function updateProvince(int $id, Request $request): JsonResponse
{
$province = $this->provinceRepo->find($id);
if ($province === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'استان یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $province->setName($data['name']);
if (isset($data['status'])) $province->setStatus((int) $data['status']);
if (isset($data['weight'])) $province->setWeight((int) $data['weight']);
$this->provinceRepo->save($province);
return $this->success(['data' => $province->toArray()]);
}
#[Route('/api/v1/admin/province/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteProvince(int $id): JsonResponse
{
$province = $this->provinceRepo->find($id);
if ($province === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'استان یافت نشد', 404);
}
$this->provinceRepo->remove($province);
return $this->success(['message' => 'استان با موفقیت حذف شد']);
}
// ── Admin CRUD — City ─────────────────────────────────────────────────────
#[Route('/api/v1/admin/city', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function createCity(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$province = null;
if (!empty($data['province_id'])) {
$province = $this->provinceRepo->find((int) $data['province_id']);
}
$city = new City($name, $province);
$this->applyCityData($city, $data);
$this->cityRepo->save($city);
return $this->success(['data' => $city->toArray()], 201);
}
#[Route('/api/v1/admin/city/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function updateCity(int $id, Request $request): JsonResponse
{
$city = $this->cityRepo->find($id);
if ($city === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $city->setName($data['name']);
if (array_key_exists('province_id', $data)) {
$province = $data['province_id'] ? $this->provinceRepo->find((int) $data['province_id']) : null;
$city->setProvince($province);
}
$this->applyCityData($city, $data);
$this->cityRepo->save($city);
return $this->success(['data' => $city->toArray()]);
}
#[Route('/api/v1/admin/city/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteCity(int $id): JsonResponse
{
$city = $this->cityRepo->find($id);
if ($city === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 404);
}
$this->cityRepo->remove($city);
return $this->success(['message' => 'شهر با موفقیت حذف شد']);
}
// ── Admin paginated lists ─────────────────────────────────────────────────
#[Route('/api/v1/admin/provinces', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminProvinces(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$qb = $this->provinceRepo->createQueryBuilder('p')->orderBy('p.weight', 'ASC')->addOrderBy('p.name', 'ASC');
if ($search !== '') {
$qb->andWhere('p.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
$total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
$items = array_map(fn(array $r) => [
'id' => $r['id'], 'uuid' => $r['uuid'], 'name' => $r['name'],
'status' => $r['status'], 'weight' => $r['weight'],
], $rows);
return $this->paginated($items, (int) $total, $page, $limit);
}
#[Route('/api/v1/admin/cities', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCities(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$provinceId = $request->query->get('province_id');
$qb = $this->cityRepo->createQueryBuilder('c')
->leftJoin('c.province', 'p')
->addSelect('p')
->orderBy('c.weight', 'ASC')
->addOrderBy('c.name', 'ASC');
if ($search !== '') {
$qb->andWhere('c.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($provinceId !== null && $provinceId !== '') {
$qb->andWhere('c.province = :province')->setParameter('province', (int) $provinceId);
}
$total = (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
$cities = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
$items = array_map(fn(City $c) => $c->toArray(), $cities);
return $this->paginated($items, (int) $total, $page, $limit);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function applyCityData(City $city, array $data): void
{
if (isset($data['status'])) $city->setStatus((int) $data['status']);
if (isset($data['weight'])) $city->setWeight((int) $data['weight']);
if (array_key_exists('representation_id', $data))
$city->setRepresentationId($data['representation_id'] !== null ? (int) $data['representation_id'] : null);
if (array_key_exists('contact_phone', $data)) $city->setContactPhone($data['contact_phone']);
if (array_key_exists('email', $data)) $city->setEmail($data['email']);
if (array_key_exists('description', $data)) $city->setDescription($data['description']);
if (array_key_exists('slogan', $data)) $city->setSlogan($data['slogan']);
if (array_key_exists('domain', $data)) $city->setDomain($data['domain']);
if (array_key_exists('keywords', $data)) $city->setKeywords($data['keywords']);
if (array_key_exists('footer_description', $data)) $city->setFooterDescription($data['footer_description']);
if (array_key_exists('social_media', $data)) $city->setSocialMedia($data['social_media']);
if (array_key_exists('logo_url', $data)) $city->setLogoUrl($data['logo_url']);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Location\Entity;
use App\Location\Repository\CityRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: CityRepository::class)]
#[ORM\Table(name: 'cities')]
#[ORM\Index(columns: ['province_id'], name: 'idx_cities_province')]
#[ORM\Index(columns: ['status'], name: 'idx_cities_status')]
#[ORM\Index(columns: ['representation_id'], name: 'idx_cities_representation')]
class City
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
#[ORM\Column(type: 'integer')]
private int $weight = 0;
#[ORM\ManyToOne(targetEntity: Province::class)]
#[ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id', nullable: true)]
private ?Province $province = null;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
#[ORM\Column(name: 'contact_phone', type: 'string', length: 255, nullable: true)]
private ?string $contactPhone = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $slogan = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $domain = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $keywords = null;
#[ORM\Column(name: 'footer_description', type: 'text', nullable: true)]
private ?string $footerDescription = null;
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
private ?array $socialMedia = null;
#[ORM\Column(name: 'logo_url', type: 'string', length: 500, nullable: true)]
private ?string $logoUrl = null;
public function __construct(string $name, ?Province $province = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->province = $province;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getStatus(): int { return $this->status; }
public function getWeight(): int { return $this->weight; }
public function getProvince(): ?Province { return $this->province; }
public function getRepresentationId(): ?int { return $this->representationId; }
public function getContactPhone(): ?string { return $this->contactPhone; }
public function getEmail(): ?string { return $this->email; }
public function getDescription(): ?string { return $this->description; }
public function getSlogan(): ?string { return $this->slogan; }
public function getDomain(): ?string { return $this->domain; }
public function getKeywords(): ?string { return $this->keywords; }
public function getFooterDescription(): ?string { return $this->footerDescription; }
public function getSocialMedia(): ?array { return $this->socialMedia; }
public function getLogoUrl(): ?string { return $this->logoUrl; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function setWeight(int $v): self { $this->weight = $v; return $this; }
public function setProvince(?Province $v): self { $this->province = $v; return $this; }
public function setRepresentationId(?int $v): self { $this->representationId = $v; return $this; }
public function setContactPhone(?string $v): self { $this->contactPhone = $v; return $this; }
public function setEmail(?string $v): self { $this->email = $v; return $this; }
public function setDescription(?string $v): self { $this->description = $v; return $this; }
public function setSlogan(?string $v): self { $this->slogan = $v; return $this; }
public function setDomain(?string $v): self { $this->domain = $v; return $this; }
public function setKeywords(?string $v): self { $this->keywords = $v; return $this; }
public function setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; }
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; }
public function setLogoUrl(?string $v): self { $this->logoUrl = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'status' => $this->status,
'weight' => $this->weight,
'province_id' => $this->province?->getId(),
'province_name' => $this->province?->getName(),
'representation_id' => $this->representationId,
'contact_phone' => $this->contactPhone,
'email' => $this->email,
'description' => $this->description,
'slogan' => $this->slogan,
'domain' => $this->domain,
'keywords' => $this->keywords,
'footer_description' => $this->footerDescription,
'social_media' => $this->socialMedia,
'logo_url' => $this->logoUrl,
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Location\Entity;
use App\Location\Repository\ProvinceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ProvinceRepository::class)]
#[ORM\Table(name: 'provinces')]
#[ORM\Index(columns: ['status'], name: 'idx_provinces_status')]
class Province
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
#[ORM\Column(type: 'integer')]
private int $weight = 0;
public function __construct(string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getStatus(): int { return $this->status; }
public function getWeight(): int { return $this->weight; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function setWeight(int $v): self { $this->weight = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'status' => $this->status,
'weight' => $this->weight,
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Location\Repository;
use App\Location\Entity\City;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class CityRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, City::class);
}
/** @return City[] */
public function findActive(?int $provinceId = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.status = 1')
->orderBy('c.weight', 'ASC')
->addOrderBy('c.name', 'ASC');
if ($provinceId !== null) {
$qb->andWhere('c.province = :province')->setParameter('province', $provinceId);
}
return $qb->getQuery()->getResult();
}
public function save(City $city, bool $flush = true): void
{
$this->getEntityManager()->persist($city);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(City $city, bool $flush = true): void
{
$this->getEntityManager()->remove($city);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Location\Repository;
use App\Location\Entity\Province;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProvinceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Province::class);
}
/** @return Province[] */
public function findActive(): array
{
return $this->createQueryBuilder('p')
->where('p.status = 1')
->orderBy('p.weight', 'ASC')
->addOrderBy('p.name', 'ASC')
->getQuery()
->getResult();
}
public function save(Province $province, bool $flush = true): void
{
$this->getEntityManager()->persist($province);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(Province $province, bool $flush = true): void
{
$this->getEntityManager()->remove($province);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Specialty\Controller;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class SpecialtyController extends BaseController
{
public function __construct(
private readonly SpecialtyRepository $repo,
) {}
#[Route('/api/v1/specialties', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$parentId = $request->query->get('parent_id');
$items = array_map(
fn(Specialty $s) => $s->toArray(),
$this->repo->findActive($parentId !== null ? (int) $parentId : null)
);
return $this->success(['data' => $items]);
}
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/specialty', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$slug = $data['slug'] ?? $this->slugify($name);
if ($this->repo->findBySlug($slug) !== null) {
$slug = $slug . '-' . time();
}
$parent = null;
if (!empty($data['parent_id'])) {
$parent = $this->repo->find((int) $data['parent_id']);
}
$specialty = new Specialty($name, $slug, $parent);
if (isset($data['status'])) $specialty->setStatus((int) $data['status']);
if (isset($data['weight'])) $specialty->setWeight((int) $data['weight']);
$this->repo->save($specialty);
return $this->success(['data' => $specialty->toArray()], 201);
}
#[Route('/api/v1/admin/specialty/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$specialty = $this->repo->find($id);
if ($specialty === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تخصص یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $specialty->setName($data['name']);
if (isset($data['slug'])) $specialty->setSlug($data['slug']);
if (isset($data['status'])) $specialty->setStatus((int) $data['status']);
if (isset($data['weight'])) $specialty->setWeight((int) $data['weight']);
if (array_key_exists('parent_id', $data)) {
$parent = $data['parent_id'] ? $this->repo->find((int) $data['parent_id']) : null;
$specialty->setParent($parent);
}
$this->repo->save($specialty);
return $this->success(['data' => $specialty->toArray()]);
}
#[Route('/api/v1/admin/specialty/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$specialty = $this->repo->find($id);
if ($specialty === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تخصص یافت نشد', 404);
}
$this->repo->remove($specialty);
return $this->success(['message' => 'تخصص با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/specialties', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$qb = $this->repo->createQueryBuilder('s')
->leftJoin('s.parent', 'p')
->addSelect('p')
->orderBy('s.weight', 'ASC')
->addOrderBy('s.name', 'ASC');
if ($search !== '') {
$qb->andWhere('s.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
$total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
return $this->paginated(
array_map(fn(Specialty $s) => $s->toArray(), $rows),
(int) $total, $page, $limit
);
}
private function slugify(string $text): string
{
$text = mb_strtolower(trim($text));
$text = preg_replace('/\s+/', '-', $text);
$text = preg_replace('/[^\p{L}\p{N}\-]/u', '', $text);
return $text ?: 'specialty-' . time();
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Specialty\Entity;
use App\Specialty\Repository\SpecialtyRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SpecialtyRepository::class)]
#[ORM\Table(name: 'specialties')]
#[ORM\UniqueConstraint(name: 'uq_specialties_slug', columns: ['slug'])]
#[ORM\Index(columns: ['status'], name: 'idx_specialties_status')]
#[ORM\Index(columns: ['parent_id'], name: 'idx_specialties_parent')]
class Specialty
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
#[ORM\Column(type: 'integer')]
private int $weight = 0;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?self $parent = null;
public function __construct(string $name, string $slug, ?self $parent = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->slug = $slug;
$this->parent = $parent;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getSlug(): string { return $this->slug; }
public function getStatus(): int { return $this->status; }
public function getWeight(): int { return $this->weight; }
public function getParent(): ?self { return $this->parent; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setSlug(string $v): self { $this->slug = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function setWeight(int $v): self { $this->weight = $v; return $this; }
public function setParent(?self $v): self { $this->parent = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
'weight' => $this->weight,
'parent_id' => $this->parent?->getId(),
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Specialty\Repository;
use App\Specialty\Entity\Specialty;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SpecialtyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Specialty::class);
}
/** @return Specialty[] */
public function findActive(?int $parentId = null): array
{
$qb = $this->createQueryBuilder('s')
->where('s.status = 1')
->orderBy('s.weight', 'ASC')
->addOrderBy('s.name', 'ASC');
if ($parentId !== null) {
$qb->andWhere('s.parent = :parent')->setParameter('parent', $parentId);
}
return $qb->getQuery()->getResult();
}
public function findBySlug(string $slug): ?Specialty
{
return $this->findOneBy(['slug' => $slug]);
}
public function save(Specialty $specialty, bool $flush = true): void
{
$this->getEntityManager()->persist($specialty);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(Specialty $specialty, bool $flush = true): void
{
$this->getEntityManager()->remove($specialty);
if ($flush) $this->getEntityManager()->flush();
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Tag\Controller;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Tag\Entity\Tag;
use App\Tag\Repository\TagRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TagController extends BaseController
{
public function __construct(
private readonly TagRepository $repo,
) {}
#[Route('/api/v1/tags', methods: ['GET'])]
public function list(): JsonResponse
{
$items = array_map(fn(Tag $t) => $t->toArray(), $this->repo->findActive());
return $this->success(['data' => $items]);
}
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/tag', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$slug = $data['slug'] ?? $this->slugify($name);
$tag = new Tag($name, $slug);
if (isset($data['status'])) $tag->setStatus((int) $data['status']);
$this->repo->save($tag);
return $this->success(['data' => $tag->toArray()], 201);
}
#[Route('/api/v1/admin/tag/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$tag = $this->repo->find($id);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تگ یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $tag->setName($data['name']);
if (isset($data['slug'])) $tag->setSlug($data['slug']);
if (isset($data['status'])) $tag->setStatus((int) $data['status']);
$this->repo->save($tag);
return $this->success(['data' => $tag->toArray()]);
}
#[Route('/api/v1/admin/tag/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$tag = $this->repo->find($id);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تگ یافت نشد', 404);
}
$this->repo->remove($tag);
return $this->success(['message' => 'تگ با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/tags', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$qb = $this->repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
if ($search !== '') {
$qb->andWhere('t.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
$total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
return $this->paginated(
array_map(fn(Tag $t) => $t->toArray(), $rows),
(int) $total, $page, $limit
);
}
private function slugify(string $text): string
{
$text = mb_strtolower(trim($text));
$text = preg_replace('/\s+/', '-', $text);
$text = preg_replace('/[^\p{L}\p{N}\-]/u', '', $text);
return $text ?: 'tag-' . time();
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Tag\Entity;
use App\Tag\Repository\TagRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\Table(name: 'tags')]
#[ORM\UniqueConstraint(name: 'uq_tags_slug', columns: ['slug'])]
#[ORM\Index(columns: ['status'], name: 'idx_tags_status')]
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug;
#[ORM\Column(type: 'smallint')]
private int $status = 1;
public function __construct(string $name, string $slug)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->slug = $slug;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getSlug(): string { return $this->slug; }
public function getStatus(): int { return $this->status; }
public function setName(string $v): self { $this->name = $v; return $this; }
public function setSlug(string $v): self { $this->slug = $v; return $this; }
public function setStatus(int $v): self { $this->status = $v; return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Tag\Repository;
use App\Tag\Entity\Tag;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tag::class);
}
/** @return Tag[] */
public function findActive(): array
{
return $this->createQueryBuilder('t')
->where('t.status = 1')
->orderBy('t.name', 'ASC')
->getQuery()
->getResult();
}
public function save(Tag $tag, bool $flush = true): void
{
$this->getEntityManager()->persist($tag);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(Tag $tag, bool $flush = true): void
{
$this->getEntityManager()->remove($tag);
if ($flush) $this->getEntityManager()->flush();
}
}