add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-22 13:44:25 -04:00
parent 23d1cbb64c
commit d906a915d6
45 changed files with 4711 additions and 442 deletions
+210
View File
@@ -79,6 +79,11 @@ final class SchoolYearClosingService
$findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
}
$inventory = $this->inventoryClosingPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : '');
foreach ($inventory['findings'] as $finding) {
$findings[] = $finding;
}
$carryForward = $this->carryForwardFamilies($sourceName);
$warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning'));
$blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking'));
@@ -89,6 +94,7 @@ final class SchoolYearClosingService
'overview' => $overview,
'finance' => $finance,
'promotion' => $promotion,
'inventory' => $inventory,
'findings' => $findings,
'blockers' => $blockers,
'warnings' => $warnings,
@@ -198,6 +204,7 @@ final class SchoolYearClosingService
'error_message' => null,
]);
}
$this->executeInventoryCarryForward($source, $target, (int) $batch['id'], $userId);
if (($batch['status'] ?? '') !== 'completed') {
$this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
}
@@ -239,6 +246,19 @@ final class SchoolYearClosingService
$this->db->transStart();
$now = date('Y-m-d H:i:s');
if ($this->db->tableExists('inventory_item_years')) {
$this->db->table('inventory_item_years')
->where('school_year', (string) ($target['name'] ?? ''))
->where('source_item_year_id IS NOT NULL', null, false)
->where('closing_batch_id', (int) $batch['id'])
->where('status', 'carried')
->update(['status' => 'open', 'updated_at' => $now, 'updated_by' => $userId]);
$this->db->table('inventory_item_years')
->where('school_year', (string) ($this->requireYear($sourceYearId)['name'] ?? ''))
->where('closing_batch_id', (int) $batch['id'])
->where('status', 'carried')
->update(['status' => 'closed', 'updated_at' => $now, 'updated_by' => $userId]);
}
$this->batchModel->update((int) $batch['id'], [
'status' => 'completed',
'completed_by' => $userId,
@@ -459,6 +479,195 @@ final class SchoolYearClosingService
]);
}
private function inventoryClosingPreview(string $sourceYear, string $targetYear): array
{
$empty = ['rows' => [], 'summary' => ['books' => 0, 'target_opening_quantity' => 0], 'findings' => []];
if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) {
return $empty;
}
$itemYears = $this->db->table('inventory_item_years iy')
->select('iy.*, i.name AS item_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
->where('iy.school_year', $sourceYear)
->where('i.type', 'book')
->orderBy('i.name', 'ASC')
->get()->getResultArray();
if ($itemYears === []) {
return $empty;
}
$ids = array_map(static fn (array $row): int => (int) $row['id'], $itemYears);
$movementTotals = [];
$issueCounts = [];
if ($this->db->tableExists('inventory_movements')) {
foreach ($this->db->table('inventory_movements')
->select('item_year_id, COALESCE(SUM(qty_change), 0) AS movement_total')
->whereIn('item_year_id', $ids)
->whereIn('status', ['posted', 'reversed'])
->groupBy('item_year_id')
->get()->getResultArray() as $row) {
$movementTotals[(int) $row['item_year_id']] = (int) ($row['movement_total'] ?? 0);
}
}
if ($this->db->tableExists('student_book_issues')) {
foreach ($this->db->table('student_book_issues')
->select('inventory_item_year_id, COUNT(*) AS issue_count')
->whereIn('inventory_item_year_id', $ids)
->where('school_year', $sourceYear)
->groupBy('inventory_item_year_id')
->get()->getResultArray() as $row) {
$issueCounts[(int) $row['inventory_item_year_id']] = (int) ($row['issue_count'] ?? 0);
}
}
$findings = [];
$rows = [];
$targetOpeningTotal = 0;
foreach ($itemYears as $row) {
$opening = (int) ($row['opening_quantity'] ?? 0);
$system = $opening + (int) ($movementTotals[(int) $row['id']] ?? 0);
$hasIssueSnapshots = (int) ($issueCounts[(int) $row['id']] ?? 0) > 0;
$counted = $row['counted_closing_quantity'];
$countedInt = $counted === null ? ($hasIssueSnapshots ? null : $system) : (int) $counted;
$variance = $countedInt === null ? null : $countedInt - $system;
$price = (int) ($row['charge_price_cents'] ?? 0);
if ($price <= 0 || (int) ($row['price_confirmed'] ?? 0) !== 1) {
$findings[] = $this->finding(
$hasIssueSnapshots ? 'blocking' : 'warning',
'Book price missing',
(string) $row['item_name'] . ' has no confirmed charge price for ' . $sourceYear
. ($hasIssueSnapshots ? '.' : '; this bootstrap year has no issue price snapshots, so confirm the target-year price before future distribution.')
);
}
if ($system < 0) {
$findings[] = $this->finding('blocking', 'Negative book stock', (string) $row['item_name'] . ' calculates to negative stock.');
}
if ($counted === null && ! $hasIssueSnapshots) {
$findings[] = $this->finding('warning', 'Physical book count defaulted', (string) $row['item_name'] . ' has no physical count in this bootstrap year; system closing quantity will be carried forward.');
} elseif ($countedInt === null) {
$findings[] = $this->finding('blocking', 'Physical book count missing', (string) $row['item_name'] . ' needs a counted closing quantity.');
} elseif ($variance !== 0) {
$findings[] = $this->finding('blocking', 'Unresolved book variance', (string) $row['item_name'] . ' has variance ' . $variance . '. Resolve with an audited adjustment before closing.');
}
$targetOpening = max(0, $countedInt ?? 0);
$targetOpeningTotal += $targetOpening;
$rows[] = [
'item_year_id' => (int) $row['id'],
'inventory_item_id' => (int) $row['inventory_item_id'],
'item_name' => (string) $row['item_name'],
'isbn' => (string) ($row['isbn'] ?? ''),
'edition' => (string) ($row['edition'] ?? ''),
'opening_quantity' => $opening,
'movement_total' => (int) ($movementTotals[(int) $row['id']] ?? 0),
'system_closing_quantity' => $system,
'counted_closing_quantity' => $countedInt,
'variance_quantity' => $variance,
'charge_price_cents' => $price,
'target_school_year' => $targetYear,
'target_opening_quantity' => $targetOpening,
];
}
foreach ($this->inventoryEvidenceFindings($sourceYear) as $finding) {
$findings[] = $finding;
}
return [
'rows' => $rows,
'summary' => ['books' => count($rows), 'target_opening_quantity' => $targetOpeningTotal],
'findings' => $findings,
];
}
private function inventoryEvidenceFindings(string $sourceYear): array
{
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('student_book_issues')) {
return [];
}
$findings = [];
$hasAnyIssueSnapshots = $this->db->table('student_book_issues')
->where('school_year', $sourceYear)
->countAllResults() > 0;
$missingIssues = $this->db->table('inventory_movements m')
->join('student_book_issues sbi', 'sbi.distribution_movement_id = m.id', 'left')
->where('m.school_year', $sourceYear)
->where('m.movement_type', 'distribution')
->where('m.status', 'posted')
->where('sbi.id IS NULL', null, false)
->countAllResults();
if ($missingIssues > 0) {
$findings[] = $this->finding(
$hasAnyIssueSnapshots ? 'blocking' : 'warning',
'Distribution movement missing issue snapshot',
$missingIssues . ' legacy book distribution movement(s) have no linked student book issue'
. ($hasAnyIssueSnapshots ? '.' : '; this bootstrap year will not use those movements as refund price evidence.')
);
}
$missingMovements = $this->db->table('student_book_issues sbi')
->join('inventory_movements m', 'm.id = sbi.distribution_movement_id', 'left')
->where('sbi.school_year', $sourceYear)
->where('sbi.status', 'issued')
->where('m.id IS NULL', null, false)
->countAllResults();
if ($missingMovements > 0) {
$findings[] = $this->finding('blocking', 'Student issue missing stock movement', $missingMovements . ' student book issue(s) have no linked stock movement.');
}
return $findings;
}
private function executeInventoryCarryForward(array $source, array $target, int $batchId, ?int $userId): void
{
if (! $this->db->tableExists('inventory_item_years')) {
return;
}
$preview = $this->inventoryClosingPreview((string) $source['name'], (string) $target['name']);
$blockers = array_values(array_filter($preview['findings'], static fn (array $finding): bool => ($finding['severity'] ?? '') === 'blocking'));
if ($blockers !== []) {
throw new InvalidArgumentException('Resolve inventory closing blockers before executing carry-forward.');
}
$now = date('Y-m-d H:i:s');
foreach ($preview['rows'] as $row) {
$targetOpening = (int) ($row['target_opening_quantity'] ?? 0);
if ($targetOpening <= 0) {
continue;
}
$exists = $this->db->table('inventory_item_years')
->where('inventory_item_id', (int) $row['inventory_item_id'])
->where('school_year', (string) $target['name'])
->get(1)->getRowArray();
if ($exists === null) {
$this->db->table('inventory_item_years')->insert([
'inventory_item_id' => (int) $row['inventory_item_id'],
'school_year_id' => (int) $target['id'],
'school_year' => (string) $target['name'],
'opening_quantity' => $targetOpening,
'charge_price_cents' => (int) $row['charge_price_cents'],
'currency' => 'USD',
'price_confirmed' => 0,
'status' => 'carried',
'source_item_year_id' => (int) $row['item_year_id'],
'closing_batch_id' => $batchId,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
}
$this->db->table('inventory_item_years')->where('id', (int) $row['item_year_id'])->update([
'system_closing_quantity' => (int) $row['system_closing_quantity'],
'variance_quantity' => 0,
'status' => 'carried',
'closing_batch_id' => $batchId,
'updated_by' => $userId,
'updated_at' => $now,
]);
}
}
private function countExistingTargetInvoices(array $batch): int
{
$batchId = (int) ($batch['id'] ?? 0);
@@ -1120,6 +1329,7 @@ final class SchoolYearClosingService
'target_id' => $preview['target']['id'] ?? null,
'finance' => $preview['finance'],
'promotion' => $preview['promotion'],
'inventory' => $preview['inventory'] ?? [],
'carry_forward' => $preview['carry_forward'],
'blockers' => $preview['blockers'],
], JSON_UNESCAPED_SLASHES));