Files
clinicpro/src/Shared/Constant/ErrorCodes.php
T
hamedandClaude Opus 5 4395eea56e feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:55 +03:30

211 lines
13 KiB
PHP

<?php
namespace App\Shared\Constant;
class ErrorCodes
{
// Auth
public const ERR_AUTH_001 = 'ERR_AUTH_001';
public const ERR_AUTH_002 = 'ERR_AUTH_002';
public const ERR_AUTH_003 = 'ERR_AUTH_003';
public const ERR_AUTH_004 = 'ERR_AUTH_004';
public const ERR_AUTH_005 = 'ERR_AUTH_005';
public const ERR_AUTH_006 = 'ERR_AUTH_006';
// Validation
public const ERR_VALIDATION_001 = 'ERR_VALIDATION_001';
public const ERR_VALIDATION_002 = 'ERR_VALIDATION_002';
// Not Found
public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001';
/** هیچ منبعی شرایط یک بخش از نوبت را ندارد — بند ۱۰ مستند. */
public const ERR_NO_ELIGIBLE_RESOURCE = 'ERR_NO_ELIGIBLE_RESOURCE';
/** این اندپوینت با روش نوبت‌دهی فعلیِ آن محل سازگار نیست. */
public const ERR_WRONG_BOOKING_MODE = 'ERR_WRONG_BOOKING_MODE';
/** منبع در آن بازه ظرفیت خالی ندارد — از قید یکتای دیتابیس می‌آید، نه از بررسی کد. */
public const ERR_SLOT_TAKEN = 'ERR_SLOT_TAKEN';
/** مهلت رزرو موقت گذشته است. */
public const ERR_HOLD_EXPIRED = 'ERR_HOLD_EXPIRED';
// Conflict
public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001';
// Forbidden
public const ERR_FORBIDDEN_001 = 'ERR_FORBIDDEN_001';
// Payment
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
public const ERR_PAYMENT_004 = 'ERR_PAYMENT_004';
// Appointment
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002';
/** مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند (حالت نوبت‌دهی سرویسی). */
public const ERR_APPOINTMENT_003 = 'ERR_APPOINTMENT_003';
/** عملیات با روش نوبت‌دهیِ این محل سازگار نیست (slot در برابر service). */
public const ERR_APPOINTMENT_004 = 'ERR_APPOINTMENT_004';
// File
public const ERR_FILE_001 = 'ERR_FILE_001';
public const ERR_FILE_002 = 'ERR_FILE_002';
// SMS
public const ERR_SMS_001 = 'ERR_SMS_001';
public const ERR_SMS_002 = 'ERR_SMS_002';
public const ERR_SMS_003 = 'ERR_SMS_003';
// Secretary
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
// Staff
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
public const ERR_STAFF_MOBILE_INVALID = 'ERR_STAFF_MOBILE_INVALID';
public const ERR_STAFF_MOBILE_TAKEN = 'ERR_STAFF_MOBILE_TAKEN';
// Subscription
public const ERR_SUBSCRIPTION_REQUIRED = 'ERR_SUBSCRIPTION_REQUIRED';
public const ERR_TRIAL_ALREADY_USED = 'ERR_TRIAL_ALREADY_USED';
public const ERR_TRIAL_DISABLED = 'ERR_TRIAL_DISABLED';
public const ERR_SUBSCRIPTION_NOT_FOUND = 'ERR_SUBSCRIPTION_NOT_FOUND';
// Clinic Services
public const ERR_SERVICE_ITEM_IN_USE = 'ERR_SERVICE_ITEM_IN_USE';
public const ERR_SERVICE_NOT_FOUND = 'ERR_SERVICE_NOT_FOUND';
// Patient
public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND';
public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND';
public const ERR_SESSION_PAYMENT_INVALID = 'ERR_SESSION_PAYMENT_INVALID';
public const ERR_SESSION_PAYMENT_EXCEEDS = 'ERR_SESSION_PAYMENT_EXCEEDS';
public const ERR_SESSION_DISCOUNT_INVALID = 'ERR_SESSION_DISCOUNT_INVALID';
// Profile
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
public const ERR_PROFILE_MOBILE_TAKEN = 'ERR_PROFILE_002';
// SMS Wallet
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
// Patient Wallet
public const ERR_WALLET_INSUFFICIENT = 'ERR_WALLET_INSUFFICIENT';
// Rate Limit
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
// Captcha (ALTCHA)
public const ERR_CAPTCHA_001 = 'ERR_CAPTCHA_001';
// Rating
public const ERR_RATING_NOT_ELIGIBLE = 'ERR_RATING_NOT_ELIGIBLE';
// External Inquiry (s.api.ir)
public const ERR_EXTERNAL_001 = 'ERR_EXTERNAL_001';
public const ERR_EXTERNAL_NOT_CONFIGURED = 'ERR_EXTERNAL_002';
public const ERR_NATIONAL_CODE_MISMATCH = 'ERR_IDENTITY_001';
public const ERR_IBAN_MISMATCH = 'ERR_IDENTITY_002';
public const ERR_IBAN_LIMIT = 'ERR_IDENTITY_003';
public const ERR_NATIONAL_CODE_UNVERIFIED = 'ERR_IDENTITY_004';
// Legacy domain string codes — centralised here so message() resolves them.
// Wire values are kept identical (e.g. 'SLOT_TAKEN') for client compatibility.
public const VALIDATION = 'VALIDATION';
public const FORBIDDEN = 'FORBIDDEN';
public const NOT_FOUND = 'NOT_FOUND';
public const USER_NOT_FOUND = 'USER_NOT_FOUND';
public const DOCTOR_NOT_FOUND = 'DOCTOR_NOT_FOUND';
public const DOCTOR_EXISTS = 'DOCTOR_EXISTS';
public const CLINIC_NOT_FOUND = 'CLINIC_NOT_FOUND';
public const SLOT_TAKEN = 'SLOT_TAKEN';
public const SLOT_PAST = 'SLOT_PAST';
public const INVALID_ROLE = 'INVALID_ROLE';
public const DUPLICATE_REQUEST = 'DUPLICATE_REQUEST';
public const ERR_GONE = 'ERR_GONE';
public const ERR_MOVED = 'ERR_MOVED';
public const ERR_ACCESS_DENIED = 'ERR_ACCESS_DENIED';
public const ERR_METHOD_NOT_ALLOWED_001 = 'ERR_METHOD_NOT_ALLOWED_001';
public static function message(string $code): string
{
return match ($code) {
self::ERR_AUTH_001 => 'توکن JWT منقضی شده یا نامعتبر است',
self::ERR_AUTH_002 => 'کد OTP نامعتبر است',
self::ERR_AUTH_003 => 'کد OTP منقضی شده است',
self::ERR_AUTH_004 => 'تعداد تلاش‌های OTP به حد مجاز رسیده است',
self::ERR_AUTH_005 => 'نام کاربری یا رمز عبور اشتباه است',
self::ERR_AUTH_006 => 'این نوع حساب فقط از طریق کد OTP وارد می‌شود',
self::ERR_VALIDATION_001 => 'ورودی نامعتبر است',
self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است',
self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد',
self::ERR_NO_ELIGIBLE_RESOURCE => 'برای این خدمت منبع واجد شرایطی در این شعبه نیست',
self::ERR_WRONG_BOOKING_MODE => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست',
self::ERR_SLOT_TAKEN => 'این زمان هم‌اکنون رزرو شد',
self::ERR_HOLD_EXPIRED => 'مهلت رزرو موقت تمام شده است',
self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست',
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست',
self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست',
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
self::ERR_APPOINTMENT_003 => 'مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند',
self::ERR_APPOINTMENT_004 => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست',
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)',
self::ERR_SMS_001 => 'موجودی پیامک کافی نیست',
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد',
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید',
self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید',
self::ERR_STAFF_NOT_FOUND => 'پرسنل یافت نشد',
self::ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست',
self::ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است',
self::ERR_SUBSCRIPTION_REQUIRED => 'این قابلیت نیاز به پنل Basic یا بالاتر دارد',
self::ERR_TRIAL_ALREADY_USED => 'قبلاً از تریال استفاده کرده‌اید',
self::ERR_TRIAL_DISABLED => 'تریال در حال حاضر غیرفعال است',
self::ERR_SUBSCRIPTION_NOT_FOUND => 'اشتراک یافت نشد',
self::ERR_SERVICE_ITEM_IN_USE => 'این سرویس در پرونده بیمار ثبت شده است',
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
self::ERR_SESSION_PAYMENT_INVALID => 'مبلغ یا روش پرداخت نامعتبر است',
self::ERR_SESSION_PAYMENT_EXCEEDS => 'مبلغ پرداخت از مانده بدهی بیشتر است',
self::ERR_SESSION_DISCOUNT_INVALID => 'مقدار تخفیف نامعتبر است',
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
self::ERR_WALLET_INSUFFICIENT => 'موجودی کیف پول کافی نیست',
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید',
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
self::ERR_NATIONAL_CODE_MISMATCH => 'کد ملی متعلق به این شماره موبایل نیست',
self::ERR_IBAN_MISMATCH => 'شماره شبا متعلق به شما نیست',
self::ERR_IBAN_LIMIT => 'حداکثر دو شماره شبا می‌توانید ثبت کنید',
self::ERR_NATIONAL_CODE_UNVERIFIED => 'ابتدا کد ملی خود را تأیید کنید',
self::VALIDATION => 'ورودی نامعتبر است',
self::FORBIDDEN => 'دسترسی مجاز نیست',
self::NOT_FOUND => 'منبع یافت نشد',
self::USER_NOT_FOUND => 'کاربر یافت نشد',
self::DOCTOR_NOT_FOUND => 'پزشک یافت نشد',
self::DOCTOR_EXISTS => 'این پزشک قبلاً ثبت شده است',
self::CLINIC_NOT_FOUND => 'کلینیک یافت نشد',
self::SLOT_TAKEN => 'این نوبت قبلاً رزرو شده است',
self::SLOT_PAST => 'زمان این اسلات گذشته است',
self::INVALID_ROLE => 'نقش نامعتبر است',
self::DUPLICATE_REQUEST => 'درخواست تکراری است',
self::ERR_GONE => 'این منبع دیگر در دسترس نیست',
self::ERR_MOVED => 'این منبع منتقل شده است',
self::ERR_ACCESS_DENIED => 'دسترسی مجاز نیست',
self::ERR_METHOD_NOT_ALLOWED_001 => 'متد درخواستی برای این آدرس مجاز نیست',
default => 'خطای ناشناخته',
};
}
}