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:
@@ -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