add refund logic and fix books inventory logic
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user