fix(plan): stop the segment replace from destroying segments when it rejects
PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.
Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.
While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.
The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.
A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.
A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.
require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.
Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -117,6 +117,23 @@
|
||||
**۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمیشود.** ۴۲۲ میدهد، چون رد
|
||||
کردن بیصدا یعنی بیمار به منبعی میرسد که قرار نبود.
|
||||
|
||||
## سقفها و ایمنی جایگزینی
|
||||
|
||||
| قید | مقدار | چرا |
|
||||
|---|---|---|
|
||||
| مجموع مدت | ۴۸۰ دقیقه | حفاظت از جستجوی وقت |
|
||||
| تعداد بخش | ۲۰ | موتور برای هر بخش × هر نیازمندی ترکیب منابع را میسنجد |
|
||||
| نیازمندی هر بخش | ۱۰ | همان |
|
||||
|
||||
`PUT .../segments` **حذفکن-و-بنویس** است. همهٔ اعتبارسنجیها (قید ناشناخته، نوع اشغال،
|
||||
مدت، سقفها) **پیش از حذف** انجام میشوند و خودِ حذف و نوشتن در یک تراکنشاند: خطای بعد
|
||||
از حذف یعنی سرویس بدون بخش میماند و نوبتدهیاش بیصدا به «یک بخش پیوسته» برمیگردد —
|
||||
که مدت و منابع همهٔ نوبتهای بعدی را عوض میکند.
|
||||
|
||||
پاسخ `preview` علاوه بر `total_minutes`، فیلد `patient_facing_minutes` هم دارد: مدتی که
|
||||
بیمار واقعاً روی صندلی است. نوبت نودقیقهای که چهل دقیقهاش انتظار اثر بیحسی است، «نود
|
||||
دقیقه وقت بگذارید» نیست — و محاسبه یکجا در بکاند است تا هر کلاینت خودش جمع نزند.
|
||||
|
||||
## ادغام بخشها
|
||||
|
||||
برنامه از الگوهای **سرویس اصلی بهعلاوهٔ آیتمهای انتخابشده** ساخته میشود. تا پیش از
|
||||
|
||||
@@ -409,3 +409,15 @@ POST /api/v1/policy
|
||||
| `/admin/policies` | فهرست قوانین |
|
||||
| `/admin/policies/new` | ساخت با الگو یا حالت پیشرفته |
|
||||
| `/admin/policies/{uuid}/simulate` | گزارش آزمایش + دکمهٔ فعالسازی |
|
||||
|
||||
## اعتبار نسخهٔ تازه
|
||||
|
||||
`POST /policy/{uuid}/version` مقدار `valid_from` در گذشته را **رد میکند** (۴۲۲). نوبتهای
|
||||
گذشته با متن قبلی حساب شدهاند و ردپای قیمتشان به نسخه اشاره میکند؛ اعتبارِ عقبرونده
|
||||
یعنی آن ارجاع قانونی را توصیف کند که آن روز وجود نداشت. روی نسخهٔ نخست آزاد است.
|
||||
|
||||
## فیلدِ غایب در شرط
|
||||
|
||||
شرطی که فیلدش در حقایق درخواست نباشد **رد** میشود (نه نادیده گرفته) و یک `warning` با
|
||||
نام قانون و فهرست حقایق موجود لاگ میشود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط
|
||||
میرسد عملاً خاموش است و کسی خبردار نمیشود.
|
||||
|
||||
@@ -62,6 +62,16 @@ class AppointmentPlanController extends BaseController
|
||||
}
|
||||
|
||||
$service = $this->requireItem($user, $uuid);
|
||||
|
||||
if (count($data['segments']) > SegmentTemplate::MAX_SEGMENTS) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('یک سرویس حداکثر %d بخش دارد', SegmentTemplate::MAX_SEGMENTS),
|
||||
422,
|
||||
'segments',
|
||||
);
|
||||
}
|
||||
|
||||
$planned = [];
|
||||
$total = 0;
|
||||
|
||||
@@ -94,15 +104,44 @@ class AppointmentPlanController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'type_uuid هر نیازمندی الزامی است', 422, 'requirements');
|
||||
}
|
||||
|
||||
// اعتبارسنجی اینجاست نه هنگام نوشتن: نوشتن بعد از حذف اتفاق میافتد و
|
||||
// خطای آنجا یعنی سرویس بخشهایش را از دست داده.
|
||||
$occupancy = is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE;
|
||||
$constraints = is_array($req['constraints'] ?? null) ? array_values($req['constraints']) : [];
|
||||
|
||||
if (!in_array($occupancy, SegmentRequirement::OCCUPANCIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع اشغال نیازمندی نامعتبر است', 422, 'occupancy');
|
||||
}
|
||||
|
||||
foreach ($constraints as $constraint) {
|
||||
if (!is_string($constraint) || !in_array($constraint, SegmentRequirement::CONSTRAINTS, true)) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('قید «%s» شناختهشده نیست', is_string($constraint) ? $constraint : '—'),
|
||||
422,
|
||||
'constraints',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$requirements[] = [
|
||||
'type' => $this->resourceContext->type($user, $req['type_uuid']),
|
||||
'skill' => is_string($req['skill_uuid'] ?? null) ? $this->resourceContext->skill($user, $req['skill_uuid']) : null,
|
||||
'count' => is_numeric($req['count'] ?? null) ? max(1, (int) $req['count']) : 1,
|
||||
'occupancy' => is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE,
|
||||
'constraints' => is_array($req['constraints'] ?? null) ? $req['constraints'] : [],
|
||||
'occupancy' => $occupancy,
|
||||
'constraints' => $constraints,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($requirements) > SegmentTemplate::MAX_REQUIREMENTS_PER_SEGMENT) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('هر بخش حداکثر %d نیازمندی دارد', SegmentTemplate::MAX_REQUIREMENTS_PER_SEGMENT),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
$planned[] = [
|
||||
'sequence' => is_numeric($row['sequence'] ?? null) ? (int) $row['sequence'] : $index + 1,
|
||||
'name' => trim($row['name']),
|
||||
@@ -123,36 +162,31 @@ class AppointmentPlanController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
$this->templates->deleteForService($service);
|
||||
// حذف و نوشتنِ دوباره در **یک** تراکنش. `deleteForService` یک DELETE فوری است؛
|
||||
// اگر بعد از آن چیزی بشکند، سرویس بدون هیچ بخشی میماند و نوبتدهیاش بیصدا به
|
||||
// «یک بخش پیوسته» برمیگردد — یعنی مدت و منابعِ همهٔ نوبتهای بعدی عوض میشود.
|
||||
$this->em->wrapInTransaction(function () use ($service, $planned): void {
|
||||
$this->templates->deleteForService($service);
|
||||
|
||||
foreach ($planned as $row) {
|
||||
$template = new SegmentTemplate($service, $row['sequence'], $row['name']);
|
||||
|
||||
try {
|
||||
foreach ($planned as $row) {
|
||||
$template = new SegmentTemplate($service, $row['sequence'], $row['name']);
|
||||
$template->setDuration($row['source'], $row['minutes']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت بخش نامعتبر است', 422, 'duration_minutes');
|
||||
}
|
||||
$template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']);
|
||||
|
||||
$template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']);
|
||||
$this->em->persist($template);
|
||||
$this->em->persist($template);
|
||||
|
||||
foreach ($row['requirements'] as $req) {
|
||||
$requirement = new SegmentRequirement($template, $req['type'], $req['count']);
|
||||
$requirement->setSkill($req['skill']);
|
||||
|
||||
try {
|
||||
foreach ($row['requirements'] as $req) {
|
||||
$requirement = new SegmentRequirement($template, $req['type'], $req['count']);
|
||||
$requirement->setSkill($req['skill']);
|
||||
$requirement->setOccupancy($req['occupancy'])->setConstraints($req['constraints']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422, 'requirements');
|
||||
|
||||
$this->em->persist($requirement);
|
||||
$template->getRequirements()->add($requirement);
|
||||
}
|
||||
|
||||
$this->em->persist($requirement);
|
||||
$template->getRequirements()->add($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->flush();
|
||||
});
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (SegmentTemplate $s): array => $s->toArray(),
|
||||
|
||||
@@ -39,6 +39,17 @@ class SegmentTemplate
|
||||
/** سقف مجموع مدت یک نوبت — حفاظت از جستجوی وقت در تسک ۰۶. */
|
||||
public const MAX_TOTAL_MINUTES = 480;
|
||||
|
||||
/**
|
||||
* سقف تعداد — همان حفاظت، از سمت دیگر.
|
||||
*
|
||||
* موتور دسترسپذیری برای هر بخش × هر نیازمندی یک بار ترکیب منابع را میسنجد؛ صد
|
||||
* بخشِ دهنیازمندی یک جستجوی وقت را از پا درمیآورد. عددها سخاوتمندانهاند: هیچ
|
||||
* سرویس واقعیای به آنها نمیرسد، ولی ورودی اشتباه یا اسکریپت خراب میرسد.
|
||||
*/
|
||||
public const MAX_SEGMENTS = 20;
|
||||
|
||||
public const MAX_REQUIREMENTS_PER_SEGMENT = 10;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
|
||||
@@ -357,7 +357,7 @@ final class AppointmentPlanBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
$extra[] = $this->requirementForRole($code, $address);
|
||||
$extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies);
|
||||
}
|
||||
|
||||
if ($extra === []) {
|
||||
@@ -395,14 +395,25 @@ final class AppointmentPlanBuilder
|
||||
* قانونی که نقشِ ناشناخته یا بیمنبع میخواهد **خطاست، نه بیاثر**: در سکوت رد
|
||||
* کردنش یعنی کلینیک فکر کند قانونش اجرا میشود در حالی که هیچوقت نشده.
|
||||
*/
|
||||
private function requirementForRole(string $code, DoctorAddress $address): PlannedRequirement
|
||||
/**
|
||||
* @param list<array<string, mixed>> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون
|
||||
*/
|
||||
private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement
|
||||
{
|
||||
// بدون نام قانون، اپراتور میداند چه چیزی کم است ولی نه چرا لازم شده — و بین ده
|
||||
// قانون فعال باید حدس بزند کدام را خاموش کند.
|
||||
$names = array_values(array_filter(array_map(
|
||||
static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null,
|
||||
$appliedPolicies,
|
||||
)));
|
||||
$because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names));
|
||||
|
||||
$type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);
|
||||
|
||||
if ($type === null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است', $code),
|
||||
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
@@ -413,7 +424,7 @@ final class AppointmentPlanBuilder
|
||||
if ($eligible === []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
|
||||
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست', $type->getName(), $address->getName() ?? '—'),
|
||||
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
|
||||
@@ -16,11 +16,27 @@ final readonly class AppointmentPlan
|
||||
public int $totalMinutes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* مدتی که بیمار واقعاً روی صندلی است.
|
||||
*
|
||||
* با `total_minutes` فرق دارد و همین تفاوت است که به بیمار گفته میشود: نوبتِ
|
||||
* نودقیقهای که چهل دقیقهاش انتظار اثر بیحسی است، «نود دقیقه وقت بگذارید» نیست.
|
||||
* محاسبهاش یکجا اینجاست تا هر کلاینت خودش جمع نزند.
|
||||
*/
|
||||
public function patientFacingMinutes(): int
|
||||
{
|
||||
return array_sum(array_map(
|
||||
static fn (PlannedSegment $s): int => $s->patientPresent ? $s->durationMinutes : 0,
|
||||
$this->segments,
|
||||
));
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_minutes' => $this->totalMinutes,
|
||||
'segments' => array_map(
|
||||
'total_minutes' => $this->totalMinutes,
|
||||
'patient_facing_minutes' => $this->patientFacingMinutes(),
|
||||
'segments' => array_map(
|
||||
static fn (PlannedSegment $s): array => $s->toArray(),
|
||||
$this->segments,
|
||||
),
|
||||
|
||||
@@ -139,6 +139,18 @@ class PolicyController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// شروعِ اعتبارِ عقبرونده روی نسخهٔ تازه یعنی قانونی که ادعا میکند از دیروز برقرار
|
||||
// بوده، در حالی که نوبتهای دیروز با متن قبلی حساب شدهاند و ردپای قیمتشان به این
|
||||
// نسخه اشاره میکند. روی نسخهٔ نخست آزاد است — هنوز چیزی بر اساسش تصمیم نگرفتهایم.
|
||||
if (is_numeric($data['valid_from'] ?? null) && (int) $data['valid_from'] < time()) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'شروع اعتبار نسخهٔ تازه نمیتواند در گذشته باشد؛ نوبتهای گذشته با متن قبلی حساب شدهاند',
|
||||
422,
|
||||
'valid_from',
|
||||
);
|
||||
}
|
||||
|
||||
$this->apply($user, $policy, $data);
|
||||
$policy->bumpVersion();
|
||||
|
||||
@@ -208,9 +220,11 @@ class PolicyController extends BaseController
|
||||
}
|
||||
|
||||
if (array_key_exists('valid_from', $data) || array_key_exists('valid_to', $data)) {
|
||||
$validFrom = is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null;
|
||||
|
||||
try {
|
||||
$policy->setValidity(
|
||||
is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null,
|
||||
$validFrom,
|
||||
is_numeric($data['valid_to'] ?? null) ? (int) $data['valid_to'] : null,
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Policy\Service;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* ارزیابی شرط یک قانون در برابر «حقایق» یک درخواست.
|
||||
@@ -15,8 +17,12 @@ use App\Shared\Exception\AppException;
|
||||
*/
|
||||
final class ConditionEvaluator
|
||||
{
|
||||
/** قانونِ در حال ارزیابی — فقط برای اینکه لاگِ فیلدِ غایب بگوید کدام قانون بود. */
|
||||
private ?Policy $policy = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -24,6 +30,8 @@ final class ConditionEvaluator
|
||||
*/
|
||||
public function matches(Policy $policy, array $facts): bool
|
||||
{
|
||||
$this->policy = $policy;
|
||||
|
||||
$condition = $policy->getCondition();
|
||||
$conditions = $condition['conditions'] ?? [];
|
||||
|
||||
@@ -62,7 +70,17 @@ final class ConditionEvaluator
|
||||
|
||||
// فیلدی که در حقایق این درخواست نیست، شرط را **رد** میکند نه اینکه نادیده
|
||||
// بگیرد: قانون «سن زیر ۱۸» وقتی سن نامشخص است نباید بیصدا صادق شود.
|
||||
//
|
||||
// ولی رد کردنِ خاموش هم بد است: قانونی که هر بار به این خط میرسد، عملاً
|
||||
// خاموش است و کسی خبردار نمیشود. لاگ تنها چیزی است که این را قابل کشف میکند.
|
||||
if (!array_key_exists($field, $facts)) {
|
||||
$this->logger->warning('policy condition skipped: fact missing', [
|
||||
'policy_uuid' => $this->policy?->getUuid(),
|
||||
'category' => $this->policy?->getCategory(),
|
||||
'field' => $field,
|
||||
'known_facts' => array_keys($facts),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,75 @@ class AppointmentPlanTest extends ApiTestCase
|
||||
self::assertCount(3, $replaced['data']['segments']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐⭐ جایگزینی بخشها **حذفکن-و-بنویس** است. اگر اعتبارسنجی بعد از حذف بیفتد،
|
||||
* سرویس بدون هیچ بخشی میماند و نوبتدهیاش بیصدا به «یک بخش پیوسته» برمیگردد —
|
||||
* یعنی مدت و منابع همهٔ نوبتهای بعدی عوض میشود، بدون اینکه کسی چیزی خواسته باشد.
|
||||
*/
|
||||
public function testARejectedReplaceLeavesTheExistingSegmentsIntact(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
$room = $this->resourceType($address, 'room', 'اتاق');
|
||||
$this->resource($user, $address, $room, 'اتاق ۱');
|
||||
|
||||
$this->setSegments($user, $service, [
|
||||
['sequence' => 1, 'name' => 'آمادهسازی', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||||
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||||
]);
|
||||
self::assertCount(2, $this->preview($user, $service, $address)['data']['segments']);
|
||||
|
||||
// قیدِ ناشناخته: باید ۴۲۲ بگیرد **و** چیزی را خراب نکند.
|
||||
$this->setSegments($user, $service, [
|
||||
['sequence' => 1, 'name' => 'بخش تازه', 'duration_minutes' => 15, 'requirements' => [
|
||||
['type_uuid' => $room->getUuid(), 'constraints' => ['same_blood_type']],
|
||||
]],
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$after = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertCount(2, $after['data']['segments'], 'بخشهای قبلی باید دستنخورده مانده باشند');
|
||||
self::assertSame('آمادهسازی', $after['data']['segments'][0]['name']);
|
||||
}
|
||||
|
||||
public function testTooManySegmentsIsRejected(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
|
||||
$segments = [];
|
||||
for ($i = 1; $i <= 21; $i++) {
|
||||
$segments[] = ['sequence' => $i, 'name' => sprintf('بخش %d', $i), 'duration_minutes' => 5];
|
||||
}
|
||||
|
||||
$this->setSegments($user, $service, $segments);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ «نود دقیقه وقت بگذارید» برای نوبتی که چهل دقیقهاش انتظار است، حرفِ درستی نیست.
|
||||
* محاسبه یکجا در بکاند است تا هر کلاینت خودش جمع نزند.
|
||||
*/
|
||||
public function testThePlanReportsHowLongThePatientIsActuallyPresent(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
$room = $this->resourceType($address, 'room', 'اتاق');
|
||||
$this->resource($user, $address, $room, 'اتاق ۱');
|
||||
|
||||
$this->setSegments($user, $service, [
|
||||
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'patient_present' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||||
['sequence' => 2, 'name' => 'تمیزکاری', 'duration_minutes' => 15, 'patient_present' => false, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address)['data'];
|
||||
|
||||
self::assertSame(35, $plan['total_minutes']);
|
||||
self::assertSame(20, $plan['patient_facing_minutes']);
|
||||
}
|
||||
|
||||
public function testForeignServiceIsNotFound(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
@@ -414,6 +414,81 @@ class PolicyEngineTest extends ApiTestCase
|
||||
self::assertSame([1, 2], array_column($show['data']['versions'], 'version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ نسخهٔ تازه نمیتواند ادعا کند از دیروز برقرار بوده.
|
||||
*
|
||||
* نوبتهای دیروز با متن قبلی حساب شدهاند؛ اعتبار عقبرونده یعنی ردپای قیمتها با
|
||||
* قانونی توضیح داده شود که آن روز وجود نداشت.
|
||||
*/
|
||||
public function testANewVersionCannotStartInThePast(): void
|
||||
{
|
||||
[$user, , ] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف پاییز',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
], false);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'valid_from' => time() - 7 * 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
// آینده مجاز است.
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'valid_from' => time() + 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی میسازد که یا همیشه
|
||||
* میگیرد یا هرگز — و هیچکدام خطا نمیدهند.
|
||||
*
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')]
|
||||
public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'خدمت عملگر', 20, 1_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'آزمون عملگر',
|
||||
'condition' => ['match' => 'all', 'conditions' => [$clause]],
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||||
]);
|
||||
|
||||
$quote = $this->quote($user, $service, $address);
|
||||
|
||||
self::assertSame(
|
||||
$expected ? 500_000 : 0,
|
||||
$quote['data']['discount_rials'],
|
||||
json_encode($clause, JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
/** بدون `item_uuids` هیچ آیتم اضافهای انتخاب نشده، پس `item_count` صفر است. */
|
||||
public static function operatorCases(): array
|
||||
{
|
||||
return [
|
||||
'equals میگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true],
|
||||
'equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false],
|
||||
'not_equals میگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true],
|
||||
'not_equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false],
|
||||
'greater_than میگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true],
|
||||
'greater_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false],
|
||||
'less_than میگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true],
|
||||
'less_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false],
|
||||
'in میگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true],
|
||||
'in نمیگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false],
|
||||
'contains نمیگیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false],
|
||||
];
|
||||
}
|
||||
|
||||
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
||||
|
||||
public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void
|
||||
|
||||
Reference in New Issue
Block a user