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:
hamed
2026-08-02 18:00:48 +03:30
co-authored by Claude Opus 5
parent f06efe26c0
commit 4fe0c4f9bf
41 changed files with 208 additions and 1835 deletions
-125
View File
@@ -1,125 +0,0 @@
<?php
namespace App\Pricing\Entity;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Repository\PriceListRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* لیست قیمت با **بازهٔ تاریخ** — بند ۱۲ مستند.
*
* `Tariff` موجود فقط «سال» دارد، پس تغییر تعرفه از اول مهر قابل بیان نیست. این جدول
* بازهٔ دقیق می‌گیرد و `Tariff` به‌عنوان لایهٔ پشتیبان سرِ جایش می‌ماند.
*
* `address` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». قیمت اختصاصی یک شعبه
* از {@see \App\ClinicService\Entity\ServiceBranchOverride} می‌آید که بر این مقدم است.
*/
#[ORM\Entity(repositoryClass: PriceListRepository::class)]
#[ORM\Table(name: 'price_lists')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_price_list_tenant')]
#[ORM\Index(columns: ['starts_at', 'ends_at'], name: 'idx_price_list_range')]
class PriceList
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?DoctorAddress $address = null;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'ends_at', type: 'integer')]
private int $endsAt;
/** تا فعال نشده هیچ اثری ندارد؛ ساختنِ پیش‌نویس نباید قیمت امروز را عوض کند. */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $active = false;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
/** @var Collection<int, PriceListItem> */
#[ORM\OneToMany(targetEntity: PriceListItem::class, mappedBy: 'priceList', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
public function __construct(string $entityType, int $entityId, string $name, int $startsAt, int $endsAt)
{
if ($endsAt <= $startsAt) {
throw new \InvalidArgumentException('Price list end must be after its start.');
}
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->startsAt = $startsAt;
$this->endsAt = $endsAt;
$this->createdAt = time();
$this->updatedAt = time();
$this->items = new ArrayCollection();
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getAddress(): ?DoctorAddress { return $this->address; }
public function getName(): string { return $this->name; }
public function getStartsAt(): int { return $this->startsAt; }
public function getEndsAt(): int { return $this->endsAt; }
public function isActive(): bool { return $this->active; }
/** @return Collection<int, PriceListItem> */
public function getItems(): Collection { return $this->items; }
public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function covers(int $at): bool
{
return $this->active && $at >= $this->startsAt && $at < $this->endsAt;
}
public function overlaps(int $startsAt, int $endsAt): bool
{
return $startsAt < $this->endsAt && $endsAt > $this->startsAt;
}
public function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'address_uuid' => $this->address?->getUuid(),
'address_name' => $this->address?->getName(),
'starts_at' => $this->startsAt,
'ends_at' => $this->endsAt,
'active' => $this->active,
'items' => array_map(
static fn (PriceListItem $i): array => $i->toArray(),
$this->items->toArray(),
),
];
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
namespace App\Pricing\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Pricing\Repository\PriceListItemRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* قیمت یک سرویس در یک لیست قیمت. فرزند aggregate با ریشهٔ {@see PriceList} که خودش
* جفت محیط دارد؛ uuid از request نمی‌گیرد.
*/
#[ORM\Entity(repositoryClass: PriceListItemRepository::class)]
#[ORM\Table(name: 'price_list_items')]
#[ORM\UniqueConstraint(name: 'uniq_price_list_service', columns: ['price_list_id', 'service_item_id'])]
class PriceListItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: PriceList::class, inversedBy: 'items')]
#[ORM\JoinColumn(name: 'price_list_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PriceList $priceList;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
#[ORM\Column(name: 'price_rials', type: 'bigint')]
private int $priceRials;
public function __construct(PriceList $priceList, ServiceItem $serviceItem, int $priceRials)
{
if ($priceRials < 0) {
throw new \InvalidArgumentException('Price cannot be negative.');
}
$this->priceList = $priceList;
$this->serviceItem = $serviceItem;
$this->priceRials = $priceRials;
}
public function getId(): ?int { return $this->id; }
public function getPriceList(): PriceList { return $this->priceList; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getPriceRials(): int { return (int) $this->priceRials; }
public function toArray(): array
{
return [
'service_uuid' => $this->serviceItem->getUuid(),
'service_name' => $this->serviceItem->getName(),
'price_rials' => $this->getPriceRials(),
];
}
}