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:
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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,6 @@ services:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Category\Controller\CategoryController:
|
||||
App\Insurance\Controller\InsuranceController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -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 1–31) ───────────────
|
||||
$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');
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' => 'بیمه پزشک با موفقیت حذف شد']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user