refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\Insurance\Enum\ServiceCategory;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -16,9 +15,7 @@ use App\ClinicService\Repository\CatalogCategoryRepository;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Repository\ServiceSectionRepository;
|
||||
use App\ClinicService\Repository\TariffRepository;
|
||||
use App\ClinicService\Service\ServiceItemAuditService;
|
||||
use App\ClinicService\Service\TariffService;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -44,8 +41,6 @@ class ClinicServiceController extends BaseController
|
||||
private readonly ServiceItemRepository $itemRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly TariffRepository $tariffRepo,
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly \App\Appointment\Plan\Repository\SegmentTemplateRepository $segmentRepo,
|
||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||
@@ -426,8 +421,6 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
// قیمت سرویس همان تعرفهی سال جاری است؛ هنگام ساخت، تعرفهی سال جاری ثبت میشود.
|
||||
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||
$this->auditService->logCreate($item, $user);
|
||||
|
||||
return $this->success($this->serializeItems([$item])[0], 201);
|
||||
@@ -447,9 +440,8 @@ class ClinicServiceController extends BaseController
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$before = $this->auditService->snapshot($item);
|
||||
|
||||
$priceChanged = false;
|
||||
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
|
||||
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; }
|
||||
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); }
|
||||
if (isset($data['active'])) { $item->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('staff_uuids', $data) || array_key_exists('staff_uuid', $data)) {
|
||||
$staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId);
|
||||
@@ -484,11 +476,6 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
// اگر قیمت پایه تغییر کرد، تعرفهی سال جاری هم همگام میشود (قیمت واحد).
|
||||
if ($priceChanged) {
|
||||
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||
}
|
||||
|
||||
$this->auditService->logChanges($item, $before, $this->auditService->snapshot($item), $user);
|
||||
|
||||
return $this->success($this->serializeItems([$item])[0]);
|
||||
@@ -508,59 +495,6 @@ class ClinicServiceController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tariffs (تعرفهی نسخهدار سالانه) ──────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/service-items/{uuid}/tariffs', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$tariffs = $this->tariffRepo->findByService($item->getId());
|
||||
|
||||
return $this->success([
|
||||
'current_year' => $this->tariffService->currentJalaliYear(),
|
||||
'default_price_rials' => $item->getPriceRials(),
|
||||
'data' => array_map(fn($t) => $t->toArray(), $tariffs),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-items/{uuid}/tariffs/{year}', methods: ['PUT'], requirements: ['year' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
if ($year < 1390 || $year > 1500) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$price = (int) ($data['price_rials'] ?? 0);
|
||||
|
||||
$tariff = $this->tariffService->upsert($item->getId(), $year, $price);
|
||||
|
||||
// تعرفهی سال جاری = قیمت پایهی سرویس (قیمت واحد در همهجا).
|
||||
if ($year === $this->tariffService->currentJalaliYear()) {
|
||||
$item->setPriceRials($price);
|
||||
$this->itemRepo->save($item);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $tariff->toArray()]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -446,7 +446,7 @@ class ServiceCatalogController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
|
||||
}
|
||||
|
||||
foreach (['price_rials', 'solo_duration_minutes', 'additional_duration_minutes'] as $field) {
|
||||
foreach (['solo_duration_minutes', 'additional_duration_minutes'] as $field) {
|
||||
$value = $row[$field] ?? null;
|
||||
|
||||
if ($value !== null && (!is_numeric($value) || (int) $value < 0)) {
|
||||
@@ -464,7 +464,6 @@ class ServiceCatalogController extends BaseController
|
||||
|
||||
// `isset()` خودش null را رد میکند، پس مقایسهٔ اضافه لازم نیست.
|
||||
// `null` یعنی «همان مقدار خودِ سرویس» — صفر نیست.
|
||||
$override->setPriceRials(isset($row['price_rials']) ? (int) $row['price_rials'] : null);
|
||||
$override->setSoloDurationMinutes(isset($row['solo_duration_minutes']) ? (int) $row['solo_duration_minutes'] : null);
|
||||
$override->setAdditionalDurationMinutes(isset($row['additional_duration_minutes']) ? (int) $row['additional_duration_minutes'] : null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user