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:
@@ -81,10 +81,10 @@ class ResourceServiceResolverTest extends ApiTestCase
|
||||
return $offering;
|
||||
}
|
||||
|
||||
private function branchOverride(ServiceItem $item, ?int $minutes, ?int $price): void
|
||||
private function branchOverride(ServiceItem $item, ?int $minutes): void
|
||||
{
|
||||
$override = new ServiceBranchOverride($item, $this->address);
|
||||
$override->setSoloDurationMinutes($minutes)->setPriceRials($price);
|
||||
$override->setSoloDurationMinutes($minutes);
|
||||
$this->em->persist($override);
|
||||
$this->em->flush();
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class ResourceServiceResolverTest extends ApiTestCase
|
||||
{
|
||||
$this->offer($this->option, 15, 9_500_000);
|
||||
$this->offer($this->service, 40, 12_000_000);
|
||||
$this->branchOverride($this->option, 50, 11_000_000);
|
||||
$this->branchOverride($this->option, 50);
|
||||
|
||||
$spec = $this->resolve();
|
||||
|
||||
@@ -113,7 +113,7 @@ class ResourceServiceResolverTest extends ApiTestCase
|
||||
public function testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing(): void
|
||||
{
|
||||
$this->offer($this->service, 40, 12_000_000);
|
||||
$this->branchOverride($this->option, 50, 11_000_000);
|
||||
$this->branchOverride($this->option, 50);
|
||||
|
||||
$spec = $this->resolve();
|
||||
|
||||
@@ -122,15 +122,17 @@ class ResourceServiceResolverTest extends ApiTestCase
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE, $spec->durationSource);
|
||||
}
|
||||
|
||||
public function testLevelThreeBranchWinsWhenTheResourceHasNothing(): void
|
||||
/** شعبه فقط مدت میدهد؛ قیمتش را از خودِ سرویس میگیرد. */
|
||||
public function testLevelThreeBranchWinsForDurationOnly(): void
|
||||
{
|
||||
$this->branchOverride($this->option, 50, 11_000_000);
|
||||
$this->branchOverride($this->option, 50);
|
||||
|
||||
$spec = $this->resolve();
|
||||
|
||||
self::assertSame(50, $spec->durationMinutes);
|
||||
self::assertSame(11_000_000, $spec->priceRials);
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->durationSource);
|
||||
self::assertSame(8_000_000, $spec->priceRials);
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource);
|
||||
}
|
||||
|
||||
public function testLevelFourFallsBackToTheItemItself(): void
|
||||
@@ -147,17 +149,17 @@ class ResourceServiceResolverTest extends ApiTestCase
|
||||
|
||||
public function testDurationAndPriceResolveIndependently(): void
|
||||
{
|
||||
// منبع فقط مدت را میگوید؛ قیمت باید تا سطح شعبه پایین برود.
|
||||
// منبع فقط مدت را میگوید؛ قیمت باید تا خودِ سرویس پایین برود.
|
||||
$this->offer($this->option, 15, null);
|
||||
$this->branchOverride($this->option, null, 11_000_000);
|
||||
$this->branchOverride($this->option, null);
|
||||
|
||||
$spec = $this->resolve();
|
||||
|
||||
self::assertSame(15, $spec->durationMinutes);
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource);
|
||||
|
||||
self::assertSame(11_000_000, $spec->priceRials);
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->priceSource);
|
||||
self::assertSame(8_000_000, $spec->priceRials);
|
||||
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource);
|
||||
}
|
||||
|
||||
public function testAnInactiveOfferingIsSkippedEntirely(): void
|
||||
|
||||
@@ -255,7 +255,8 @@ class ServiceSelectionTest extends ApiTestCase
|
||||
self::assertStringContainsString('حلقه', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
public function testBranchOverrideChangesPriceAndDuration(): void
|
||||
/** شعبه فقط مدت را عوض میکند؛ قیمت همیشه از خودِ سرویس است. */
|
||||
public function testBranchOverrideChangesDurationButNotPrice(): void
|
||||
{
|
||||
[$user, , $section, $address] = $this->clinicWithSection();
|
||||
$face = $this->item($section, 'صورت', 15, 8, 500_000);
|
||||
@@ -263,7 +264,6 @@ class ServiceSelectionTest extends ApiTestCase
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$face->getUuid()}/branch-overrides", $user, [
|
||||
'overrides' => [[
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'price_rials' => 900_000,
|
||||
'solo_duration_minutes' => 25,
|
||||
]],
|
||||
]);
|
||||
@@ -274,7 +274,7 @@ class ServiceSelectionTest extends ApiTestCase
|
||||
self::assertSame(15, $plain['data']['total_duration_minutes']);
|
||||
|
||||
$atBranch = $this->validate($user, [$face->getUuid()], ['branch_uuid' => $address->getUuid()]);
|
||||
self::assertSame(900_000, $atBranch['data']['total_price_rials']);
|
||||
self::assertSame(500_000, $atBranch['data']['total_price_rials'], 'قیمت شعبهای وجود ندارد');
|
||||
self::assertSame(25, $atBranch['data']['total_duration_minutes']);
|
||||
}
|
||||
|
||||
|
||||
+32
-129
@@ -10,7 +10,10 @@ use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* لیست قیمت بازهدار و فاکتور تفکیکشده — بند ۱۲ و قانون پنجم مستند.
|
||||
* زنجیرهٔ قیمتگذاری و فاکتور تفکیکشده — بند ۱۲ و قانون پنجم مستند.
|
||||
*
|
||||
* قیمت تنها یک منبع دارد: `ServiceItem::priceRials`. لیست قیمت، تعرفهٔ سالانه و قیمت
|
||||
* اختصاصی شعبه حذف شدهاند.
|
||||
*/
|
||||
class PricingTest extends ApiTestCase
|
||||
{
|
||||
@@ -56,20 +59,7 @@ class PricingTest extends ApiTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([
|
||||
'name' => $name,
|
||||
'starts_at' => $from,
|
||||
'ends_at' => $to,
|
||||
'address_uuid' => $addressUuid,
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
/** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمیگردد — هرگز صفر یا خطا. */
|
||||
/** قیمت همیشه از خودِ سرویس میآید — هرگز صفر یا خطا. */
|
||||
public function testFallsBackToTheServicePrice(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
@@ -83,99 +73,42 @@ class PricingTest extends ApiTestCase
|
||||
self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]);
|
||||
}
|
||||
|
||||
/** لیست قیمت فقط در بازهٔ خودش حاکم است. */
|
||||
public function testPriceListAppliesOnlyInsideItsRange(): void
|
||||
/** override شعبه فقط مدت را عوض میکند؛ قیمت همچنان از خودِ سرویس میآید. */
|
||||
public function testBranchOverrideNoLongerChangesThePrice(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
|
||||
|
||||
$from = strtotime('+10 days');
|
||||
$to = strtotime('+40 days');
|
||||
|
||||
$list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
|
||||
self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید');
|
||||
|
||||
$before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]);
|
||||
self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی');
|
||||
}
|
||||
|
||||
/** دو لیست فعالِ همپوشان یعنی یک تاریخ دو قیمت — هنگام فعالسازی رد میشود. */
|
||||
public function testOverlappingActiveListsAreRejected(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$from = strtotime('+10 days');
|
||||
|
||||
$first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400);
|
||||
$this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400);
|
||||
$body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('همپوشانی', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
/** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */
|
||||
public function testBranchListWinsOverTheGeneralList(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
|
||||
|
||||
$from = strtotime('+10 days');
|
||||
$to = $from + 30 * 86400;
|
||||
|
||||
$general = $this->priceList($c['user'], 'عمومی', $from, $to);
|
||||
$this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
|
||||
]);
|
||||
$this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid());
|
||||
$this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]],
|
||||
]);
|
||||
$this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']);
|
||||
self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد');
|
||||
|
||||
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
|
||||
|
||||
self::assertSame(9_000_000, $body['data']['base_rials']);
|
||||
}
|
||||
|
||||
/** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */
|
||||
public function testBranchOverrideBeatsThePriceList(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
|
||||
|
||||
$from = strtotime('+10 days');
|
||||
$list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400);
|
||||
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
|
||||
]);
|
||||
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
|
||||
'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]],
|
||||
'overrides' => [[
|
||||
'address_uuid' => $c['address']->getUuid(),
|
||||
'solo_duration_minutes' => 45,
|
||||
]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
|
||||
$body = $this->quote($c['user'], $service, $c['address']);
|
||||
|
||||
self::assertSame(11_000_000, $body['data']['base_rials']);
|
||||
self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]);
|
||||
self::assertSame(5_000_000, $body['data']['base_rials']);
|
||||
self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]);
|
||||
}
|
||||
|
||||
/** قیمتِ ارسالی برای override نادیده گرفته میشود — شعبه دیگر قیمت ندارد. */
|
||||
public function testBranchOverridePayloadHasNoPriceField(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'لیزر', 3_000_000);
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
|
||||
'overrides' => [[
|
||||
'address_uuid' => $c['address']->getUuid(),
|
||||
'price_rials' => 9_000_000,
|
||||
]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertArrayNotHasKey('price_rials', $body['data'][0]);
|
||||
self::assertSame(3_000_000, $this->quote($c['user'], $service, $c['address'])['data']['base_rials']);
|
||||
}
|
||||
|
||||
public function testFullChainAppliesInOrder(): void
|
||||
@@ -305,20 +238,6 @@ class PricingTest extends ApiTestCase
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNegativePriceIsRejected(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'ویزیت', 1_000_000);
|
||||
$list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days'));
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('price_rials', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبتهای ثبتشده را عوض نمیکند.»
|
||||
*
|
||||
@@ -477,20 +396,4 @@ class PricingTest extends ApiTestCase
|
||||
self::assertSame(0, $snapshot['data']['items_rials']);
|
||||
self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']);
|
||||
}
|
||||
|
||||
public function testDraftListHasNoEffectUntilActivated(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
|
||||
|
||||
$from = strtotime('+2 days');
|
||||
$list = $this->priceList($c['user'], 'پیشنویس', $from, $from + 30 * 86400);
|
||||
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
|
||||
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]],
|
||||
]);
|
||||
|
||||
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
|
||||
|
||||
self::assertSame(5_000_000, $body['data']['base_rials'], 'پیشنویس نباید قیمت را عوض کند');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class TenantLookupInventoryTest extends TestCase
|
||||
// ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه
|
||||
'src/Billing/Controller/BillingController.php' => 3,
|
||||
// ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل)
|
||||
'src/ClinicService/Controller/ClinicServiceController.php' => 10,
|
||||
'src/ClinicService/Controller/ClinicServiceController.php' => 8,
|
||||
'src/Discount/Controller/DiscountController.php' => 1,
|
||||
// قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType()
|
||||
'src/Insurance/Controller/InsuranceController.php' => 5,
|
||||
|
||||
Reference in New Issue
Block a user