feat(patient): soft-archive sessions with active/all/archived filter

Add archived + archived_at columns to PatientSession (setArchived stamps the
time). findByRecord/countByRecord accept an archived filter (default all to
keep existing callers, incl. the discount visit-count, unchanged); the
sessions GET reads ?filter=active|all|archived, defaulting to active so
archived visits are hidden. updateSession accepts { archived }. Records are
kept, only hidden. Verified end-to-end; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 14:44:23 +03:30
co-authored by Claude Fable 5
parent 53bd87e807
commit 666833fc69
5 changed files with 70 additions and 13 deletions
+2 -1
View File
@@ -280,7 +280,7 @@ GET /api/v1/patient/{uuid}/sessions
Returns paginated sessions for a patient record.
**Query params:** `page`, `limit` (same as list)
**Query params:** `page`, `limit`, و `filter` = `active` (پیش‌فرض — آرشیوها مخفی) | `all` | `archived`. مقادیر نامعتبر به `active` برمی‌گردند. هر session کلیدهای `archived` (bool) و `archived_at` (unix|null) را هم دارد.
**Response 200:**
@@ -488,6 +488,7 @@ Updates mutable fields on a session.
- **تخفیف تسویه (دستی):** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`).
- **تخفیف بر اساس قانون:** `discount_rule_uuid` (رشته) → قانون تخفیف (owner-scoped) اعمال می‌شود؛ مقدار ریالی از خود قانون توسط موتور محاسبه می‌گردد (نوع/مبنا بر اساس قانون). `''`/`null` → حذف تخفیف. اولویت بر `discount_type` دستی. قانونِ نامعتبر → `404`. منبع اعمال‌شده در پاسخ به‌صورت `applied_discount_rule_id` و `applied_discount_rule_label` (audit) برمی‌گردد. قوانین قابل‌اعمال از `GET /api/v1/session/{uuid}/discount-suggestions` (نگاه کنید به `discount.md`).
- `paid_at`: unix timestamp زمان تسویه.
- **آرشیو:** `archived` (bool) → آرشیو نرم مراجعه؛ `true` آن را از لیست پیش‌فرض (`filter=active`) مخفی می‌کند و `archived_at` را ست می‌کند، `false` بازمی‌گرداند. سابقه (فاکتور/پرداخت‌ها) حذف نمی‌شود.
**Response 200:**
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260717111223 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add archived + archived_at columns to patient_sessions (soft-archive)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE patient_sessions ADD archived TINYINT DEFAULT 0 NOT NULL, ADD archived_at INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE patient_sessions DROP archived, DROP archived_at');
}
}
+7 -2
View File
@@ -922,8 +922,10 @@ class PatientController extends BaseController
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit);
$total = $this->sessionRepo->countByRecord($record);
// فیلتر آرشیو: active (پیش‌فرض — آرشیوها مخفی) | all | archived
$filter = in_array($request->query->get('filter'), ['all', 'archived'], true) ? $request->query->get('filter') : 'active';
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit, $filter);
$total = $this->sessionRepo->countByRecord($record, $filter);
return $this->paginated(
array_map(fn($s) => $this->sessionWithBilling($s), $sessions),
@@ -1047,6 +1049,9 @@ class PatientController extends BaseController
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
// آرشیو نرم: مخفی‌سازی مراجعه‌ی اشتباه بدون حذف سابقه.
if (array_key_exists('archived', $data)) { $session->setArchived((bool) $data['archived']); }
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
// '' یا null → حذف تخفیف. اولویت بر تخفیف دستی.
if (array_key_exists('discount_rule_uuid', $data)) {
+12
View File
@@ -91,6 +91,13 @@ class PatientSession
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notes = null;
/** آرشیو نرم: مراجعه‌ی اشتباه از لیست پیش‌فرض مخفی می‌شود ولی سابقه حفظ می‌گردد. */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $archived = false;
#[ORM\Column(name: 'archived_at', type: 'integer', nullable: true)]
private ?int $archivedAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -217,6 +224,9 @@ class PatientSession
public function setInventoryPackage(?InventoryPackage $v): self { $this->inventoryPackage = $v; $this->updatedAt = time(); return $this; }
public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; }
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
public function isArchived(): bool { return $this->archived; }
public function setArchived(bool $v): self { $this->archived = $v; $this->archivedAt = $v ? time() : null; $this->updatedAt = time(); return $this; }
public function getArchivedAt(): ?int { return $this->archivedAt; }
public function toArray(): array
{
@@ -259,6 +269,8 @@ class PatientSession
),
'consumables_total_rials' => $this->getConsumablesTotalRials(),
'notes' => $this->notes,
'archived' => $this->archived,
'archived_at' => $this->archivedAt,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
@@ -19,26 +19,36 @@ class PatientSessionRepository extends ServiceEntityRepository
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20): array
/** $filter: all | active | archived. پیش‌فرض all برای حفظ رفتار callerهای موجود. */
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20, string $filter = 'all'): array
{
return $this->createQueryBuilder('s')
$qb = $this->createQueryBuilder('s')
->where('s.record = :record')
->setParameter('record', $record)
->orderBy('s.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
->setMaxResults($limit);
$this->applyArchivedFilter($qb, $filter);
return $qb->getQuery()->getResult();
}
public function countByRecord(PatientRecord $record): int
public function countByRecord(PatientRecord $record, string $filter = 'all'): int
{
return (int) $this->createQueryBuilder('s')
$qb = $this->createQueryBuilder('s')
->select('COUNT(s.id)')
->where('s.record = :record')
->setParameter('record', $record)
->getQuery()
->getSingleScalarResult();
->setParameter('record', $record);
$this->applyArchivedFilter($qb, $filter);
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function applyArchivedFilter(\Doctrine\ORM\QueryBuilder $qb, string $filter): void
{
if ($filter === 'active') {
$qb->andWhere('s.archived = false');
} elseif ($filter === 'archived') {
$qb->andWhere('s.archived = true');
}
}
public function sumRevenue(string $entityType, int $entityId, int $from, int $to): int