This commit is contained in:
@@ -6,6 +6,7 @@ use App\Models\SchoolYearClosingBatchModel;
|
||||
use App\Models\SchoolYearClosingItemModel;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
@@ -123,8 +124,8 @@ final class SchoolYearClosingService
|
||||
'status' => 'started',
|
||||
'preview_hash' => $preview['hash'],
|
||||
'total_families' => count($preview['carry_forward']),
|
||||
'total_positive_balance' => $preview['finance']['positive_balance'],
|
||||
'total_credit_balance' => $preview['finance']['credit_balance'],
|
||||
'total_positive_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], true),
|
||||
'total_credit_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], false),
|
||||
'started_by' => $userId,
|
||||
'started_at' => $now,
|
||||
], true);
|
||||
@@ -168,22 +169,44 @@ final class SchoolYearClosingService
|
||||
|
||||
$preview = $this->preview($sourceYearId, (int) $batch['target_school_year_id']);
|
||||
if ($preview['hash'] !== (string) $batch['preview_hash']) {
|
||||
throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.');
|
||||
if (($batch['status'] ?? '') === 'started') {
|
||||
$this->refreshBatchFromPreview($batch, $preview);
|
||||
$batch = $this->latestOpenBatch($sourceYearId) ?? $batch;
|
||||
} elseif (in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true) && $this->countExistingTargetInvoices($batch) === 0) {
|
||||
$this->refreshBatchFromPreview($batch, $preview);
|
||||
$batch = $this->latestOpenBatch($sourceYearId) ?? $batch;
|
||||
} elseif (! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) {
|
||||
throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.');
|
||||
}
|
||||
}
|
||||
|
||||
$source = $this->requireYear($sourceYearId);
|
||||
$target = $this->requireYear((int) $batch['target_school_year_id']);
|
||||
$sourceName = (string) ($source['name'] ?? '');
|
||||
$targetName = (string) ($target['name'] ?? '');
|
||||
|
||||
$this->db->transStart();
|
||||
$items = $this->itemModel->where('closing_batch_id', (int) $batch['id'])->findAll();
|
||||
foreach ($items as $item) {
|
||||
if (($item['status'] ?? '') === 'completed') {
|
||||
if (($item['status'] ?? '') === 'completed' && (int) ($item['target_invoice_id'] ?? 0) > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->itemModel->update((int) $item['id'], ['status' => 'completed']);
|
||||
$targetInvoiceId = $this->createCarryForwardInvoice($item, $sourceName, $targetName, $userId);
|
||||
$this->itemModel->update((int) $item['id'], [
|
||||
'target_invoice_id' => $targetInvoiceId,
|
||||
'status' => 'completed',
|
||||
'error_message' => null,
|
||||
]);
|
||||
}
|
||||
$this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
|
||||
$this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSING, 'carry_forward_execute', $userId, [
|
||||
if (($batch['status'] ?? '') !== 'completed') {
|
||||
$this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
|
||||
}
|
||||
$currentStatus = (string) ($source['status'] ?? SchoolYearStatus::CLOSING);
|
||||
$this->managementService->log($sourceYearId, $currentStatus, $currentStatus, 'carry_forward_execute', $userId, [
|
||||
'closing_batch_id' => (int) $batch['id'],
|
||||
'note' => 'Marked previewed carry-forward items complete. Target accounting records require the dedicated opening-balance schema.',
|
||||
'target_school_year_id' => (int) $batch['target_school_year_id'],
|
||||
'note' => 'Created target-year opening balance invoices for previewed carry-forward items.',
|
||||
]);
|
||||
$this->db->transComplete();
|
||||
|
||||
@@ -358,6 +381,7 @@ final class SchoolYearClosingService
|
||||
$count = $this->db->table('invoices')
|
||||
->where('school_year', $schoolYear)
|
||||
->where('balance >', 0)
|
||||
->where("LOWER(status) IN ('unpaid', 'partially paid')", null, false)
|
||||
->countAllResults();
|
||||
|
||||
return $count > 0
|
||||
@@ -374,7 +398,9 @@ final class SchoolYearClosingService
|
||||
$builder = $this->db->table('invoices i')
|
||||
->select('i.parent_id AS family_id')
|
||||
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
||||
->where('i.school_year', $schoolYear);
|
||||
->where('i.school_year', $schoolYear)
|
||||
->where('i.balance !=', 0)
|
||||
->where("LOWER(i.status) IN ('unpaid', 'partially paid')", null, false);
|
||||
|
||||
if ($this->db->tableExists('users')) {
|
||||
$builder
|
||||
@@ -407,6 +433,137 @@ final class SchoolYearClosingService
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
private function refreshBatchFromPreview(array $batch, array $preview): void
|
||||
{
|
||||
$batchId = (int) ($batch['id'] ?? 0);
|
||||
if ($batchId <= 0) {
|
||||
throw new InvalidArgumentException('Closing batch was not found.');
|
||||
}
|
||||
|
||||
$this->itemModel->where('closing_batch_id', $batchId)->delete();
|
||||
foreach ($preview['carry_forward'] as $row) {
|
||||
$this->itemModel->insert([
|
||||
'closing_batch_id' => $batchId,
|
||||
'family_id' => (int) $row['family_id'],
|
||||
'source_balance' => $row['source_balance'],
|
||||
'credit_amount' => $row['credit_amount'],
|
||||
'carry_forward_amount' => $row['carry_forward_amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->batchModel->update($batchId, [
|
||||
'preview_hash' => $preview['hash'],
|
||||
'total_families' => count($preview['carry_forward']),
|
||||
'total_positive_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], true),
|
||||
'total_credit_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], false),
|
||||
]);
|
||||
}
|
||||
|
||||
private function countExistingTargetInvoices(array $batch): int
|
||||
{
|
||||
$batchId = (int) ($batch['id'] ?? 0);
|
||||
if ($batchId <= 0 || ! $this->db->tableExists('invoices')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$items = $this->itemModel
|
||||
->select('target_invoice_id')
|
||||
->where('closing_batch_id', $batchId)
|
||||
->where('target_invoice_id IS NOT NULL', null, false)
|
||||
->where('target_invoice_id >', 0)
|
||||
->findAll();
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_map(static fn (array $row): int => (int) ($row['target_invoice_id'] ?? 0), $items)));
|
||||
if ($invoiceIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->db->table('invoices')
|
||||
->whereIn('id', $invoiceIds)
|
||||
->countAllResults();
|
||||
}
|
||||
|
||||
private function sumCarryForwardBalances(array $rows, bool $positive): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
$amount = (float) ($row['carry_forward_amount'] ?? 0);
|
||||
if ($positive && $amount > 0) {
|
||||
$total += $amount;
|
||||
} elseif (! $positive && $amount < 0) {
|
||||
$total += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
private function createCarryForwardInvoice(array $item, string $sourceYear, string $targetYear, ?int $userId): int
|
||||
{
|
||||
$existingId = (int) ($item['target_invoice_id'] ?? 0);
|
||||
if ($existingId > 0) {
|
||||
$existing = $this->db->table('invoices')->where('id', $existingId)->get()->getRowArray();
|
||||
if ($existing !== null) {
|
||||
return $existingId;
|
||||
}
|
||||
}
|
||||
|
||||
$amount = round((float) ($item['carry_forward_amount'] ?? 0), 2);
|
||||
if (abs($amount) < 0.005) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($item['family_id'] ?? 0);
|
||||
$invoiceNumber = $this->carryForwardInvoiceNumber((int) ($item['id'] ?? 0), $parentId, $sourceYear, $targetYear);
|
||||
$existing = $this->db->table('invoices')
|
||||
->select('id')
|
||||
->where('invoice_number', $invoiceNumber)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existing !== null) {
|
||||
return (int) $existing['id'];
|
||||
}
|
||||
|
||||
$now = function_exists('utc_now') ? utc_now() : gmdate('Y-m-d H:i:s');
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'balance' => $amount,
|
||||
'school_year' => $targetYear,
|
||||
'semester' => 'Opening Balance',
|
||||
'issue_date' => $now,
|
||||
'due_date' => null,
|
||||
'status' => $amount > 0 ? 'Unpaid' : 'Credit',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('description', 'invoices')) {
|
||||
$label = $amount > 0 ? 'Balance carried over' : 'Credit carried over';
|
||||
$payload['description'] = "{$label} from previous school year {$sourceYear}.";
|
||||
}
|
||||
|
||||
$invoiceModel = new InvoiceModel();
|
||||
$invoiceId = $invoiceModel->insert($payload, true);
|
||||
if (! $invoiceId) {
|
||||
throw new RuntimeException('Unable to create carry-forward invoice: ' . json_encode($invoiceModel->errors()));
|
||||
}
|
||||
|
||||
return (int) $invoiceId;
|
||||
}
|
||||
|
||||
private function carryForwardInvoiceNumber(int $itemId, int $parentId, string $sourceYear, string $targetYear): string
|
||||
{
|
||||
$source = preg_replace('/[^0-9A-Za-z]/', '', $sourceYear);
|
||||
$target = preg_replace('/[^0-9A-Za-z]/', '', $targetYear);
|
||||
|
||||
return sprintf('CF-%s-%s-P%d-I%d', $source, $target, $parentId, $itemId);
|
||||
}
|
||||
|
||||
private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array
|
||||
{
|
||||
$summary = [
|
||||
@@ -764,7 +921,7 @@ final class SchoolYearClosingService
|
||||
{
|
||||
return $this->batchModel
|
||||
->where('source_school_year_id', $sourceYearId)
|
||||
->whereIn('status', ['started', 'executed'])
|
||||
->whereIn('status', ['started', 'executed', 'completed'])
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user