feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
/**
|
||||
* خروجی واقعیِ اندپوینتهای قانون برای `docs/api/policy.md`.
|
||||
*
|
||||
* جدا از تستهای رفتاری است و در گروه `docs` میماند تا در اجرای عادی نویز نسازد.
|
||||
*/
|
||||
#[Group('docs')]
|
||||
class DocsCaptureTest extends ApiTestCase
|
||||
{
|
||||
public function testCapture(): void
|
||||
{
|
||||
// بهصورت پیشفرض رد میشود: کارش تولید خروجی برای مستندات است، نه ادعای رفتار.
|
||||
if (getenv('POLICY_DOCS') !== '1') {
|
||||
self::markTestSkipped('برای تولید خروجی مستندات: POLICY_DOCS=1');
|
||||
}
|
||||
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک نمونه');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر صورت');
|
||||
$service->setSoloDurationMinutes(20);
|
||||
$service->setPriceRials(1_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$dump = function (string $label, mixed $body): void {
|
||||
fwrite(
|
||||
STDERR,
|
||||
sprintf("\n===%s %d===\n%s\n", $label, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)),
|
||||
);
|
||||
};
|
||||
|
||||
$dump('SCHEMA', $this->authJson('GET', '/api/v1/policy-schema', $user));
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل یک ساعت برای لیزر',
|
||||
'priority' => 10,
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 1],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||||
]);
|
||||
$dump('CREATE', $created);
|
||||
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$dump('ACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/activate", $user));
|
||||
|
||||
$dump('VERSION', $this->authJson('POST', "/api/v1/policy/$uuid/version", $user, [
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
|
||||
]));
|
||||
|
||||
$dump('SHOW', $this->authJson('GET', "/api/v1/policy/$uuid", $user));
|
||||
$dump('INDEX', $this->authJson('GET', '/api/v1/policies?category=timing', $user));
|
||||
|
||||
$dump('BAD_FIELD', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون نامعتبر',
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]));
|
||||
|
||||
$dump('BAD_EFFECT', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'اثر نامعتبر',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]));
|
||||
|
||||
$dump('DEACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/deactivate", $user));
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user