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:
hamed
2026-08-01 15:01:38 +03:30
co-authored by Claude Opus 5
parent 496432889d
commit fe48b10fb5
10 changed files with 308 additions and 31 deletions
@@ -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,
),