From 97fea48393819655566c52b12e395a078e8a5eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E9=93=AD=E6=B4=8B?= Date: Sat, 29 Aug 2026 11:52:22 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E9=87=91=E7=89=9B=E9=9B=86?= =?UTF-8?q?=E5=9B=A2=E8=B4=B7=E6=AC=BE=E6=A1=A3=E6=A1=88=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入现有代码与文档,排除 data/uploads 运行时数据。 --- .gitignore | 25 + app/auth.php | 43 + app/config.php | 14 + app/db.php | 110 + app/helpers.php | 100 + app/services/quick_queries.php | 234 + app/views/acceptance_discount_calculator.php | 285 ++ app/views/interest_calculator.php | 218 + muban.xlsx | Bin 0 -> 17186 bytes public/index.php | 4063 ++++++++++++++++++ 功能更新.md | 13 + 功能说明.md | 190 + 更新1.2.md | 79 + 部署说明.md | 104 + 14 files changed, 5478 insertions(+) create mode 100644 .gitignore create mode 100755 app/auth.php create mode 100755 app/config.php create mode 100755 app/db.php create mode 100755 app/helpers.php create mode 100644 app/services/quick_queries.php create mode 100644 app/views/acceptance_discount_calculator.php create mode 100644 app/views/interest_calculator.php create mode 100755 muban.xlsx create mode 100755 public/index.php create mode 100755 功能更新.md create mode 100755 功能说明.md create mode 100644 更新1.2.md create mode 100755 部署说明.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4744c2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 运行时数据与上传(勿入库) +/data/ +/uploads/ +!/data/.gitkeep +!/uploads/.gitkeep + +# Composer +/vendor/ +composer.lock + +# 本地与 IDE +.env +.env.* +!.env.example +.DS_Store +*.swp +*.swo +.idea/ +.vscode/ +*.log + +# 临时与备份 +*.bak +*.tmp +/tmp/ diff --git a/app/auth.php b/app/auth.php new file mode 100755 index 0000000..299c662 --- /dev/null +++ b/app/auth.php @@ -0,0 +1,43 @@ +prepare('SELECT id, username, role, created_at FROM users WHERE id = :id'); + $stmt->execute([':id' => (int)$_SESSION['uid']]); + $u = $stmt->fetch(); + return $u ?: null; +} + +function isAdmin(): bool +{ + $u = currentUser(); + return $u && $u['role'] === 'admin'; +} + +function requireLogin(): void +{ + if (!currentUser()) { + header('Location: index.php?action=login'); + exit; + } +} + +function requireAdmin(): void +{ + requireLogin(); + if (!isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + exit; + } +} diff --git a/app/config.php b/app/config.php new file mode 100755 index 0000000..f44701d --- /dev/null +++ b/app/config.php @@ -0,0 +1,14 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA foreign_keys = ON'); + + initSchema($pdo); + seedAdmin($pdo); + return $pdo; +} + +function initSchema(PDO $pdo): void +{ + $pdo->exec(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','user')), + created_at TEXT NOT NULL + ); + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS directories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER NULL, + level INTEGER NOT NULL CHECK(level BETWEEN 1 AND 3), + created_at TEXT NOT NULL, + FOREIGN KEY(parent_id) REFERENCES directories(id) ON DELETE CASCADE + ); + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + directory_id INTEGER NOT NULL, + loan_institution TEXT, + borrower TEXT, + credit_limit REAL, + remaining_limit REAL, + start_date TEXT, + end_date TEXT, + annual_rate REAL, + guarantee_type TEXT, + guarantor TEXT, + archive_kind TEXT NOT NULL DEFAULT 'bank_loan', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(directory_id) REFERENCES directories(id) ON DELETE CASCADE + ); + "); + + $hasArchiveKind = false; + foreach ($pdo->query('PRAGMA table_info(archives)')->fetchAll(PDO::FETCH_ASSOC) as $col) { + if (($col['name'] ?? '') === 'archive_kind') { + $hasArchiveKind = true; + break; + } + } + if (!$hasArchiveKind) { + $pdo->exec("ALTER TABLE archives ADD COLUMN archive_kind TEXT NOT NULL DEFAULT 'bank_loan'"); + } + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id INTEGER NOT NULL, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + file_ext TEXT NOT NULL, + file_size INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(archive_id) REFERENCES archives(id) ON DELETE CASCADE + ); + "); + + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_archives_end_date ON archives(end_date)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_archives_loan_inst ON archives(loan_institution)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_archives_borrower ON archives(borrower)"); +} + +function seedAdmin(PDO $pdo): void +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE username = :username'); + $stmt->execute([':username' => 'admin']); + if ((int)$stmt->fetchColumn() > 0) { + return; + } + $stmt = $pdo->prepare('INSERT INTO users(username,password_hash,role,created_at) VALUES(:u,:p,:r,:c)'); + $stmt->execute([ + ':u' => 'admin', + ':p' => password_hash('jinniu123', PASSWORD_DEFAULT), + ':r' => 'admin', + ':c' => date('Y-m-d H:i:s'), + ]); +} diff --git a/app/helpers.php b/app/helpers.php new file mode 100755 index 0000000..e2f4f14 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,100 @@ + $type, 'msg' => $msg]; +} + +function getFlash(): ?array +{ + if (!isset($_SESSION['flash'])) { + return null; + } + $f = $_SESSION['flash']; + unset($_SESSION['flash']); + return $f; +} + +function normalizeFileName(string $name): string +{ + $name = strtolower($name); + $name = preg_replace('/[^a-z0-9\.]+/', '_', $name); + return trim((string)$name, '_'); +} + +function archiveStatus(?string $endDate): string +{ + if (!$endDate) { + return '待更新'; + } + $today = date('Y-m-d'); + if ($endDate >= $today) { + return '履行中'; + } + return '待更新'; +} + +function canPreview(string $ext): bool +{ + return in_array(strtolower($ext), ['pdf', 'jpg', 'jpeg', 'png', 'gif'], true); +} + +function amount(?float $v): string +{ + if ($v === null) { + return ''; + } + return number_format($v, 2, '.', ''); +} + +function dirPathMap(array $dirs): array +{ + $map = []; + foreach ($dirs as $d) { + $map[(int)$d['id']] = $d; + } + $path = []; + foreach ($dirs as $d) { + $id = (int)$d['id']; + $parts = [$d['name']]; + $p = $d['parent_id']; + while ($p && isset($map[(int)$p])) { + $parts[] = $map[(int)$p]['name']; + $p = $map[(int)$p]['parent_id']; + } + $path[$id] = implode(' / ', array_reverse($parts)); + } + return $path; +} + +function collectSubDirIds(array $dirs, int $rootId): array +{ + $children = []; + foreach ($dirs as $d) { + $pid = $d['parent_id'] === null ? null : (int)$d['parent_id']; + if ($pid !== null) { + $children[$pid][] = (int)$d['id']; + } + } + $result = [$rootId]; + $queue = [$rootId]; + while ($queue) { + $cur = array_shift($queue); + foreach ($children[$cur] ?? [] as $cid) { + $result[] = $cid; + $queue[] = $cid; + } + } + return array_values(array_unique($result)); +} diff --git a/app/services/quick_queries.php b/app/services/quick_queries.php new file mode 100644 index 0000000..a637540 --- /dev/null +++ b/app/services/quick_queries.php @@ -0,0 +1,234 @@ + 0, 'current_interest' => 0.0, 'settlement_interest' => 0.0, 'settlement_dates' => []]; + } + $cfg = getArchiveFinanceConfig($financeAll, $aid); + $plans = (array)((getArchiveRepaymentPlan($planAll, $aid))['plans'] ?? []); + $mode = (string)($cfg['interest_mode'] ?? 'monthly'); + $nextSettle = $mode === 'bullet' + ? ((string)($a['end_date'] ?? '') >= $todayQ ? (string)$a['end_date'] : null) + : nextSettlementDate($todayQ, $mode, (int)($cfg['settlement_day'] ?? 21)); + $instStats[$inst]['loan_count']++; + $instStats[$inst]['current_interest'] += calcCurrentInterest($a, $cfg, $plans); + if (!empty($nextSettle)) { + $instStats[$inst]['settlement_interest'] += calcSettlementPeriodInterest($a, $cfg, $plans, $todayQ); + $instStats[$inst]['settlement_dates'][(string)$nextSettle] = true; + } + } + ksort($instStats); + $instRows = []; + foreach ($instStats as $inst => $s) { + $instRows[] = [ + 'loan_institution' => $inst, + 'loan_count' => (int)$s['loan_count'], + 'current_interest' => round((float)$s['current_interest'], 2), + 'settlement_interest' => round((float)$s['settlement_interest'], 2), + 'settlement_date' => implode(' / ', array_keys((array)$s['settlement_dates'])), + ]; + } + + $planRows = []; + $planTotal = 0.0; + foreach ($planAll as $aid => $cfg) { + if (empty($cfg['enabled']) || empty($cfg['plans']) || !is_array($cfg['plans'])) { + continue; + } + $aidInt = (int)$aid; + $arc = $archiveMap[$aidInt] ?? null; + if (!$arc) { + continue; + } + foreach ((array)$cfg['plans'] as $p) { + $status = (string)($p['status'] ?? 'pending'); + $due = (string)($p['due_date'] ?? ''); + if ($due === '' || $due < $monthStart || $due > $monthEnd || $status === 'paid') { + continue; + } + $inst = (string)($arc['loan_institution'] ?? ''); + if ($inst === '') { + $inst = '未填写机构'; + } + $amt = (float)($p['amount'] ?? 0); + $planRows[] = [ + 'loan_institution' => $inst, + 'archive_name' => (string)($arc['name'] ?? ''), + 'borrower' => (string)($arc['borrower'] ?? ''), + 'due_date' => $due, + 'amount' => $amt, + 'status' => $status, + ]; + $planTotal += $amt; + } + } + usort($planRows, function ($a, $b) { + $cmp = strcmp((string)$a['due_date'], (string)$b['due_date']); + if ($cmp !== 0) { + return $cmp; + } + return strcmp((string)$a['loan_institution'], (string)$b['loan_institution']); + }); + return ['instRows' => $instRows, 'planRows' => $planRows, 'planTotal' => round($planTotal, 2)]; +} + +/** + * @param string $scope all_bank|all_guar|bank_inst|guar_inst + * @param string|null $inst 与档案「贷款机构/债权人」完全一致;后两项 scope 必填 + * @return array{count:int,sum_credit:float,sum_remaining:float} + */ +function qqBuildTotalsQuery(array $rowsAll, array $financeAll, string $scope, ?string $inst): array +{ + if (($scope === 'bank_inst' || $scope === 'guar_inst') && ($inst === null || trim($inst) === '')) { + return ['count' => 0, 'sum_credit' => 0.0, 'sum_remaining' => 0.0]; + } + $instNorm = $inst !== null ? trim($inst) : ''; + $filtered = array_filter($rowsAll, function ($a) use ($financeAll, $scope, $instNorm): bool { + if (!archiveStatusIsOngoingAggregate((string)($a['end_date'] ?? ''), getArchiveFinanceConfig($financeAll, (int)$a['id']))) { + return false; + } + $kind = normalizeArchiveKind((string)($a['archive_kind'] ?? '')); + if ($scope === 'all_bank') { + return $kind === ARCHIVE_KIND_BANK_LOAN; + } + if ($scope === 'all_guar') { + return $kind === ARCHIVE_KIND_EXTERNAL_GUARANTEE; + } + $rowInst = (string)($a['loan_institution'] ?? ''); + if ($scope === 'bank_inst') { + return $kind === ARCHIVE_KIND_BANK_LOAN && $rowInst === $instNorm; + } + if ($scope === 'guar_inst') { + return $kind === ARCHIVE_KIND_EXTERNAL_GUARANTEE && $rowInst === $instNorm; + } + return false; + }); + $sumCredit = 0.0; + $sumRemaining = 0.0; + $count = 0; + foreach ($filtered as $a) { + $count++; + if (isset($a['credit_limit']) && $a['credit_limit'] !== null && $a['credit_limit'] !== '') { + $sumCredit += (float)$a['credit_limit']; + } + if (isset($a['remaining_limit']) && $a['remaining_limit'] !== null && $a['remaining_limit'] !== '') { + $sumRemaining += (float)$a['remaining_limit']; + } + } + return [ + 'count' => $count, + 'sum_credit' => round($sumCredit, 2), + 'sum_remaining' => round($sumRemaining, 2), + ]; +} + +function qqSearchGuarantorArchives(array $rows, string $kw): array +{ + return array_values(array_filter($rows, function ($r) use ($kw) { + if ($kw === '') { + return false; + } + return mb_stripos((string)($r['guarantor'] ?? ''), $kw) !== false; + })); +} + +/** + * 历史还款:分期计划已还本金明细 + 已完成档案授信合计(按类型) + * + * @return array{plan_rows: list, plan_paid_total: float, completed_bank: array{count:int,sum_credit:float}, completed_guar: array{count:int,sum_credit:float}} + */ +function qqBuildRepaymentHistory(array $rowsAll, array $planAll, array $financeAll): array +{ + $map = []; + foreach ($rowsAll as $r) { + $map[(int)($r['id'] ?? 0)] = $r; + } + $planRows = []; + $planPaidTotal = 0.0; + foreach ($planAll as $aidStr => $cfg) { + $aid = (int)$aidStr; + if (empty($cfg['enabled']) || empty($cfg['plans']) || !is_array($cfg['plans'])) { + continue; + } + $arc = $map[$aid] ?? null; + foreach ($cfg['plans'] as $p) { + if ((string)($p['status'] ?? '') !== 'paid') { + continue; + } + $amt = (float)($p['amount'] ?? 0); + $planPaidTotal += $amt; + $planRows[] = [ + 'archive_id' => $aid, + 'archive_name' => $arc ? (string)($arc['name'] ?? '') : '(档案不存在)', + 'loan_institution' => $arc ? (string)($arc['loan_institution'] ?? '') : '', + 'borrower' => $arc ? (string)($arc['borrower'] ?? '') : '', + 'due_date' => (string)($p['due_date'] ?? ''), + 'paid_at' => (string)($p['paid_at'] ?? ''), + 'amount' => $amt, + ]; + } + } + usort($planRows, static function ($a, $b): int { + $c = strcmp((string)($b['paid_at'] ?? ''), (string)($a['paid_at'] ?? '')); + if ($c !== 0) { + return $c; + } + return ((int)($b['archive_id'] ?? 0)) <=> ((int)($a['archive_id'] ?? 0)); + }); + + $completedBank = ['count' => 0, 'sum_credit' => 0.0]; + $completedGuar = ['count' => 0, 'sum_credit' => 0.0]; + foreach ($rowsAll as $a) { + $st = archiveStatusWithConfig((string)($a['end_date'] ?? ''), getArchiveFinanceConfig($financeAll, (int)$a['id'])); + if ($st !== '已完成') { + continue; + } + $kind = normalizeArchiveKind((string)($a['archive_kind'] ?? '')); + $credit = $a['credit_limit'] !== null && $a['credit_limit'] !== '' ? (float)$a['credit_limit'] : 0.0; + if ($kind === ARCHIVE_KIND_BANK_LOAN) { + $completedBank['count']++; + $completedBank['sum_credit'] += $credit; + } elseif ($kind === ARCHIVE_KIND_EXTERNAL_GUARANTEE) { + $completedGuar['count']++; + $completedGuar['sum_credit'] += $credit; + } + } + + return [ + 'plan_rows' => $planRows, + 'plan_paid_total' => round($planPaidTotal, 2), + 'completed_bank' => [ + 'count' => (int)$completedBank['count'], + 'sum_credit' => round((float)$completedBank['sum_credit'], 2), + ], + 'completed_guar' => [ + 'count' => (int)$completedGuar['count'], + 'sum_credit' => round((float)$completedGuar['sum_credit'], 2), + ], + ]; +} diff --git a/app/views/acceptance_discount_calculator.php b/app/views/acceptance_discount_calculator.php new file mode 100644 index 0000000..32209a3 --- /dev/null +++ b/app/views/acceptance_discount_calculator.php @@ -0,0 +1,285 @@ + +
+

承兑贴现计算器

+ +
+
+

票据信息

+
+ +
+ + +

银承信用风险相对较低;商承请结合承兑人资信评估。

+
+
+
+ +
+ +
+
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+ +
+

贴现参数

+
+ +
+ + + + +
+
+
+ + 360 天/年(票据贴现常用) +
+
+ +
+
+ + +
+

反向推算(选填其一)

+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+

计算结果

+

填写左侧并点击「计算」

+ +
+
+

本地历史记录

+

保存在本浏览器,可对比多次试算。

+
    + +
    +
    +
    +
    + diff --git a/app/views/interest_calculator.php b/app/views/interest_calculator.php new file mode 100644 index 0000000..3139a90 --- /dev/null +++ b/app/views/interest_calculator.php @@ -0,0 +1,218 @@ + +
    +

    利息计算器

    + +
    +
    +

    输入

    +
    + +
    + +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    + +
    + + +
    +
    + + +
    +
    + + + +
    +
    +
    +

    计算结果

    +

    请先填写左侧数据并点击「计算」

    + +
    +
    +
    + diff --git a/muban.xlsx b/muban.xlsx new file mode 100755 index 0000000000000000000000000000000000000000..9c3d6b9d8f0cbae1760f595b7ddb33d83c0bf829 GIT binary patch literal 17186 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 E10mB|82|tP literal 0 HcmV?d00001 diff --git a/public/index.php b/public/index.php new file mode 100755 index 0000000..6122462 --- /dev/null +++ b/public/index.php @@ -0,0 +1,4063 @@ +prepare('SELECT * FROM users WHERE username = :u'); + $stmt->execute([':u' => $username]); + $user = $stmt->fetch(); + if ($user && password_verify($password, $user['password_hash'])) { + $_SESSION['uid'] = (int)$user['id']; + header('Location: index.php'); + exit; + } + flash('error', '用户名或密码错误'); + header('Location: index.php?action=login'); + exit; + } + $flash = getFlash(); + ?> + + 金牛集团贷款档案管理系统 - 登录 + + +
    +

    金牛集团贷款档案管理系统

    +

    请输入账号密码登录系统

    +
    +
    + + + +
    +
    + + + + 金牛集团贷款档案管理系统 + + +
    +
    金牛集团贷款档案管理系统
    + +
    +
    + +
    + + + + + +HTML; +} + +function fetchDirectories(PDO $pdo): array +{ + return $pdo->query('SELECT * FROM directories ORDER BY level ASC, id ASC')->fetchAll(); +} + +function safePathPart(string $name): string +{ + $name = trim($name); + $name = preg_replace('/[\/\\\\:\*\?"<>\|]+/u', '_', $name); + $name = preg_replace('/\s+/u', '_', (string)$name); + $name = trim((string)$name, '._'); + return $name !== '' ? $name : 'unnamed'; +} + +function buildArchiveUploadSubdir(PDO $pdo, int $archiveId): string +{ + $stmt = $pdo->prepare('SELECT id,name,directory_id FROM archives WHERE id=:id'); + $stmt->execute([':id' => $archiveId]); + $archive = $stmt->fetch(); + if (!$archive) { + return 'unknown/archive_' . $archiveId; + } + $dirs = fetchDirectories($pdo); + $map = []; + foreach ($dirs as $d) { + $map[(int)$d['id']] = $d; + } + $parts = []; + $cur = (int)$archive['directory_id']; + while (isset($map[$cur])) { + $parts[] = safePathPart((string)$map[$cur]['name']); + $pid = $map[$cur]['parent_id']; + if ($pid === null) { + break; + } + $cur = (int)$pid; + } + $parts = array_reverse($parts); + $parts[] = 'archive_' . (int)$archive['id'] . '_' . safePathPart((string)$archive['name']); + return implode('/', $parts); +} + +function maskUsername(string $username): string +{ + $len = mb_strlen($username); + if ($len <= 2) { + return $username; + } + return mb_substr($username, 0, 1) . '***' . mb_substr($username, -1); +} + +function roleLabel(string $role): string +{ + return $role === 'admin' ? '管理员' : '普通用户'; +} + +function repaymentPlanStorePath(): string +{ + return __DIR__ . '/../data/repayment_plans.json'; +} + +function loadRepaymentPlans(): array +{ + $path = repaymentPlanStorePath(); + if (!is_file($path)) { + return []; + } + $raw = file_get_contents($path); + if ($raw === false || $raw === '') { + return []; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +function saveRepaymentPlans(array $data): void +{ + $path = repaymentPlanStorePath(); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); +} + +function getArchiveRepaymentPlan(array $all, int $archiveId): array +{ + return $all[(string)$archiveId] ?? ['enabled' => false, 'plans' => []]; +} + +function financeStorePath(): string +{ + return __DIR__ . '/../data/archive_finance.json'; +} + +function loadFinanceConfigs(): array +{ + $path = financeStorePath(); + if (!is_file($path)) { + return []; + } + $raw = file_get_contents($path); + if ($raw === false || $raw === '') { + return []; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +function saveFinanceConfigs(array $data): void +{ + $path = financeStorePath(); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); +} + +function getArchiveFinanceConfig(array $all, int $archiveId): array +{ + $cfg = $all[(string)$archiveId] ?? []; + return [ + 'interest_mode' => $cfg['interest_mode'] ?? 'monthly', + 'settlement_rule' => $cfg['settlement_rule'] ?? 'monthly_day', + 'settlement_day' => (int)($cfg['settlement_day'] ?? 21), + 'overdue_float_pct' => (float)($cfg['overdue_float_pct'] ?? 50), + 'manual_overdue' => !empty($cfg['manual_overdue']), + 'manual_early_settled' => !empty($cfg['manual_early_settled']), + 'remark' => (string)($cfg['remark'] ?? ''), + 'manual_principal_repaid' => (float)($cfg['manual_principal_repaid'] ?? 0), + 'manual_interest_paid' => (float)($cfg['manual_interest_paid'] ?? 0), + ]; +} + +/** 计息用本金:优先剩余额度,否则授信额度 */ +function effectivePrincipalForInterest(array $archive): float +{ + if (isset($archive['remaining_limit']) && $archive['remaining_limit'] !== null && $archive['remaining_limit'] !== '') { + return max(0.0, (float)$archive['remaining_limit']); + } + if ($archive['credit_limit'] === null || $archive['credit_limit'] === '') { + return 0.0; + } + return max(0.0, (float)$archive['credit_limit']); +} + +function archiveStatusWithConfig(?string $endDate, array $financeCfg): string +{ + if (!empty($financeCfg['manual_early_settled'])) { + return '已完成'; + } + if (!empty($financeCfg['manual_overdue'])) { + return '逾期中'; + } + // 与 helpers::archiveStatus 一致;内联避免因 helpers 未同步部署仍按旧逻辑显示「到期自动已完成」 + if (!$endDate) { + return '待更新'; + } + $today = date('Y-m-d'); + if ($endDate >= $today) { + return '履行中'; + } + return '待更新'; +} + +/** 便捷查询汇总:履行中与逾期中均视为未结清存续业务 */ +function archiveStatusIsOngoingAggregate(?string $endDate, array $financeCfg): bool +{ + $s = archiveStatusWithConfig($endDate, $financeCfg); + return $s === '履行中' || $s === '逾期中'; +} + +/** 与当前结息周期对齐的区间起点(按 refDate 所在周期),用于「当前应付利息」 */ +function interestPeriodStartForRefDate(array $archive, array $financeCfg, string $refDate): string +{ + $startDate = $archive['start_date'] ?: $refDate; + $mode = (string)($financeCfg['interest_mode'] ?? 'monthly'); + $settlementRule = (string)($financeCfg['settlement_rule'] ?? 'monthly_day'); + $day = max(1, min(28, (int)($financeCfg['settlement_day'] ?? 20))); + $periodStart = $startDate; + if ($settlementRule === 'monthly_day' && $mode !== 'bullet') { + $curYm = date('Y-m', strtotime($refDate)); + $thisSettle = $curYm . '-' . str_pad((string)$day, 2, '0', STR_PAD_LEFT); + if ($refDate >= $thisSettle) { + $periodStart = $thisSettle; + } else { + $periodStart = date('Y-m-d', strtotime('-1 month', strtotime($thisSettle))); + } + } elseif ($settlementRule === 'quarterly_day' && $mode !== 'bullet') { + $m = (int)date('n', strtotime($refDate)); + $qStartMonth = ((int)(($m - 1) / 3)) * 3 + 1; + $curQuarterSettle = date('Y', strtotime($refDate)) . '-' . str_pad((string)$qStartMonth, 2, '0', STR_PAD_LEFT) . '-' . str_pad((string)$day, 2, '0', STR_PAD_LEFT); + if ($refDate >= $curQuarterSettle) { + $periodStart = $curQuarterSettle; + } else { + $periodStart = date('Y-m-d', strtotime('-3 month', strtotime($curQuarterSettle))); + } + } + if ($periodStart < $startDate) { + $periodStart = $startDate; + } + return $periodStart; +} + +/** + * 下一结息日所在「整段结息周期」的起始日:上一结息日与起息日孰晚。 + * 用于结息日应付利息(自周期起点至结息日,而非自今日至结息日)。 + */ +function settlementPeriodStartForNextSettle(string $nextSettle, array $archive, array $financeCfg): string +{ + $startDate = $archive['start_date'] ?: $nextSettle; + $mode = (string)($financeCfg['interest_mode'] ?? 'monthly'); + $settlementRule = (string)($financeCfg['settlement_rule'] ?? 'monthly_day'); + $day = max(1, min(28, (int)($financeCfg['settlement_day'] ?? 20))); + if ($settlementRule === 'maturity_once' || $mode === 'bullet') { + return $startDate; + } + $prevAnchor = $startDate; + if ($settlementRule === 'monthly_day') { + $prevAnchor = date('Y-m-d', strtotime('-1 month', strtotime($nextSettle))); + } elseif ($settlementRule === 'quarterly_day') { + $prevAnchor = date('Y-m-d', strtotime('-3 months', strtotime($nextSettle))); + } + return max($startDate, $prevAnchor); +} + +/** 自 periodStart 起至 throughDate(含区间逻辑与 calcCurrentInterest 一致)的应计利息,不含「已归还利息」扣减 */ +function calcAccruedInterestForRange(array $archive, array $financeCfg, array $repaymentPlans, string $periodStart, string $throughDate): float +{ + $principal = effectivePrincipalForInterest($archive); + $annualRate = $archive['annual_rate'] === null ? 0.0 : (float)$archive['annual_rate']; + if ($principal <= 0 || $annualRate <= 0 || $throughDate < $periodStart) { + return 0.0; + } + + $status = archiveStatusWithConfig($archive['end_date'], $financeCfg); + $overdueRate = $annualRate * (1 + ((float)($financeCfg['overdue_float_pct'] ?? 0) / 100)); + $events = []; + $overduePrincipal = 0.0; + foreach ($repaymentPlans as $p) { + $amount = (float)($p['amount'] ?? 0); + if ($amount <= 0) { + continue; + } + $pStatus = (string)($p['status'] ?? 'pending'); + if ($pStatus === 'paid' && !empty($p['paid_at'])) { + $paidAt = (string)$p['paid_at']; + if ($paidAt >= $periodStart && $paidAt <= $throughDate) { + $events[] = ['date' => $paidAt, 'type' => 'paid', 'amount' => $amount]; + } + continue; + } + if ($pStatus === 'overdue') { + $dueDate = (string)($p['due_date'] ?? ''); + if ($dueDate === '' || $dueDate > $throughDate) { + continue; + } + if ($dueDate < $periodStart) { + $overduePrincipal += $amount; + } else { + $events[] = ['date' => $dueDate, 'type' => 'overdue_start', 'amount' => $amount]; + } + } + } + usort($events, function ($a, $b) { + $cmp = strcmp((string)$a['date'], (string)$b['date']); + if ($cmp !== 0) { + return $cmp; + } + $orderA = ($a['type'] ?? '') === 'overdue_start' ? 0 : 1; + $orderB = ($b['type'] ?? '') === 'overdue_start' ? 0 : 1; + return $orderA <=> $orderB; + }); + + $interest = 0.0; + $cursor = $periodStart; + $currentPrincipal = $principal; + foreach ($events as $ev) { + $evDate = (string)($ev['date'] ?? ''); + if ($evDate > $cursor) { + $days = (strtotime($evDate) - strtotime($cursor)) / 86400; + if ($days > 0) { + $normalPrincipal = max(0, $currentPrincipal - $overduePrincipal); + $interest += $normalPrincipal * ($annualRate / 100) * ($days / 360); + $interest += $overduePrincipal * ($overdueRate / 100) * ($days / 360); + } + } + $amount = (float)($ev['amount'] ?? 0); + if (($ev['type'] ?? '') === 'overdue_start') { + $overduePrincipal = min($currentPrincipal, $overduePrincipal + $amount); + } elseif (($ev['type'] ?? '') === 'paid') { + $currentPrincipal = max(0, $currentPrincipal - $amount); + $overduePrincipal = min($overduePrincipal, $currentPrincipal); + } + $cursor = $evDate; + } + if ($throughDate > $cursor) { + $days = (strtotime($throughDate) - strtotime($cursor)) / 86400; + if ($days > 0) { + $normalPrincipal = max(0, $currentPrincipal - $overduePrincipal); + if ($overduePrincipal <= 0 && $status === '逾期中') { + $interest += $currentPrincipal * ($overdueRate / 100) * ($days / 360); + } else { + $interest += $normalPrincipal * ($annualRate / 100) * ($days / 360); + $interest += $overduePrincipal * ($overdueRate / 100) * ($days / 360); + } + } + } + return round($interest, 2); +} + +function applyInterestPaidCredit(float $accrued, array $financeCfg): float +{ + $paid = (float)($financeCfg['manual_interest_paid'] ?? 0); + return round(max(0.0, $accrued - $paid), 2); +} + +function calcCurrentInterest(array $archive, array $financeCfg, array $repaymentPlans): float +{ + $today = date('Y-m-d'); + $periodStart = interestPeriodStartForRefDate($archive, $financeCfg, $today); + $raw = calcAccruedInterestForRange($archive, $financeCfg, $repaymentPlans, $periodStart, $today); + return applyInterestPaidCredit($raw, $financeCfg); +} + +/** 最近一个结息日应付的整周期利息(与利息查询一致) */ +function calcSettlementPeriodInterest(array $archive, array $financeCfg, array $repaymentPlans, string $todayRef): float +{ + $mode = (string)($financeCfg['interest_mode'] ?? 'monthly'); + $annualRate = $archive['annual_rate'] === null ? 0.0 : (float)$archive['annual_rate']; + if (effectivePrincipalForInterest($archive) <= 0 || $annualRate <= 0) { + return 0.0; + } + $nextSettle = $mode === 'bullet' + ? (((string)($archive['end_date'] ?? '') !== '' && (string)$archive['end_date'] >= $todayRef) ? (string)$archive['end_date'] : null) + : nextSettlementDate($todayRef, $mode, (int)($financeCfg['settlement_day'] ?? 21)); + if ($nextSettle === null || $nextSettle === '') { + return 0.0; + } + $periodStart = settlementPeriodStartForNextSettle($nextSettle, $archive, $financeCfg); + $raw = calcAccruedInterestForRange($archive, $financeCfg, $repaymentPlans, $periodStart, $nextSettle); + return applyInterestPaidCredit($raw, $financeCfg); +} + +function recalcRemainingLimitByPlans(PDO $pdo, int $archiveId, array $plans): void +{ + if ($archiveId <= 0) { + return; + } + $st = $pdo->prepare('SELECT credit_limit FROM archives WHERE id=:id'); + $st->execute([':id' => $archiveId]); + $credit = $st->fetchColumn(); + if ($credit === false || $credit === null) { + return; + } + $paidSum = 0.0; + foreach ($plans as $p) { + if ((string)($p['status'] ?? '') === 'paid') { + $paidSum += (float)($p['amount'] ?? 0); + } + } + $financeAll = loadFinanceConfigs(); + $manualPrincipal = (float)(getArchiveFinanceConfig($financeAll, $archiveId)['manual_principal_repaid'] ?? 0); + $remaining = max(0, (float)$credit - $paidSum - $manualPrincipal); + $pdo->prepare('UPDATE archives SET remaining_limit=:r,updated_at=:u WHERE id=:id') + ->execute([':r' => $remaining, ':u' => now(), ':id' => $archiveId]); +} + +function uploadArchiveFiles(PDO $pdo, int $archiveId, array $files): void +{ + if ($archiveId <= 0 || empty($files) || empty($files['name']) || !is_array($files['name'])) { + return; + } + $names = $files['name']; + $tmpNames = $files['tmp_name'] ?? []; + $sizes = $files['size'] ?? []; + $errors = $files['error'] ?? []; + + $subDir = buildArchiveUploadSubdir($pdo, $archiveId); + $targetDir = rtrim(UPLOAD_DIR . '/' . $subDir, '/'); + if (!is_dir($targetDir)) { + mkdir($targetDir, 0775, true); + } + for ($i = 0; $i < count($names); $i++) { + if ((int)($errors[$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + continue; + } + if ((int)($sizes[$i] ?? 0) > MAX_FILE_SIZE) { + continue; + } + $original = (string)$names[$i]; + $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); + if (!in_array($ext, ALLOWED_EXTENSIONS, true)) { + continue; + } + $normalized = normalizeFileName($original); + $stored = $subDir . '/' . uniqid('f_', true) . '_' . $normalized; + if (move_uploaded_file((string)$tmpNames[$i], UPLOAD_DIR . '/' . $stored)) { + $pdo->prepare('INSERT INTO files(archive_id,original_name,stored_name,file_ext,file_size,created_at) VALUES(:a,:o,:s,:e,:z,:c)') + ->execute([':a' => $archiveId, ':o' => $original, ':s' => $stored, ':e' => $ext, ':z' => (int)$sizes[$i], ':c' => now()]); + } + } +} + +function deleteApprovalStorePath(): string +{ + return __DIR__ . '/../data/delete_approvals.json'; +} + +function loadDeleteApprovals(): array +{ + $path = deleteApprovalStorePath(); + if (!is_file($path)) { + return []; + } + $raw = file_get_contents($path); + if ($raw === false || $raw === '') { + return []; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +function saveDeleteApprovals(array $data): void +{ + $path = deleteApprovalStorePath(); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); +} + +function currentUrlFromServer(): string +{ + $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); + if ($uri === '' || strpos($uri, 'index.php') === false) { + return 'index.php'; + } + return $uri; +} + +function redirectBackOr(string $fallback): void +{ + $posted = trim((string)($_POST['redirect_to'] ?? '')); + $query = trim((string)($_GET['redirect_to'] ?? '')); + $ref = trim((string)($_SERVER['HTTP_REFERER'] ?? '')); + $target = $posted !== '' ? $posted : ($query !== '' ? $query : $ref); + if ($target === '' || strpos($target, 'index.php') === false) { + $target = $fallback; + } + header('Location: ' . $target); + exit; +} + +function fetchArchiveSimpleMap(PDO $pdo): array +{ + $rows = $pdo->query('SELECT id,name,loan_institution,borrower,archive_kind FROM archives')->fetchAll(); + $map = []; + foreach ($rows as $r) { + $id = (int)($r['id'] ?? 0); + if ($id <= 0) { + continue; + } + $map[$id] = [ + 'name' => (string)($r['name'] ?? ''), + 'loan_institution' => (string)($r['loan_institution'] ?? ''), + 'borrower' => (string)($r['borrower'] ?? ''), + 'archive_kind' => normalizeArchiveKind((string)($r['archive_kind'] ?? '')), + ]; + } + return $map; +} + +function approvalTargetLabel(array $ap, array $archiveMap): string +{ + $type = (string)($ap['target_type'] ?? ''); + if ($type === 'archive_batch') { + $ids = array_values(array_unique(array_filter(array_map('intval', (array)($ap['target_ids'] ?? []))))); + $labels = []; + foreach (array_slice($ids, 0, 5) as $aid) { + if (!isset($archiveMap[$aid])) { + $labels[] = 'ID:' . $aid . '(已删除)'; + continue; + } + $arc = $archiveMap[$aid]; + $labels[] = '[' . archiveKindLabel((string)$arc['archive_kind']) . '] ' . (string)$arc['name']; + } + $more = count($ids) > 5 ? ' 等' . count($ids) . '条' : ''; + return '批量' . (int)($ap['target_count'] ?? count($ids)) . '条:' . implode(';', $labels) . $more; + } + $aid = (int)($ap['target_id'] ?? 0); + if ($aid <= 0) { + return 'ID:0'; + } + if (!isset($archiveMap[$aid])) { + return 'ID:' . $aid . '(已删除/不存在)'; + } + $arc = $archiveMap[$aid]; + $name = (string)$arc['name']; + $inst = (string)$arc['loan_institution']; + $borrower = (string)$arc['borrower']; + return 'ID:' . $aid . ' [' . archiveKindLabel((string)$arc['archive_kind']) . '] ' . $name . ' / ' . $inst . ' / ' . $borrower; +} + +function nextSettlementDate(string $today, string $mode, int $day): ?string +{ + if ($mode === 'bullet') { + return null; + } + $day = max(1, min(28, $day)); + if ($mode === 'quarterly') { + $m = (int)date('n', strtotime($today)); + $qStartMonth = ((int)(($m - 1) / 3)) * 3 + 1; + $cand = date('Y', strtotime($today)) . '-' . str_pad((string)$qStartMonth, 2, '0', STR_PAD_LEFT) . '-' . str_pad((string)$day, 2, '0', STR_PAD_LEFT); + if ($cand <= $today) { + $cand = date('Y-m-d', strtotime('+3 month', strtotime($cand))); + } + return $cand; + } + $curYm = date('Y-m', strtotime($today)); + $cand = $curYm . '-' . str_pad((string)$day, 2, '0', STR_PAD_LEFT); + if ($cand <= $today) { + $cand = date('Y-m-d', strtotime('+1 month', strtotime($cand))); + } + return $cand; +} + +function ensureDirectoryPath(PDO $pdo, string $lv1, string $lv2, string $lv3): int +{ + $clean = function (string $v): string { + $v = str_replace("\xC2\xA0", ' ', $v); + $v = preg_replace('/\s+/u', ' ', trim($v)); + return (string)$v; + }; + $lv1 = $clean($lv1); + $lv2 = $clean($lv2); + $lv3 = $clean($lv3); + + $findOrCreate = function (?int $parentId, int $level, string $name) use ($pdo): int { + if ($parentId === null) { + $q = $pdo->prepare('SELECT id FROM directories WHERE name=:n AND level=:l AND parent_id IS NULL'); + $q->execute([':n' => $name, ':l' => $level]); + } else { + $q = $pdo->prepare('SELECT id FROM directories WHERE name=:n AND level=:l AND parent_id=:p'); + $q->execute([':n' => $name, ':l' => $level, ':p' => $parentId]); + } + $id = (int)$q->fetchColumn(); + if ($id > 0) { + return $id; + } + $ins = $pdo->prepare('INSERT INTO directories(name,parent_id,level,created_at) VALUES(:n,:p,:l,:c)'); + $ins->bindValue(':n', $name); + $ins->bindValue(':p', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT); + $ins->bindValue(':l', $level, PDO::PARAM_INT); + $ins->bindValue(':c', now()); + $ins->execute(); + return (int)$pdo->lastInsertId(); + }; + $id1 = $findOrCreate(null, 1, $lv1); + $id2 = $findOrCreate($id1, 2, $lv2); + return $findOrCreate($id2, 3, $lv3); +} + +function hardDeleteArchive(PDO $pdo, int $archiveId): void +{ + $stmt = $pdo->prepare('SELECT stored_name FROM files WHERE archive_id=:a'); + $stmt->execute([':a' => $archiveId]); + foreach ($stmt->fetchAll() as $f) { + $filePath = UPLOAD_DIR . '/' . $f['stored_name']; + if (is_file($filePath)) { + unlink($filePath); + } + } + $pdo->prepare('DELETE FROM archives WHERE id=:id')->execute([':id' => $archiveId]); +} + +function loanInstitutionStorePath(): string +{ + return __DIR__ . '/../data/loan_institutions.json'; +} + +function defaultLoanInstitutions(): array +{ + return [ + '河南农商银行登封支行', + '郑州银行登封支行', + '浦发银行郑州金水支行', + '中国银行登封支行', + '建设银行登封支行', + '邮储银行登封支行', + '河南农商银行郑州关虎屯支行', + '中信银行登封支行', + ]; +} + +function loadLoanInstitutions(): array +{ + $path = loanInstitutionStorePath(); + if (!is_file($path)) { + return defaultLoanInstitutions(); + } + $raw = file_get_contents($path); + if ($raw === false || $raw === '') { + return defaultLoanInstitutions(); + } + $arr = json_decode($raw, true); + if (!is_array($arr)) { + return defaultLoanInstitutions(); + } + $out = []; + foreach ($arr as $x) { + $v = trim((string)$x); + if ($v !== '') { + $out[] = $v; + } + } + return $out ?: defaultLoanInstitutions(); +} + +function saveLoanInstitutions(array $items): void +{ + $out = []; + foreach ($items as $x) { + $v = trim((string)$x); + if ($v !== '') { + $out[] = $v; + } + } + $out = array_values(array_unique($out)); + if (empty($out)) { + $out = defaultLoanInstitutions(); + } + $path = loanInstitutionStorePath(); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($path, json_encode($out, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); +} + +function autoArchiveName(string $borrower, ?float $creditLimit): string +{ + $b = trim($borrower); + if ($b === '') { + return ''; + } + $wan = $creditLimit === null ? '' : rtrim(rtrim(number_format($creditLimit / 10000, 2, '.', ''), '0'), '.'); + return $wan === '' ? $b : ($b . $wan . '万元'); +} + +/** 对外担保:担保人 +「对外担保」+ 授信/10000 +「万元」 */ +function autoExternalGuaranteeArchiveName(string $guarantor, ?float $creditLimit): string +{ + $g = trim($guarantor); + if ($g === '') { + return ''; + } + $wan = $creditLimit === null ? '' : rtrim(rtrim(number_format($creditLimit / 10000, 2, '.', ''), '0'), '.'); + return $wan === '' ? '' : ($g . '对外担保' . $wan . '万元'); +} + +function ensureAutoBankLoanDirectory(PDO $pdo, ?string $startDate, string $loanInstitution): int +{ + $year = $startDate ? date('Y', strtotime((string) $startDate)) : ''; + $bank = trim($loanInstitution); + if ($year === '' || $bank === '') { + return 0; + } + return ensureDirectoryPath($pdo, '银行贷款', $year, $bank); +} + +function ensureAutoExternalGuaranteeDirectory(PDO $pdo, ?string $startDate, string $guarantor): int +{ + $year = $startDate ? date('Y', strtotime((string) $startDate)) : ''; + $g = trim($guarantor); + if ($year === '' || $g === '') { + return 0; + } + return ensureDirectoryPath($pdo, '对外担保', $year, $g); +} + +function collectAncestorIds(array $dirs, ?int $selectedId): array +{ + if (!$selectedId) { + return []; + } + $parentMap = []; + foreach ($dirs as $d) { + $parentMap[(int)$d['id']] = $d['parent_id'] === null ? null : (int)$d['parent_id']; + } + $ids = [$selectedId]; + $cur = $selectedId; + while (isset($parentMap[$cur]) && $parentMap[$cur] !== null) { + $cur = (int)$parentMap[$cur]; + $ids[] = $cur; + } + return array_values(array_unique($ids)); +} + +/** 二级目录排序:从名称中提取年份(如 2025、2025年)升序;无年份则按名称排在后段。 */ +function directoryLevel2TimeSortKey(string $name): int +{ + $name = trim($name); + if ($name === '') { + return 9999; + } + if (preg_match('/^(\d{4})(?:\b|年)/u', $name, $m)) { + return (int) $m[1]; + } + if (preg_match('/(\d{4})/u', $name, $m)) { + return (int) $m[1]; + } + return 9999; +} + +function buildTreeHtml(array $dirs, ?int $selectedId, bool $admin, array $pathIds): string +{ + $byParent = []; + $dirMap = []; + foreach ($dirs as $d) { + $pid = $d['parent_id'] === null ? 0 : (int) $d['parent_id']; + $byParent[$pid][] = $d; + $dirMap[(int)$d['id']] = $d; + } + foreach ($byParent as &$children) { + if ($children === []) { + continue; + } + if ((int) ($children[0]['level'] ?? 0) === 2) { + usort($children, static function (array $a, array $b): int { + $ka = directoryLevel2TimeSortKey((string) $a['name']); + $kb = directoryLevel2TimeSortKey((string) $b['name']); + if ($ka !== $kb) { + return $ka <=> $kb; + } + return strcmp((string) $a['name'], (string) $b['name']); + }); + } + } + unset($children); + $pathSet = array_flip($pathIds); + $rootNameById = []; + foreach ($dirMap as $id => $d) { + $cur = $id; + $rootName = (string)$d['name']; + while (isset($dirMap[$cur]) && $dirMap[$cur]['parent_id'] !== null) { + $cur = (int)$dirMap[$cur]['parent_id']; + if (!isset($dirMap[$cur])) { + break; + } + $rootName = (string)$dirMap[$cur]['name']; + } + $rootNameById[$id] = $rootName; + } + $render = function ($parent) use (&$render, $byParent, $selectedId, $admin, $pathSet, $rootNameById): string { + if (empty($byParent[$parent])) { + return ''; + } + $html = '
      '; + foreach ($byParent[$parent] as $d) { + $id = (int)$d['id']; + $name = e($d['name']); + $hasChildren = !empty($byParent[$id]); + $isCurrent = $selectedId === $id; + $isAncestor = isset($pathSet[$id]); + $linkClass = 'tree-link' . ($isCurrent ? ' current' : ($isAncestor ? ' ancestor' : '')); + $actions = ''; + $kindQ = ''; + $rootName = (string)($rootNameById[$id] ?? ''); + if ($rootName === '银行贷款') { + $kindQ = '&kind=' . ARCHIVE_KIND_BANK_LOAN; + } elseif ($rootName === '对外担保') { + $kindQ = '&kind=' . ARCHIVE_KIND_EXTERNAL_GUARANTEE; + } + if ($admin) { + $actions .= ''; + $actions .= ''; + if ((int)$d['level'] < 3) { + $actions .= ''; + } + $actions .= '🗑'; + $actions .= ''; + } + $html .= '
    • '; + if ($hasChildren) { + $open = $isAncestor ? ' open' : ''; + $html .= '
      ' . $name . '' . $actions . '
      '; + } else { + $html .= '
      ' . $name . '' . $actions . '
      '; + } + $html .= $render($id); + if ($hasChildren) { + $html .= ''; + } + $html .= '
    • '; + } + $html .= '
    '; + return $html; + }; + return $render(0); +} + +/** 便捷查询侧栏 / 便捷查询页:折叠菜单内链接 */ +function quickQueryMenuHtml(): string +{ + $links = [ + ['query_payable_interest', '应付本息查询'], + ['query_external_guarantee', '对外担保查询'], + ['query_totals', '查询总金额'], + ['interest_calculator', '利息计算器'], + ['acceptance_discount_calculator', '承兑贴现计算器'], + ['repayment_history', '历史还款记录查询'], + ]; + $inner = ''; + foreach ($links as $pair) { + $inner .= '' . e($pair[1]) . ''; + } + return '
    ' + . '便捷查询点击展开' + . '' + . '
    '; +} + +if ($action === 'new_dir') { + requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim((string)($_POST['name'] ?? '')); + $parentId = (int)($_POST['parent_id'] ?? 0); + if ($name === '') { + flash('error', '目录名称不能为空'); + header('Location: index.php'); + exit; + } + $level = 1; + $parentValue = null; + if ($parentId > 0) { + $stmt = $pdo->prepare('SELECT * FROM directories WHERE id=:id'); + $stmt->execute([':id' => $parentId]); + $p = $stmt->fetch(); + if (!$p) { + flash('error', '父目录不存在'); + header('Location: index.php'); + exit; + } + $level = (int)$p['level'] + 1; + $parentValue = $parentId; + if ($level > 3) { + flash('error', '最多只支持三级目录'); + header('Location: index.php?dir_id=' . $parentId); + exit; + } + } + $stmt = $pdo->prepare('INSERT INTO directories(name,parent_id,level,created_at) VALUES(:n,:p,:l,:c)'); + $stmt->bindValue(':n', $name); + $stmt->bindValue(':p', $parentValue, $parentValue === null ? PDO::PARAM_NULL : PDO::PARAM_INT); + $stmt->bindValue(':l', $level, PDO::PARAM_INT); + $stmt->bindValue(':c', now()); + $stmt->execute(); + flash('ok', '目录已创建'); + header('Location: index.php' . ($parentId ? '?dir_id=' . $parentId : '')); + exit; + } + $parentId = (int)($_GET['parent_id'] ?? 0); + pageHeader($me); ?> +
    +

    新建目录

    +
    + + + + 返回 +
    +
    + prepare('SELECT * FROM directories WHERE id=:id'); + $stmt->execute([':id' => $id]); + $d = $stmt->fetch(); + if (!$d) { + flash('error', '目录不存在'); + header('Location: index.php'); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim((string)($_POST['name'] ?? '')); + if ($name === '') { + flash('error', '目录名称不能为空'); + } else { + $up = $pdo->prepare('UPDATE directories SET name=:n WHERE id=:id'); + $up->execute([':n' => $name, ':id' => $id]); + flash('ok', '目录已更新'); + header('Location: index.php?dir_id=' . $id); + exit; + } + } + pageHeader($me); ?> +

    编辑目录

    +
    + + + 返回 +
    +
    + prepare('SELECT COUNT(*) FROM directories WHERE parent_id=:id'); + $c1->execute([':id' => $id]); + $c2 = $pdo->prepare('SELECT COUNT(*) FROM archives WHERE directory_id=:id'); + $c2->execute([':id' => $id]); + if ((int)$c1->fetchColumn() > 0 || (int)$c2->fetchColumn() > 0) { + flash('error', '目录非空,不能删除'); + header('Location: index.php?dir_id=' . $id); + exit; + } + $del = $pdo->prepare('DELETE FROM directories WHERE id=:id'); + $del->execute([':id' => $id]); + flash('ok', '目录已删除'); + header('Location: index.php'); + exit; +} + +if ($action === 'save_archive') { + requireAdmin(); + $id = (int)($_POST['id'] ?? 0); + $directoryId = (int)($_POST['directory_id'] ?? 0); + $archiveKind = normalizeArchiveKind((string)($_POST['archive_kind'] ?? '')); + $loanInstitution = trim((string)($_POST['loan_institution'] ?? '')); + $borrower = trim((string)($_POST['borrower'] ?? '')); + $guarantorEarly = trim((string)($_POST['guarantor'] ?? '')); + $creditLimitVal = ($_POST['credit_limit'] ?? '') === '' ? null : (float)$_POST['credit_limit']; + $startDateVal = $_POST['start_date'] ?: null; + if ($archiveKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE) { + $name = autoExternalGuaranteeArchiveName($guarantorEarly, $creditLimitVal); + if ($name === '') { + flash('error', '对外担保需填写担保人、授信额度(用于自动生成档案名称)'); + header('Location: index.php'); + exit; + } + } else { + $name = autoArchiveName($borrower, $creditLimitVal); + if ($name === '') { + flash('error', '借款主体与授信额度必填(用于自动生成档案名称)'); + header('Location: index.php'); + exit; + } + } + if (isset($_POST['loan_institutions_json'])) { + $json = json_decode((string)$_POST['loan_institutions_json'], true); + if (is_array($json)) { + saveLoanInstitutions($json); + } + } + if ($directoryId > 0) { + $check = $pdo->prepare('SELECT level FROM directories WHERE id=:id'); + $check->execute([':id' => $directoryId]); + $lv = (int)$check->fetchColumn(); + if ($lv !== 3) { + flash('error', '手动选择目录时必须是三级目录'); + header('Location: index.php?dir_id=' . $directoryId); + exit; + } + } else { + if ($archiveKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE) { + $directoryId = ensureAutoExternalGuaranteeDirectory($pdo, $startDateVal, $guarantorEarly); + if ($directoryId <= 0) { + flash('error', '未选择三级目录时,对外担保需填写起始日与担保人,系统将自动创建目录:对外担保 / 年份 / 担保人'); + header('Location: index.php'); + exit; + } + } else { + $directoryId = ensureAutoBankLoanDirectory($pdo, $startDateVal, $loanInstitution); + if ($directoryId <= 0) { + flash('error', '未选择三级目录时,银行贷款需填写贷款机构与起始日,系统将自动创建目录:银行贷款 / 年份 / 贷款机构'); + header('Location: index.php'); + exit; + } + } + } + $planEnabled = ($_POST['plan_enabled'] ?? '0') === '1'; + $allPlans = loadRepaymentPlans(); + $existing = $id > 0 ? getArchiveRepaymentPlan($allPlans, $id) : ['enabled' => false, 'plans' => []]; + $existingMap = []; + foreach (($existing['plans'] ?? []) as $ep) { + if (!empty($ep['id'])) { + $existingMap[(string)$ep['id']] = $ep; + } + } + $dueDates = (array)($_POST['plan_due_date'] ?? []); + $amounts = (array)($_POST['plan_amount'] ?? []); + $planIds = (array)($_POST['plan_id'] ?? []); + $planStatuses = (array)($_POST['plan_status'] ?? []); + $delayDates = (array)($_POST['plan_delay_date'] ?? []); + $plans = []; + $paidSum = 0.0; + if ($planEnabled) { + $n = max(count($dueDates), count($amounts), count($planStatuses)); + for ($i = 0; $i < $n; $i++) { + $due = trim((string)($dueDates[$i] ?? '')); + $amtRaw = trim((string)($amounts[$i] ?? '')); + if ($due === '' || $amtRaw === '') { + continue; + } + $amt = (float)$amtRaw; + if ($amt <= 0) { + continue; + } + $pid = trim((string)($planIds[$i] ?? '')); + $status = (string)($planStatuses[$i] ?? 'pending'); + if (!in_array($status, ['pending', 'paid', 'delayed', 'overdue'], true)) { + $status = 'pending'; + } + $today = date('Y-m-d'); + if (($status === 'delayed' || $status === 'overdue') && $due > $today) { + $status = 'pending'; + } + $delayDate = trim((string)($delayDates[$i] ?? '')); + if ($status === 'delayed' && $delayDate !== '') { + $due = $delayDate; + } + $old = $existingMap[$pid] ?? null; + $paidAt = null; + if ($status === 'paid') { + $paidAt = ($old && ($old['status'] ?? '') === 'paid' && !empty($old['paid_at'])) ? $old['paid_at'] : date('Y-m-d'); + $paidSum += $amt; + } + $plans[] = [ + 'id' => $pid !== '' ? $pid : uniqid('rp_', true), + 'due_date' => $due, + 'amount' => $amt, + 'status' => $status, + 'paid_at' => $paidAt, + 'updated_at' => now(), + ]; + } + } + + $financeAllWork = loadFinanceConfigs(); + $rawPrevFinance = $id > 0 ? ($financeAllWork[(string)$id] ?? []) : []; + $prevManualPrincipal = (float)($rawPrevFinance['manual_principal_repaid'] ?? 0); + $prevManualInterest = (float)($rawPrevFinance['manual_interest_paid'] ?? 0); + $addPrincipal = max(0, (float)($_POST['principal_repayment_now'] ?? 0)); + $addInterest = max(0, (float)($_POST['interest_repayment_now'] ?? 0)); + if ($creditLimitVal !== null) { + $repayCap = max(0, $creditLimitVal - $paidSum - $prevManualPrincipal); + if ($addPrincipal > $repayCap + 1e-6) { + flash('error', '本次归还本金超过可还金额(授信 − 分期已还 − 已累计归还本金)'); + header('Location: index.php?action=archive_form' . ($id > 0 ? ('&id=' . $id) : '')); + exit; + } + } + + $oldEndDate = ''; + if ($id > 0) { + $stEnd = $pdo->prepare('SELECT end_date FROM archives WHERE id=:id'); + $stEnd->execute([':id' => $id]); + $oldEndRow = $stEnd->fetchColumn(); + if ($oldEndRow !== false && $oldEndRow !== null) { + $oldEndDate = (string)$oldEndRow; + } + } + $loanExtendTo = trim((string)($_POST['loan_extend_to'] ?? '')); + $endDatePosted = ($_POST['end_date'] ?? '') !== '' ? (string)$_POST['end_date'] : null; + $endDateFinal = $endDatePosted; + if ($loanExtendTo !== '') { + if ($startDateVal && $loanExtendTo < $startDateVal) { + flash('error', '延期后的到期日不能早于起始日'); + header('Location: index.php?action=archive_form' . ($id > 0 ? ('&id=' . $id) : '')); + exit; + } + if ($oldEndDate !== '' && $loanExtendTo < $oldEndDate) { + flash('error', '延期后的到期日不能早于当前到期日'); + header('Location: index.php?action=archive_form' . ($id > 0 ? ('&id=' . $id) : '')); + exit; + } + $endDateFinal = $loanExtendTo; + } + + $manualPrincipalTotal = $prevManualPrincipal + $addPrincipal; + $manualInterestTotal = $prevManualInterest + $addInterest; + $remainingAuto = $creditLimitVal === null ? null : max(0, $creditLimitVal - $paidSum - $manualPrincipalTotal); + + $payload = [ + ':name' => $name, + ':directory_id' => $directoryId, + ':loan_institution' => $loanInstitution, + ':borrower' => $borrower, + ':credit_limit' => $creditLimitVal, + ':remaining_limit' => $remainingAuto, + ':start_date' => $startDateVal, + ':end_date' => $endDateFinal, + ':annual_rate' => ($_POST['annual_rate'] ?? '') === '' ? null : (float)$_POST['annual_rate'], + ':guarantee_type' => trim((string)($_POST['guarantee_type'] ?? '')), + ':guarantor' => $guarantorEarly, + ':archive_kind' => $archiveKind, + ':updated_at' => now(), + ]; + if ($id > 0) { + $sql = 'UPDATE archives SET name=:name,directory_id=:directory_id,archive_kind=:archive_kind,loan_institution=:loan_institution,borrower=:borrower,credit_limit=:credit_limit,remaining_limit=:remaining_limit,start_date=:start_date,end_date=:end_date,annual_rate=:annual_rate,guarantee_type=:guarantee_type,guarantor=:guarantor,updated_at=:updated_at WHERE id=:id'; + $payload[':id'] = $id; + $pdo->prepare($sql)->execute($payload); + $archiveId = $id; + flash('ok', '档案已更新(' . date('H:i:s') . ')'); + } else { + $sql = 'INSERT INTO archives(name,directory_id,archive_kind,loan_institution,borrower,credit_limit,remaining_limit,start_date,end_date,annual_rate,guarantee_type,guarantor,created_at,updated_at) VALUES(:name,:directory_id,:archive_kind,:loan_institution,:borrower,:credit_limit,:remaining_limit,:start_date,:end_date,:annual_rate,:guarantee_type,:guarantor,:created_at,:updated_at)'; + $payload[':created_at'] = now(); + $pdo->prepare($sql)->execute($payload); + $archiveId = (int)$pdo->lastInsertId(); + flash('ok', '档案已创建(' . date('H:i:s') . ')'); + } + + if ($planEnabled) { + $allPlans[(string)$archiveId] = ['enabled' => true, 'plans' => $plans]; + } else { + $allPlans[(string)$archiveId] = ['enabled' => false, 'plans' => []]; + } + saveRepaymentPlans($allPlans); + + $financeAll = loadFinanceConfigs(); + $interestMode = (string)($_POST['interest_mode'] ?? 'monthly'); + if (!in_array($interestMode, ['monthly', 'quarterly', 'bullet'], true)) { + $interestMode = 'monthly'; + } + $settlementRule = $interestMode === 'quarterly' ? 'quarterly_day' : ($interestMode === 'bullet' ? 'maturity_once' : 'monthly_day'); + $settlementDay = $interestMode === 'bullet' ? 0 : max(1, min(28, (int)($_POST['settlement_day'] ?? 21))); + $todaySave = date('Y-m-d'); + $endDatePosted = $endDateFinal !== null && $endDateFinal !== '' ? (string)$endDateFinal : ''; + $postedEarlyRaw = $_POST['is_early_settled'] ?? null; + $prevFinance = getArchiveFinanceConfig($financeAll, $archiveId); + if ($endDatePosted !== '' && $endDatePosted < $todaySave) { + // 已过到期日:禁止在编辑页首次标「已完成」(须走「待更新」);已是已完成的保留,除非表单显式提交为否 + if (!empty($prevFinance['manual_early_settled'])) { + $manualEarlySettled = ($postedEarlyRaw === null) ? true : ($postedEarlyRaw === '1'); + } else { + $manualEarlySettled = false; + } + } else { + $manualEarlySettled = $endDatePosted !== '' && ($postedEarlyRaw === '1'); + } + $manualOverdue = (($_POST['is_overdue'] ?? '0') === '1'); + if ($manualEarlySettled) { + $manualOverdue = false; + } + $remarkRaw = trim((string)($_POST['archive_remark'] ?? '')); + if (function_exists('mb_substr')) { + $remarkRaw = mb_substr($remarkRaw, 0, 4000, 'UTF-8'); + } elseif (strlen($remarkRaw) > 4000) { + $remarkRaw = substr($remarkRaw, 0, 4000); + } + $financeMergeBase = $financeAll[(string)$archiveId] ?? []; + $financeAll[(string)$archiveId] = array_merge($financeMergeBase, [ + 'interest_mode' => $interestMode, + 'settlement_rule' => $settlementRule, + 'settlement_day' => $settlementDay, + 'overdue_float_pct' => max(0, (float)($_POST['overdue_float_pct'] ?? 50)), + 'manual_overdue' => $manualOverdue, + 'manual_early_settled' => $manualEarlySettled, + 'remark' => $remarkRaw, + 'manual_principal_repaid' => $manualPrincipalTotal, + 'manual_interest_paid' => $manualInterestTotal, + 'updated_at' => now(), + ]); + saveFinanceConfigs($financeAll); + uploadArchiveFiles($pdo, $archiveId, $_FILES['new_files'] ?? []); + header('Location: index.php?action=archive_form&id=' . $archiveId); + exit; +} + +if ($action === 'reminder_action') { + requireAdmin(); + $op = (string)($_POST['op'] ?? $_GET['op'] ?? ''); + $archiveId = (int)($_POST['archive_id'] ?? $_GET['archive_id'] ?? 0); + $planId = trim((string)($_POST['plan_id'] ?? $_GET['plan_id'] ?? '')); + $redirectType = (string)($_POST['redirect_type'] ?? $_GET['redirect_type'] ?? 'all'); + if (!in_array($redirectType, ['all', 'due', 'plan', 'overdue'], true)) { + $redirectType = 'all'; + } + $back = 'index.php?action=reminders&type=' . $redirectType; + + if ($archiveId <= 0) { + flash('error', '参数错误'); + redirectBackOr($back); + } + + if ($op === 'early_settle') { + $financeAll = loadFinanceConfigs(); + $cfg = getArchiveFinanceConfig($financeAll, $archiveId); + $cfg['manual_early_settled'] = true; + $cfg['manual_overdue'] = false; + $cfg['updated_at'] = now(); + $financeAll[(string)$archiveId] = $cfg; + saveFinanceConfigs($financeAll); + flash('ok', '已设为提前还款,状态改为已完成'); + redirectBackOr($back); + } + + if ($op === 'plan_delay') { + $newDue = trim((string)($_POST['new_due_date'] ?? $_GET['new_due_date'] ?? '')); + if ($planId === '' || $newDue === '') { + flash('error', '延期参数不完整'); + redirectBackOr($back); + } + $allPlans = loadRepaymentPlans(); + $cfg = getArchiveRepaymentPlan($allPlans, $archiveId); + if (empty($cfg['enabled']) || empty($cfg['plans']) || !is_array($cfg['plans'])) { + flash('error', '分期计划不存在'); + redirectBackOr($back); + } + $ok = false; + foreach ($cfg['plans'] as &$p) { + if ((string)($p['id'] ?? '') !== $planId) { + continue; + } + $p['due_date'] = $newDue; + $p['status'] = 'delayed'; + $p['paid_at'] = null; + $p['updated_at'] = now(); + $ok = true; + break; + } + unset($p); + if ($ok) { + $allPlans[(string)$archiveId] = $cfg; + saveRepaymentPlans($allPlans); + flash('ok', '还款计划已延期'); + } else { + flash('error', '未找到对应还款计划'); + } + redirectBackOr($back); + } + + if ($op === 'overdue_paid') { + $allPlans = loadRepaymentPlans(); + $cfg = getArchiveRepaymentPlan($allPlans, $archiveId); + $handled = false; + if ($planId !== '' && !empty($cfg['enabled']) && !empty($cfg['plans']) && is_array($cfg['plans'])) { + foreach ($cfg['plans'] as &$p) { + if ((string)($p['id'] ?? '') !== $planId) { + continue; + } + $p['status'] = 'paid'; + $p['paid_at'] = date('Y-m-d'); + $p['updated_at'] = now(); + $handled = true; + break; + } + unset($p); + if ($handled) { + $allPlans[(string)$archiveId] = $cfg; + saveRepaymentPlans($allPlans); + recalcRemainingLimitByPlans($pdo, $archiveId, (array)$cfg['plans']); + flash('ok', '逾期分期已标记为已归还'); + } + } else { + $financeAll = loadFinanceConfigs(); + $f = getArchiveFinanceConfig($financeAll, $archiveId); + $f['manual_early_settled'] = true; + $f['manual_overdue'] = false; + $f['updated_at'] = now(); + $financeAll[(string)$archiveId] = $f; + saveFinanceConfigs($financeAll); + flash('ok', '整笔逾期已归还,状态改为已完成'); + $handled = true; + } + if (!$handled) { + flash('error', '未找到可处理的逾期项'); + } + redirectBackOr($back); + } + + flash('error', '不支持的操作'); + redirectBackOr($back); +} + +if ($action === 'pending_update_action') { + requireAdmin(); + $op = (string)($_POST['op'] ?? ''); + $archiveId = (int)($_POST['archive_id'] ?? 0); + $planId = trim((string)($_POST['plan_id'] ?? '')); + $todayPu = date('Y-m-d'); + $dirIdPost = (int)($_POST['dir_id'] ?? 0); + $back = 'index.php?action=pending_updates' . ($dirIdPost > 0 ? ('&dir_id=' . $dirIdPost) : ''); + if ($archiveId <= 0) { + flash('error', '参数错误'); + redirectBackOr($back); + } + + if ($op === 'pending_loan_repaid' || $op === 'pending_loan_overdue') { + $st = $pdo->prepare('SELECT end_date FROM archives WHERE id=:id'); + $st->execute([':id' => $archiveId]); + $endRow = $st->fetchColumn(); + $endStr = $endRow !== false && $endRow !== null ? (string)$endRow : ''; + if ($endStr === '' || $endStr >= $todayPu) { + flash('error', '该贷款尚未到期待更新处理'); + redirectBackOr($back); + } + $financeAll = loadFinanceConfigs(); + $f = getArchiveFinanceConfig($financeAll, $archiveId); + if (archiveStatusWithConfig($endStr, $f) !== '待更新') { + flash('error', '该项已处理或状态已变更'); + redirectBackOr($back); + } + if ($op === 'pending_loan_repaid') { + $f['manual_early_settled'] = true; + $f['manual_overdue'] = false; + $f['updated_at'] = now(); + $financeAll[(string)$archiveId] = $f; + saveFinanceConfigs($financeAll); + flash('ok', '已标记为已还款,状态改为已完成'); + } else { + $f['manual_overdue'] = true; + $f['manual_early_settled'] = false; + $f['updated_at'] = now(); + $financeAll[(string)$archiveId] = $f; + saveFinanceConfigs($financeAll); + flash('ok', '已标记为已逾期,状态改为逾期中'); + } + redirectBackOr($back); + } + + if ($op === 'pending_plan_repaid' || $op === 'pending_plan_overdue') { + if ($planId === '') { + flash('error', '参数错误'); + redirectBackOr($back); + } + $allPlans = loadRepaymentPlans(); + $cfg = getArchiveRepaymentPlan($allPlans, $archiveId); + $handled = false; + if (!empty($cfg['enabled']) && !empty($cfg['plans']) && is_array($cfg['plans'])) { + foreach ($cfg['plans'] as &$p) { + if ((string)($p['id'] ?? '') !== $planId) { + continue; + } + $pst = (string)($p['status'] ?? 'pending'); + $d = (string)($p['due_date'] ?? ''); + if ($d === '' || $d >= $todayPu || !in_array($pst, ['pending', 'delayed'], true)) { + break; + } + if ($op === 'pending_plan_repaid') { + $p['status'] = 'paid'; + $p['paid_at'] = date('Y-m-d'); + } else { + $p['status'] = 'overdue'; + $p['paid_at'] = null; + } + $p['updated_at'] = now(); + $handled = true; + break; + } + unset($p); + } + if ($handled) { + $allPlans[(string)$archiveId] = $cfg; + saveRepaymentPlans($allPlans); + if ($op === 'pending_plan_repaid') { + recalcRemainingLimitByPlans($pdo, $archiveId, (array)$cfg['plans']); + } + flash('ok', $op === 'pending_plan_repaid' ? '已标记为已还款(按期还款)' : '已标记为已逾期(逾期中)'); + } else { + flash('error', '未找到可处理的还款计划或状态已变更'); + } + redirectBackOr($back); + } + + flash('error', '不支持的操作'); + redirectBackOr($back); +} + +if ($action === 'download_import_template') { + requireAdmin(); + $manualTemplatePath = __DIR__ . '/../data/import_template.xlsx'; + if (is_file($manualTemplatePath)) { + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode('档案导入模板.xlsx') . '"'); + readfile($manualTemplatePath); + exit; + } + $headers = ['一级目录', '二级目录', '三级目录', '档案名称', '贷款机构', '借款主体', '授信额度', '起止日', '到期日', '年利率', '担保类型', '担保人', '计息方式', '结息日', '是否有分期还款计划']; + $sample = ['银行贷款', '2026', 'XX银行', 'A公司流动资金贷款合同', 'XX银行', 'A公司', '5000000', '2026-01-15', '2027-01-14', '5.5', '抵押', 'B公司', '按月计息', '21', '否']; + if (class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet') && class_exists('\PhpOffice\PhpSpreadsheet\Writer\Xlsx')) { + $filename = '档案导入模板.xlsx'; + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setCellValue( + 'A1', + '说明:第1行可写模板使用提示(导入时忽略);第2行为列标题勿改;从第3行起填数据。' + . '一级目录填「银行贷款」或「对外担保」;系统据此识别档案类型。对外担保导入时「贷款机构」「年利率」可留空;「档案名称」可留空,将按担保人+对外担保+授信万元自动生成。' + . '银行贷款必填至担保人(含档案名称列);选填:计息方式、结息日、是否有分期还款计划。' + ); + $sheet->fromArray($headers, null, 'A2'); + $sheet->fromArray([$sample], null, 'A3'); + foreach (range('A', 'O') as $col) { + $sheet->getColumnDimension($col)->setWidth(20); + } + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $writer->save('php://output'); + exit; + } + flash('error', '未找到可用xlsx模板。请安装PhpSpreadsheet或将模板放到 data/import_template.xlsx'); + header('Location: index.php'); + exit; +} + +if ($action === 'import_archives') { + requireAdmin(); + if (empty($_FILES['import_file']) || (int)$_FILES['import_file']['error'] !== UPLOAD_ERR_OK) { + flash('error', '请选择有效导入文件'); + header('Location: index.php'); + exit; + } + $name = (string)$_FILES['import_file']['name']; + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $rows = []; + if ($ext === 'csv') { + $fp = fopen((string)$_FILES['import_file']['tmp_name'], 'rb'); + if ($fp) { + fgetcsv($fp); + $header = fgetcsv($fp); + while (($line = fgetcsv($fp)) !== false) { + $rows[] = $line; + } + fclose($fp); + } + } elseif (in_array($ext, ['xlsx', 'xls'], true) && class_exists('\PhpOffice\PhpSpreadsheet\IOFactory')) { + $sheet = \PhpOffice\PhpSpreadsheet\IOFactory::load((string)$_FILES['import_file']['tmp_name'])->getActiveSheet(); + $arr = $sheet->toArray(); + array_shift($arr); + $header = array_shift($arr); + $rows = $arr; + } else { + flash('error', '仅支持CSV,或安装PhpSpreadsheet后支持Excel'); + header('Location: index.php'); + exit; + } + $created = 0; + $skipped = 0; + $financeAll = loadFinanceConfigs(); + $planAll = loadRepaymentPlans(); + $seen = []; + foreach ($rows as $r) { + $lv1 = (string)($r[0] ?? ''); + $lv2 = (string)($r[1] ?? ''); + $lv3 = (string)($r[2] ?? ''); + $archiveName = trim((string)($r[3] ?? '')); + $loanInstitution = trim((string)($r[4] ?? '')); + $borrower = trim((string)($r[5] ?? '')); + $creditRaw = trim((string)($r[6] ?? '')); + $startDate = trim((string)($r[7] ?? '')); + $endDate = trim((string)($r[8] ?? '')); + $annualRateRaw = trim((string)($r[9] ?? '')); + $guaranteeType = trim((string)($r[10] ?? '')); + $guarantor = trim((string)($r[11] ?? '')); + $importKind = trim($lv1) === '对外担保' ? ARCHIVE_KIND_EXTERNAL_GUARANTEE : ARCHIVE_KIND_BANK_LOAN; + if ($importKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE) { + if ($lv1 === '' || $lv2 === '' || $lv3 === '' || $borrower === '' || $creditRaw === '' || $startDate === '' || $endDate === '' || $guaranteeType === '' || $guarantor === '') { + continue; + } + $annualRate = $annualRateRaw === '' ? null : (float) $annualRateRaw; + } else { + if ($lv1 === '' || $lv2 === '' || $lv3 === '' || $archiveName === '' || $loanInstitution === '' || $borrower === '' || $creditRaw === '' || $startDate === '' || $endDate === '' || $annualRateRaw === '' || $guaranteeType === '' || $guarantor === '') { + continue; + } + $annualRate = (float) $annualRateRaw; + } + // 无论目录是否预先存在,都会自动按三级路径创建并返回三级目录ID。 + $dirId = ensureDirectoryPath($pdo, $lv1, $lv2, $lv3); + $credit = (float) $creditRaw; + $remain = $credit; + if ($importKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE) { + $archiveName = autoExternalGuaranteeArchiveName($guarantor, $credit); + if ($archiveName === '') { + continue; + } + } + + $fingerprint = implode('|', [ + $importKind, + (string) $dirId, + mb_strtolower($archiveName), + mb_strtolower($loanInstitution), + mb_strtolower($borrower), + (string) $credit, + $startDate, + $endDate, + (string) ($annualRate ?? ''), + mb_strtolower($guaranteeType), + mb_strtolower($guarantor), + ]); + if (isset($seen[$fingerprint])) { + $skipped++; + continue; + } + $seen[$fingerprint] = true; + + $dupStmt = $pdo->prepare('SELECT COUNT(*) FROM archives WHERE directory_id=:d AND archive_kind=:ak AND name=:n AND COALESCE(loan_institution,\'\')=:li AND borrower=:b AND credit_limit=:cl AND start_date=:sd AND end_date=:ed AND COALESCE(annual_rate,-999999.99)=COALESCE(:ar,-999999.99) AND guarantee_type=:gt AND guarantor=:g'); + $dupStmt->execute([ + ':d' => $dirId, + ':ak' => $importKind, + ':n' => $archiveName, + ':li' => $loanInstitution, + ':b' => $borrower, + ':cl' => $credit, + ':sd' => $startDate, + ':ed' => $endDate, + ':ar' => $annualRate, + ':gt' => $guaranteeType, + ':g' => $guarantor, + ]); + if ((int)$dupStmt->fetchColumn() > 0) { + $skipped++; + continue; + } + + $pdo->prepare('INSERT INTO archives(name,directory_id,archive_kind,loan_institution,borrower,credit_limit,remaining_limit,start_date,end_date,annual_rate,guarantee_type,guarantor,created_at,updated_at) VALUES(:name,:directory_id,:archive_kind,:loan_institution,:borrower,:credit_limit,:remaining_limit,:start_date,:end_date,:annual_rate,:guarantee_type,:guarantor,:created_at,:updated_at)') + ->execute([ + ':name' => $archiveName, + ':directory_id' => $dirId, + ':archive_kind' => $importKind, + ':loan_institution' => $loanInstitution, + ':borrower' => $borrower, + ':credit_limit' => $credit, + ':remaining_limit' => $remain, + ':start_date' => $startDate ?: null, + ':end_date' => $endDate ?: null, + ':annual_rate' => $annualRate, + ':guarantee_type' => $guaranteeType, + ':guarantor' => $guarantor, + ':created_at' => now(), + ':updated_at' => now(), + ]); + $archiveId = (int)$pdo->lastInsertId(); + $modeText = trim((string)($r[12] ?? '')); + $mode = $modeText === '按季付息' ? 'quarterly' : ($modeText === '到期一次性付清' ? 'bullet' : 'monthly'); + $settlementRule = $mode === 'quarterly' ? 'quarterly_day' : ($mode === 'bullet' ? 'maturity_once' : 'monthly_day'); + $settlementDay = $mode === 'bullet' ? 0 : max(1, min(28, (int)trim((string)($r[13] ?? '21')))); + $financeAll[(string)$archiveId] = [ + 'interest_mode' => $mode, + 'settlement_rule' => $settlementRule, + 'settlement_day' => $settlementDay, + 'overdue_float_pct' => 50, + 'manual_overdue' => false, + 'manual_early_settled' => false, + 'remark' => '', + 'updated_at' => now(), + ]; + $planEnabledText = trim((string)($r[14] ?? '否')); + $planAll[(string)$archiveId] = ['enabled' => in_array($planEnabledText, ['是', 'yes', 'YES', '1'], true), 'plans' => []]; + $created++; + } + saveFinanceConfigs($financeAll); + saveRepaymentPlans($planAll); + flash('ok', '导入完成:新增 ' . $created . ' 条,重复忽略 ' . $skipped . ' 条'); + header('Location: index.php'); + exit; +} + +if ($action === 'request_delete_archive') { + requireAdmin(); + $archiveId = (int)($_GET['id'] ?? 0); + if ($archiveId <= 0) { + flash('error', '参数错误'); + header('Location: index.php'); + exit; + } + $approvals = loadDeleteApprovals(); + foreach ($approvals as $ap) { + if (($ap['target_type'] ?? '') === 'archive' && (int)($ap['target_id'] ?? 0) === $archiveId && ($ap['status'] ?? '') === 'pending') { + flash('error', '该档案已有待审核删除申请'); + header('Location: index.php'); + exit; + } + } + $approvals[] = [ + 'id' => uniqid('del_', true), + 'target_type' => 'archive', + 'target_id' => $archiveId, + 'status' => 'pending', + 'requested_by' => (int)$me['id'], + 'requested_name' => (string)$me['username'], + 'requested_at' => now(), + 'approved_by' => null, + 'approved_at' => null, + ]; + saveDeleteApprovals($approvals); + flash('ok', '已提交删除申请,需另一位管理员审核'); + header('Location: index.php'); + exit; +} + +if ($action === 'request_delete_selected') { + requireAdmin(); + $idsRaw = $_GET['ids'] ?? ''; + if (is_array($idsRaw)) { + $ids = array_values(array_unique(array_filter(array_map('intval', $idsRaw)))); + } else { + $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)$idsRaw))))); + } + if (empty($ids)) { + flash('error', '请先选择要删除的档案'); + header('Location: index.php'); + exit; + } + $approvals = loadDeleteApprovals(); + $pendingSet = []; + foreach ($approvals as $ap) { + if (($ap['status'] ?? '') !== 'pending') { + continue; + } + if (($ap['target_type'] ?? '') === 'archive' && !empty($ap['target_id'])) { + $pendingSet[] = (string)$ap['target_id']; + } + if (($ap['target_type'] ?? '') === 'archive_batch' && !empty($ap['target_ids']) && is_array($ap['target_ids'])) { + foreach ($ap['target_ids'] as $x) { + $pendingSet[] = (string)(int)$x; + } + } + } + foreach ($ids as $id) { + if (in_array((string)$id, $pendingSet, true)) { + flash('error', '所选档案中存在待审核删除项,请先完成审核'); + header('Location: index.php'); + exit; + } + } + $approvals[] = [ + 'id' => uniqid('del_', true), + 'target_type' => 'archive_batch', + 'target_ids' => $ids, + 'target_count' => count($ids), + 'status' => 'pending', + 'requested_by' => (int)$me['id'], + 'requested_name' => (string)$me['username'], + 'requested_at' => now(), + 'approved_by' => null, + 'approved_at' => null, + ]; + saveDeleteApprovals($approvals); + flash('ok', '已提交选中档案删除申请(' . count($ids) . ' 条),需另一位管理员审核'); + header('Location: index.php'); + exit; +} + +if ($action === 'delete_approvals') { + requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $approvalId = trim((string)($_POST['approval_id'] ?? '')); + $do = (string)($_POST['do'] ?? ''); + $approvals = loadDeleteApprovals(); + foreach ($approvals as &$ap) { + if (($ap['id'] ?? '') !== $approvalId || ($ap['status'] ?? '') !== 'pending') { + continue; + } + if ((int)$ap['requested_by'] === (int)$me['id']) { + flash('error', '不能由申请人本人审核'); + break; + } + if ($do === 'approve' && ($ap['target_type'] ?? '') === 'archive') { + hardDeleteArchive($pdo, (int)$ap['target_id']); + $ap['status'] = 'approved'; + $ap['approved_by'] = (int)$me['id']; + $ap['approved_at'] = now(); + flash('ok', '删除已由第二管理员审核并执行'); + break; + } + if ($do === 'approve' && ($ap['target_type'] ?? '') === 'archive_batch') { + $ids = array_values(array_unique(array_filter(array_map('intval', (array)($ap['target_ids'] ?? []))))); + foreach ($ids as $aid) { + hardDeleteArchive($pdo, $aid); + } + $ap['status'] = 'approved'; + $ap['approved_by'] = (int)$me['id']; + $ap['approved_at'] = now(); + flash('ok', '批量删除已审核并执行(' . count($ids) . ' 条)'); + break; + } + if ($do === 'reject') { + $ap['status'] = 'rejected'; + $ap['approved_by'] = (int)$me['id']; + $ap['approved_at'] = now(); + flash('ok', '已驳回删除申请'); + break; + } + } + unset($ap); + saveDeleteApprovals($approvals); + redirectBackOr('index.php?action=delete_approvals'); + } + $approvals = array_reverse(loadDeleteApprovals()); + $filterStatus = (string)($_GET['status'] ?? 'pending'); + $filterTarget = trim((string)($_GET['target_id'] ?? '')); + $filterRequester = trim((string)($_GET['requester'] ?? '')); + $approvals = array_values(array_filter($approvals, function ($ap) use ($filterStatus, $filterTarget, $filterRequester) { + if ($filterStatus !== 'all' && ($ap['status'] ?? '') !== $filterStatus) { + return false; + } + if ($filterTarget !== '' && (string)($ap['target_id'] ?? '') !== $filterTarget) { + return false; + } + if ($filterRequester !== '' && mb_stripos((string)($ap['requested_name'] ?? ''), $filterRequester) === false) { + return false; + } + return true; + })); + $usersMap = []; + $archiveMap = fetchArchiveSimpleMap($pdo); + foreach ($pdo->query('SELECT id,username FROM users')->fetchAll() as $u) { + $usersMap[(int)$u['id']] = (string)$u['username']; + } + pageHeader($me); ?> +
    +

    删除审核(双管理员)

    +
    + + + + + + 清空 +
    + + + + + + + + + + + + + + + + + +
    申请时间类型目标申请人状态审核人审核时间操作
    -- + +
    + + + + +
    +
    + + + + +
    + + - + +
    暂无符合条件的审核记录
    +
    + prepare('SELECT * FROM files WHERE id=:id'); + $stmt->execute([':id' => $id]); + $f = $stmt->fetch(); + if ($f) { + $p = UPLOAD_DIR . '/' . $f['stored_name']; + if (is_file($p)) { + unlink($p); + } + $pdo->prepare('DELETE FROM files WHERE id=:id')->execute([':id' => $id]); + flash('ok', '文件已删除'); + header('Location: index.php?action=archive_form&id=' . (int)$f['archive_id']); + exit; + } + flash('error', '文件不存在'); + header('Location: index.php'); + exit; +} + +if ($action === 'rename_file') { + requireAdmin(); + $fileId = (int)($_POST['file_id'] ?? 0); + $newNameRaw = trim((string)($_POST['new_name'] ?? '')); + if ($fileId <= 0 || $newNameRaw === '') { + flash('error', '重命名参数错误'); + header('Location: index.php'); + exit; + } + $stmt = $pdo->prepare('SELECT * FROM files WHERE id=:id'); + $stmt->execute([':id' => $fileId]); + $f = $stmt->fetch(); + if (!$f) { + flash('error', '文件不存在'); + header('Location: index.php'); + exit; + } + + $currentStored = (string)$f['stored_name']; + $dirPart = dirname($currentStored); + $dirPart = $dirPart === '.' ? '' : $dirPart; + $prefix = pathinfo($currentStored, PATHINFO_FILENAME); + $dotPos = strpos($prefix, '_'); + $uniqPrefix = $dotPos === false ? uniqid('f_', true) : substr($prefix, 0, $dotPos); + $ext = strtolower((string)$f['file_ext']); + + $newOriginal = $newNameRaw; + if (strtolower((string)pathinfo($newOriginal, PATHINFO_EXTENSION)) !== $ext) { + $newOriginal .= '.' . $ext; + } + $normalized = normalizeFileName($newOriginal); + $newBase = $uniqPrefix . '_' . $normalized; + $newStored = ($dirPart !== '' ? $dirPart . '/' : '') . $newBase; + + $oldPath = UPLOAD_DIR . '/' . $currentStored; + $newPath = UPLOAD_DIR . '/' . $newStored; + if (!is_file($oldPath)) { + flash('error', '原文件不存在'); + header('Location: index.php?action=archive_form&id=' . (int)$f['archive_id']); + exit; + } + if (is_file($newPath)) { + $newStored = ($dirPart !== '' ? $dirPart . '/' : '') . uniqid('f_', true) . '_' . $normalized; + $newPath = UPLOAD_DIR . '/' . $newStored; + } + if (!rename($oldPath, $newPath)) { + flash('error', '文件重命名失败'); + header('Location: index.php?action=archive_form&id=' . (int)$f['archive_id']); + exit; + } + + $pdo->prepare('UPDATE files SET original_name=:o, stored_name=:s WHERE id=:id') + ->execute([':o' => $newOriginal, ':s' => $newStored, ':id' => $fileId]); + flash('ok', '文件已重命名'); + header('Location: index.php?action=archive_form&id=' . (int)$f['archive_id']); + exit; +} + +if ($action === 'download_file') { + requireLogin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM files WHERE id=:id'); + $stmt->execute([':id' => $id]); + $f = $stmt->fetch(); + if (!$f) { + http_response_code(404); + exit('Not found'); + } + $p = UPLOAD_DIR . '/' . $f['stored_name']; + if (!is_file($p)) { + http_response_code(404); + exit('File missing'); + } + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . rawurlencode($f['original_name']) . '"'); + readfile($p); + exit; +} + +if ($action === 'preview_file') { + requireLogin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM files WHERE id=:id'); + $stmt->execute([':id' => $id]); + $f = $stmt->fetch(); + if (!$f || !canPreview($f['file_ext'])) { + http_response_code(403); + exit('不支持预览'); + } + $p = UPLOAD_DIR . '/' . $f['stored_name']; + if (!is_file($p)) { + http_response_code(404); + exit('文件不存在'); + } + if ($f['file_ext'] === 'pdf') { + header('Content-Type: application/pdf'); + } else { + header('Content-Type: image/' . ($f['file_ext'] === 'jpg' ? 'jpeg' : $f['file_ext'])); + } + readfile($p); + exit; +} + +if ($action === 'archive_detail') { + requireLogin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT a.*, d.name AS dir_name FROM archives a JOIN directories d ON a.directory_id=d.id WHERE a.id=:id'); + $stmt->execute([':id' => $id]); + $a = $stmt->fetch(); + if (!$a) { + flash('error', '档案不存在'); + header('Location: index.php'); + exit; + } + $planAll = loadRepaymentPlans(); + $planCfg = getArchiveRepaymentPlan($planAll, $id); + $repaymentPlans = $planCfg['plans'] ?? []; + $financeAll = loadFinanceConfigs(); + $financeCfg = getArchiveFinanceConfig($financeAll, $id); + $status = archiveStatusWithConfig($a['end_date'], $financeCfg); + $detailKind = normalizeArchiveKind($a['archive_kind'] ?? ''); + $interestPayable = calcCurrentInterest($a, $financeCfg, $repaymentPlans); + $settlementInterestPayable = $detailKind === ARCHIVE_KIND_BANK_LOAN + ? calcSettlementPeriodInterest($a, $financeCfg, $repaymentPlans, date('Y-m-d')) + : 0.0; + usort($repaymentPlans, function ($x, $y) { + return strcmp((string)($x['due_date'] ?? ''), (string)($y['due_date'] ?? '')); + }); + $files = $pdo->prepare('SELECT * FROM files WHERE archive_id=:a ORDER BY id DESC'); + $files->execute([':a' => $id]); + pageHeader($me); ?> +
    +

    ()

    +

    状态: + 类型:

    + + + + + + + + + + + + + + + + + + + + +
    借款主体
    授信额度剩余额度
    起止日到期日
    年利率—' : e($a['annual_rate'] === null ? '' : ((string)$a['annual_rate'] . '%')) ?>担保类型
    担保人
    结息方式结息日 + +
    逾期利率上浮%当前应付利息
    结息日应付利息(本结息周期整段)
    备注
    +
    +
    +

    分期还款计划

    + +

    该档案未启用分期还款计划

    + + + + + + + + + + +
    还款日期还款金额状态
    + 已还款'; + } elseif ($st === 'delayed') { + echo '已延期'; + } elseif ($st === 'overdue') { + echo '已逾期'; + } else { + echo '待处理'; + } + ?> +
    +

    还款计划状态和延期调整请在“编辑档案”页面操作。

    + +
    +
    +

    文件列表

    + + + + + + + + + +
    文件名大小操作
    KB + +
    +
    + 0) { + $st = $pdo->prepare('SELECT * FROM archives WHERE id=:id'); + $st->execute([':id' => $id]); + $editing = $st->fetch(); + if (!$editing) { + flash('error', '档案不存在'); + header('Location: index.php'); + exit; + } + } + $dirs = fetchDirectories($pdo); + $loanInstitutions = loadLoanInstitutions(); + $dirId = (int)($_GET['dir_id'] ?? ($editing['directory_id'] ?? 0)); + $dirPath = dirPathMap($dirs); + $planAll = loadRepaymentPlans(); + $planCfg = $editing ? getArchiveRepaymentPlan($planAll, (int)$editing['id']) : ['enabled' => false, 'plans' => []]; + $financeAll = loadFinanceConfigs(); + $financeCfg = $editing ? getArchiveFinanceConfig($financeAll, (int)$editing['id']) : [ + 'interest_mode' => '', + 'settlement_rule' => 'monthly_day', + 'settlement_day' => 21, + 'overdue_float_pct' => 50, + 'manual_overdue' => false, + 'manual_early_settled' => false, + 'remark' => '', + 'manual_principal_repaid' => 0.0, + 'manual_interest_paid' => 0.0, + ]; + $planRows = $planCfg['plans'] ?? []; + if (empty($planRows)) { + for ($i = 0; $i < 6; $i++) { + $planRows[] = ['id' => '', 'due_date' => '', 'amount' => '']; + } + } + $editArchiveId = (int)($editing['id'] ?? 0); + $editFiles = []; + if ($editArchiveId > 0) { + $fs = $pdo->prepare('SELECT * FROM files WHERE archive_id=:a ORDER BY id DESC'); + $fs->execute([':a' => $editArchiveId]); + $editFiles = $fs->fetchAll(); + } + pageHeader($me); ?> +
    +

    + +
    + +
    +
    + + +
    +
    + +

    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + + +

    + + 添加机构 + | + - 删除当前机构 +

    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + 起始日 + +
    +
    + 到期日 + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + date('Y-m-d'); + ?> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + + + + + + + + + + +
    还款日期还款金额状态操作延期日期行操作
    + + + + + + >
    +

    +

    说明:到期日前可选“提前还款”;到期日后可选“按期还款/延期/逾期”。逾期未归还计划本金按上浮利率计息,剩余额度按已还款计划与手动归还本金自动计算。

    +
    + +
    +

    贷款延期与归还

    +
    +
    + + +

    填写后保存为新的到期日,须不早于当前到期日与起始日。

    +
    + +
    + +

    /

    +
    + +
    + +

    可仅使用延期;本金利息归还适用于银行贷款。

    +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    +

    归还本金从剩余额度中扣减;归还利息计入「已归还利息」,减少当前应付与结息日应付利息。

    + +
    + + +
    + + +

    点击“创建”后会自动上传并在编辑页文件管理列表中显示。

    +
    + +
    + +
    +
    +
    + 0): ?> +
    +

    文件管理(编辑页)

    +
    + + + +
    + + + + + + + + + + + + + +
    文件名大小重命名操作
    KB +
    + + + +
    +
    + +
    暂无文件
    +
    + + + 20) { + flash('error', '用户名或密码格式不正确'); + } else { + try { + $pdo->prepare('INSERT INTO users(username,password_hash,role,created_at) VALUES(:u,:p,:r,:c)') + ->execute([':u' => $u, ':p' => password_hash($p, PASSWORD_DEFAULT), ':r' => 'user', ':c' => now()]); + flash('ok', '用户已创建'); + } catch (Throwable $e) { + flash('error', '用户名已存在'); + } + } + } elseif ($do === 'role') { + $uid = (int)$_POST['uid']; + $role = $_POST['role'] === 'admin' ? 'admin' : 'user'; + $pdo->prepare('UPDATE users SET role=:r WHERE id=:id')->execute([':r' => $role, ':id' => $uid]); + flash('ok', '角色已更新'); + } elseif ($do === 'reset') { + $uid = (int)$_POST['uid']; + $np = (string)$_POST['new_password']; + if (strlen($np) < 6 || strlen($np) > 20) { + flash('error', '密码长度需为6-20'); + } else { + $pdo->prepare('UPDATE users SET password_hash=:p WHERE id=:id')->execute([':p' => password_hash($np, PASSWORD_DEFAULT), ':id' => $uid]); + flash('ok', '密码已重置'); + } + } elseif ($do === 'delete') { + $uid = (int)$_POST['uid']; + if ($uid === (int)$me['id']) { + flash('error', '不能删除当前登录账号'); + } else { + $pdo->prepare('DELETE FROM users WHERE id=:id')->execute([':id' => $uid]); + flash('ok', '用户已删除'); + } + } + header('Location: index.php?action=users'); + exit; + } + $users = $pdo->query('SELECT id,username,role,created_at FROM users ORDER BY id ASC')->fetchAll(); + pageHeader($me); ?> +
    +

    用户管理

    +
    + + + + +
    + + + + + + + + + + +
    ID用户名角色创建时间操作
    +
    + + + +
    +
    +
    + + + + +
    +
    + + + +
    +
    +
    + prepare('SELECT * FROM users WHERE id=:id'); + $stmt->execute([':id' => (int)$me['id']]); + $u = $stmt->fetch(); + if (!$u || !password_verify($cur, $u['password_hash'])) { + flash('error', '当前密码错误'); + } elseif (strlen($new) < 6 || strlen($new) > 20) { + flash('error', '新密码长度需为6-20位'); + } elseif ($new !== $confirm) { + flash('error', '新密码与确认密码不一致'); + } else { + $pdo->prepare('UPDATE users SET password_hash=:p WHERE id=:id')->execute([':p' => password_hash($new, PASSWORD_DEFAULT), ':id' => (int)$me['id']]); + flash('ok', '密码修改成功'); + } + header('Location: index.php?action=profile'); + exit; + } + pageHeader($me); ?> +
    +

    个人设置 - 修改密码

    +
    +

    +

    +

    + + 返回 +
    +
    + +
    +

    便捷查询

    + +

    应付本息:统计履行中及逾期中银行贷款按机构当前应付利息、本结息周期整段结息日应付利息,以及当月还款计划金额。

    +

    对外担保:按主体名称检索“该主体作为担保人”的履行中及逾期中银行贷款与对外担保档案。

    +

    查询总金额:按选项汇总履行中及逾期中档案的授信总额与剩余额度合计。

    +

    利息计算器 / 承兑贴现计算器:本地浏览器计算,结果仅供参考。

    +

    历史还款记录:汇总分期计划中「已还款」本金及状态为「已完成」档案的授信本金规模。

    +

    返回首页

    +
    + query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); + $planAll = loadRepaymentPlans(); + $financeAll = loadFinanceConfigs(); + $hr = qqBuildRepaymentHistory($rowsAll, $planAll, $financeAll); + $planRows = $hr['plan_rows']; + $cb = $hr['completed_bank']; + $cg = $hr['completed_guar']; + pageHeader($me); ?> +
    +

    历史还款记录查询

    + +
    +

    汇总

    + + + + + + + + + + + + + + + + + +
    项目笔数金额合计(元)
    分期还款计划中「已还款」本金
    已完成 — 银行贷款(授信本金规模)
    已完成 — 对外担保(授信本金规模)
    +

    「已完成」指合同状态为已完成;授信本金规模为档案授信额度合计,用于对应到期/结清贷款本金规模参考。分期已还与整笔结清可能同时存在,请勿简单相加避免重复理解。

    +
    +

    分期计划已还款明细

    +
    + + + + + + + + + + + + + + + +
    档案ID档案名称机构借款主体计划到期日标记还款日还款金额操作
    0): ?>查看
    暂无已标记还款的分期计划
    +
    +
    + query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); + $financeAll = loadFinanceConfigs(); + $tot = qqBuildTotalsQuery($rowsAll, $financeAll, $scope, $inst !== '' ? $inst : null); + $loanInstitutions = loadLoanInstitutions(); + pageHeader($me); ?> +
    +

    查询总金额

    + +
    + +
    + + +
    +
    + + + + +
    + +
    + +
    +
    +

    汇总结果

    + + + + + + + +
    笔数(履行+逾期)授信总额合计剩余额度合计
    +

    统计范围含状态为「履行中」与「逾期中」的档案。授信总额、剩余额度均来自各档案字段;剩余额度含分期已还与手动归还本金后的结果。

    +
    + query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); + $financeAll = loadFinanceConfigs(); + $planAll = loadRepaymentPlans(); + $quickStats = qqBuildPayableInterestData($rowsAll, $financeAll, $planAll); + $instRows = $quickStats['instRows']; + $planRows = $quickStats['planRows']; + $planTotal = (float)($quickStats['planTotal'] ?? 0); + + if ($exportFormat === 'xlsx' && class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet') && class_exists('\PhpOffice\PhpSpreadsheet\Writer\Xlsx')) { + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('机构利息汇总'); + $sheet1->fromArray(['机构', '笔数(履行+逾期)', '当前应付利息', '结息日', '结息日应付利息'], null, 'A1'); + $sheet1->fromArray(array_map(fn($x) => [$x['loan_institution'], $x['loan_count'], amount((float)$x['current_interest']), (string)($x['settlement_date'] ?? ''), amount((float)$x['settlement_interest'])], $instRows), null, 'A2'); + foreach (range('A', 'E') as $col) { $sheet1->getColumnDimension($col)->setWidth(24); } + + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('当月还款计划'); + $sheet2->fromArray(['机构', '档案名称', '借款主体', '到期日', '金额', '状态'], null, 'A1'); + $sheet2->fromArray(array_map(fn($x) => [$x['loan_institution'], $x['archive_name'], $x['borrower'], $x['due_date'], amount((float)$x['amount']), $x['status']], $planRows), null, 'A2'); + $sheet2->setCellValue('A' . (count($planRows) + 3), '合计'); + $sheet2->setCellValue('E' . (count($planRows) + 3), amount($planTotal)); + foreach (range('A', 'F') as $col) { $sheet2->getColumnDimension($col)->setWidth(22); } + + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode('应付本息查询_' . date('Y-m-d') . '.xlsx') . '"'); + (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output'); + exit; + } + pageHeader($me); ?> +
    +

    应付本息查询

    + +
    +
    +

    一、银行贷款机构利息统计(履行中及逾期中)

    + + + + + + + + + + + + + + + +
    机构笔数当前应付利息结息日结息日应付利息
    合计--
    暂无数据
    +
    +
    +

    二、当月还款计划金额

    +
    + + + + + + + + + + + + + + +
    机构档案名称借款主体到期日金额状态
    合计--
    暂无当月还款计划
    +
    +
    + query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); + $financeAll = loadFinanceConfigs(); + $resultRows = array_values(array_filter(qqSearchGuarantorArchives($rows, $kw), function ($r) use ($financeAll) { + return archiveStatusIsOngoingAggregate((string)($r['end_date'] ?? ''), getArchiveFinanceConfig($financeAll, (int)$r['id'])); + })); + if ($format === 'xlsx' && $kw !== '' && class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet') && class_exists('\PhpOffice\PhpSpreadsheet\Writer\Xlsx')) { + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('对外担保查询'); + $sheet->fromArray(['档案类型', '档案名称', '贷款机构/债权人', '借款主体', '担保人', '授信额度', '剩余额度', '到期日', '状态'], null, 'A1'); + $sheet->fromArray(array_map(function ($r) use ($financeAll) { + return [ + archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))), + (string)($r['name'] ?? ''), + (string)($r['loan_institution'] ?? ''), + (string)($r['borrower'] ?? ''), + (string)($r['guarantor'] ?? ''), + amount($r['credit_limit'] !== null ? (float)$r['credit_limit'] : null), + amount($r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null), + (string)($r['end_date'] ?? ''), + archiveStatusWithConfig((string)($r['end_date'] ?? ''), getArchiveFinanceConfig($financeAll, (int)$r['id'])), + ]; + }, $resultRows), null, 'A2'); + foreach (range('A', 'I') as $col) { $sheet->getColumnDimension($col)->setWidth(18); } + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode('对外担保查询_' . date('Y-m-d') . '.xlsx') . '"'); + (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output'); + exit; + } + pageHeader($me); ?> +
    +

    对外担保查询

    + +
    + + + + 导出xlsx +
    +

    仅列出状态为「履行中」或「逾期中」的档案。

    +
    +
    +
    + + + + + + + + + + + + + + + + +
    档案类型档案名称贷款机构/债权人借款主体担保人授信额度剩余额度到期日状态
    +
    + 0) { + $dirFilter = collectSubDirIds($dirs, $dirId); + } + $rows = $pdo->query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); + $financeAll = loadFinanceConfigs(); + $rows = array_values(array_filter($rows, function ($r) use ($mode, $selected, $dirFilter, $financeAll, $kindFilter) { + if ($dirFilter && !in_array((int)$r['directory_id'], $dirFilter, true)) { + return false; + } + $rowKind = normalizeArchiveKind((string)($r['archive_kind'] ?? '')); + if ($kindFilter !== 'all' && $rowKind !== $kindFilter) { + return false; + } + $status = archiveStatusWithConfig($r['end_date'], getArchiveFinanceConfig($financeAll, (int)$r['id'])); + if ($mode === 'selected' && !in_array((int)$r['id'], $selected, true)) { + return false; + } + return true; + })); + $modeName = ['all' => '全部', 'selected' => '选中'][$mode] ?? '全部'; + $headers = ['档案类型', '贷款机构', '借款主体', '授信额度', '剩余额度', '起止日', '到期日', '年利率', '担保类型', '担保人', '合同状态']; + $dataRows = []; + foreach ($rows as $r) { + $dataRows[] = [ + archiveKindLabel(normalizeArchiveKind($r['archive_kind'] ?? '')), + $r['loan_institution'], + $r['borrower'], + amount($r['credit_limit'] !== null ? (float)$r['credit_limit'] : null), + amount($r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null), + $r['start_date'], + $r['end_date'], + $r['annual_rate'] === null ? '' : ((string)$r['annual_rate'] . '%'), + $r['guarantee_type'], + $r['guarantor'], + archiveStatusWithConfig($r['end_date'], getArchiveFinanceConfig($financeAll, (int)$r['id'])), + ]; + } + + $xlsxEnabled = class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet') && class_exists('\PhpOffice\PhpSpreadsheet\Writer\Xlsx'); + if ($format === 'xlsx' && $xlsxEnabled) { + $filename = '档案汇总_' . $modeName . '_' . date('Y-m-d') . '.xlsx'; + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray($headers, null, 'A1'); + $sheet->fromArray($dataRows, null, 'A2'); + foreach (range('A', 'K') as $col) { + $sheet->getColumnDimension($col)->setWidth(16); + } + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $writer->save('php://output'); + exit; + } + + $filename = '档案汇总_' . $modeName . '_' . date('Y-m-d') . '.csv'; + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); + echo "\xEF\xBB\xBF"; + $out = fopen('php://output', 'wb'); + fputcsv($out, $headers); + foreach ($dataRows as $row) { + fputcsv($out, $row); + } + fclose($out); + exit; +} + +$dirs = fetchDirectories($pdo); +$dirId = (int)($_GET['dir_id'] ?? 0); +$q = trim((string)($_GET['q'] ?? '')); +$searchField = $_GET['search_field'] ?? 'all'; +$statusFilter = $_GET['status'] ?? 'not_done'; +$kindFilter = (string)($_GET['kind'] ?? ARCHIVE_KIND_BANK_LOAN); +if (!in_array($kindFilter, ['all', ARCHIVE_KIND_BANK_LOAN, ARCHIVE_KIND_EXTERNAL_GUARANTEE], true)) { + $kindFilter = ARCHIVE_KIND_BANK_LOAN; +} +$sortBy = $_GET['sort_by'] ?? 'end_date'; +$sortOrder = strtolower((string)($_GET['sort_order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc'; +$rows = $pdo->query('SELECT * FROM archives ORDER BY id DESC')->fetchAll(); +$financeAllGlobal = loadFinanceConfigs(); +$dirPath = dirPathMap($dirs); +$dirScope = []; +if ($dirId > 0) { + $dirScope = collectSubDirIds($dirs, $dirId); +} +$archives = array_values(array_filter($rows, function ($r) use ($q, $statusFilter, $dirScope, $searchField, $financeAllGlobal, $kindFilter) { + if ($dirScope && !in_array((int)$r['directory_id'], $dirScope, true)) { + return false; + } + $rowKind = normalizeArchiveKind((string)($r['archive_kind'] ?? '')); + if ($kindFilter !== 'all' && $rowKind !== $kindFilter) { + return false; + } + $status = archiveStatusWithConfig($r['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$r['id'])); + if ($statusFilter === 'not_done' && $status === '已完成') { + return false; + } + if (!in_array($statusFilter, ['all', 'not_done'], true) && $statusFilter !== $status) { + return false; + } + if ($q !== '') { + if (!in_array($searchField, ['all', 'borrower', 'guarantor'], true)) { + $searchField = 'all'; + } + if ($searchField === 'borrower') { + $hay = mb_strtolower((string)$r['borrower']); + } elseif ($searchField === 'guarantor') { + $hay = mb_strtolower((string)$r['guarantor']); + } else { + $hay = mb_strtolower(implode(' ', [ + $r['name'], + $r['loan_institution'], + $r['borrower'], + $r['guarantee_type'], + $r['guarantor'], + ])); + } + if (mb_strpos($hay, mb_strtolower($q)) === false) { + return false; + } + } + return true; +})); + +if (!in_array($sortBy, ['end_date', 'created_at', 'loan_institution'], true)) { + $sortBy = 'end_date'; +} +usort($archives, function ($a, $b) use ($sortBy, $sortOrder, $financeAllGlobal) { + $sa = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id'])); + $sb = archiveStatusWithConfig($b['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$b['id'])); + $w = ['履行中' => 0, '逾期中' => 1, '待更新' => 2, '已完成' => 3]; + $sw = ($w[$sa] ?? 9) <=> ($w[$sb] ?? 9); + if ($sw !== 0) { + return $sw; + } + $av = (string)($a[$sortBy] ?? ''); + $bv = (string)($b[$sortBy] ?? ''); + if ($sortBy === 'loan_institution') { + $av = mb_strtolower($av); + $bv = mb_strtolower($bv); + } + $cmp = $av <=> $bv; + if ($cmp === 0) { + $cmp = ((int)$a['id']) <=> ((int)$b['id']); + } + return $sortOrder === 'asc' ? $cmp : -$cmp; +}); + +$totalCount = count($archives); +$activeCount = 0; +$doneCount = 0; +foreach ($archives as $item) { + $status = archiveStatusWithConfig($item['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$item['id'])); + if ($status === '履行中') { + $activeCount++; + } elseif ($status === '已完成') { + $doneCount++; + } elseif ($status === '逾期中') { + $activeCount++; + } +} + +$today = date('Y-m-d'); +$day60 = date('Y-m-d', strtotime('+60 days')); +$dueSoonLoans = array_values(array_filter($archives, function ($a) use ($today, $day60, $financeAllGlobal) { + if (empty($a['end_date'])) { + return false; + } + $status = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id'])); + if ($status === '已完成') { + return false; + } + return $a['end_date'] >= $today && $a['end_date'] <= $day60; +})); +$dueSoonCount = count($dueSoonLoans); +$dueSoonLoans = array_slice($dueSoonLoans, 0, 8); +$overdueLoans = array_values(array_filter($archives, function ($a) use ($financeAllGlobal) { + $status = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id'])); + return $status === '逾期中'; +})); +$overdueLoanCount = count($overdueLoans); +$overdueLoans = array_slice($overdueLoans, 0, 8); +$repayPlanSoon = []; +$overduePlanRows = []; +$planAllForReminder = loadRepaymentPlans(); +foreach ($planAllForReminder as $aid => $cfg) { + if (empty($cfg['enabled']) || empty($cfg['plans']) || !is_array($cfg['plans'])) { + continue; + } + $aidInt = (int)$aid; + $arc = null; + foreach ($archives as $tmpArc) { + if ((int)$tmpArc['id'] === $aidInt) { + $arc = $tmpArc; + break; + } + } + if (!$arc) { + continue; + } + $day30 = date('Y-m-d', strtotime('+30 days')); + foreach ($cfg['plans'] as $p) { + $status = (string)($p['status'] ?? 'pending'); + $d = (string)($p['due_date'] ?? ''); + if ($status === 'paid' || $d === '') { + continue; + } + if ($d >= $today && $d <= $day30) { + $repayPlanSoon[] = [ + 'archive_id' => $aidInt, + 'plan_id' => (string)($p['id'] ?? ''), + 'archive_kind' => normalizeArchiveKind((string)($arc['archive_kind'] ?? '')), + 'loan_institution' => $arc['loan_institution'] ?? '', + 'borrower' => $arc['borrower'] ?? '', + 'amount' => (float)($p['amount'] ?? 0), + 'due_date' => $d, + ]; + } + if ($status === 'overdue' && $d <= $today) { + $overduePlanRows[] = [ + 'archive_id' => $aidInt, + 'plan_id' => (string)($p['id'] ?? ''), + 'archive_kind' => normalizeArchiveKind((string)($arc['archive_kind'] ?? '')), + 'loan_institution' => $arc['loan_institution'] ?? '', + 'borrower' => $arc['borrower'] ?? '', + 'amount' => (float)($p['amount'] ?? 0), + 'due_date' => $d, + ]; + } + } +} +$repaySoonCount = count($repayPlanSoon); +$repayPlanSoon = array_slice($repayPlanSoon, 0, 8); +$overdueCount = $overdueLoanCount + count($overduePlanRows); + +$pendingUpdateRows = []; +foreach ($archives as $a) { + $aid = (int)$a['id']; + $end = (string)($a['end_date'] ?? ''); + if ($end === '' || $end >= $today) { + continue; + } + if (archiveStatusWithConfig($end, getArchiveFinanceConfig($financeAllGlobal, $aid)) !== '待更新') { + continue; + } + $pendingUpdateRows[] = [ + 'row_kind' => 'loan', + 'archive_id' => $aid, + 'type_label' => '贷款到期', + 'loan_institution' => (string)($a['loan_institution'] ?? ''), + 'borrower' => (string)($a['borrower'] ?? ''), + 'guarantor' => (string)($a['guarantor'] ?? ''), + 'amount' => $a['remaining_limit'] !== null ? (float)$a['remaining_limit'] : null, + 'date' => $end, + 'plan_id' => '', + ]; +} +$archiveIdsInScope = []; +foreach ($archives as $a) { + $archiveIdsInScope[(int)$a['id']] = true; +} +foreach ($planAllForReminder as $aidStr => $cfg) { + $aidInt = (int)$aidStr; + if (empty($archiveIdsInScope[$aidInt])) { + continue; + } + if (empty($cfg['enabled']) || empty($cfg['plans']) || !is_array($cfg['plans'])) { + continue; + } + $arc = null; + foreach ($archives as $tmp) { + if ((int)$tmp['id'] === $aidInt) { + $arc = $tmp; + break; + } + } + if (!$arc) { + continue; + } + foreach ($cfg['plans'] as $p) { + $pst = (string)($p['status'] ?? 'pending'); + $d = (string)($p['due_date'] ?? ''); + if ($d === '' || $d >= $today) { + continue; + } + if (!in_array($pst, ['pending', 'delayed'], true)) { + continue; + } + $pendingUpdateRows[] = [ + 'row_kind' => 'plan', + 'archive_id' => $aidInt, + 'type_label' => '还款计划到期', + 'loan_institution' => (string)($arc['loan_institution'] ?? ''), + 'borrower' => (string)($arc['borrower'] ?? ''), + 'guarantor' => (string)($arc['guarantor'] ?? ''), + 'amount' => (float)($p['amount'] ?? 0), + 'date' => $d, + 'plan_id' => (string)($p['id'] ?? ''), + ]; + } +} +usort($pendingUpdateRows, function ($x, $y) { + $c = strcmp((string)($x['date'] ?? ''), (string)($y['date'] ?? '')); + if ($c !== 0) { + return $c; + } + return ((int)($x['archive_id'] ?? 0)) <=> ((int)($y['archive_id'] ?? 0)); +}); +$pendingUpdateCount = count($pendingUpdateRows); + +if ($action === 'pending_updates') { + requireLogin(); + $puBack = 'index.php'; + if ($dirId > 0) { + $puBack .= '?dir_id=' . $dirId; + } + pageHeader($me); ?> +
    +

    待更新详情

    +

    以下为已过到期日且尚未在系统中标记结果的贷款与还款计划。处理后将从本列表移除。

    +

    ← 返回首页

    +
    + + + + + + + + + + + + + + + + + +
    类型贷款机构借款主体担保人金额日期操作
    + 查看档案 + + +
    + + + 0): ?> + + +
    +
    + + + 0): ?> + + +
    + +
    + + + + 0): ?> + + +
    +
    + + + + 0): ?> + + +
    + + +
    暂无待更新项
    +
    + '贷款到期(60天)', 'archive_id' => (int)$r['id'], 'plan_id' => '', 'archive_kind' => archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))), 'loan_institution' => (string)$r['loan_institution'], 'borrower' => (string)$r['borrower'], 'amount' => amount($r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null), 'date' => (string)$r['end_date']]; + } + foreach ($repayPlanSoon as $r) { + $allReminderRows[] = ['type' => '还款计划(30天)', 'archive_id' => (int)$r['archive_id'], 'plan_id' => (string)$r['plan_id'], 'archive_kind' => archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))), 'loan_institution' => (string)$r['loan_institution'], 'borrower' => (string)$r['borrower'], 'amount' => amount((float)$r['amount']), 'date' => (string)$r['due_date']]; + } + foreach ($overdueLoans as $r) { + $allReminderRows[] = ['type' => '逾期中', 'archive_id' => (int)$r['id'], 'plan_id' => '', 'archive_kind' => archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))), 'loan_institution' => (string)$r['loan_institution'], 'borrower' => (string)$r['borrower'], 'amount' => amount($r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null), 'date' => (string)$r['end_date']]; + } + foreach ($overduePlanRows as $r) { + $allReminderRows[] = ['type' => '逾期中', 'archive_id' => (int)$r['archive_id'], 'plan_id' => (string)$r['plan_id'], 'archive_kind' => archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))), 'loan_institution' => (string)$r['loan_institution'], 'borrower' => (string)$r['borrower'], 'amount' => amount((float)$r['amount']), 'date' => (string)$r['due_date']]; + } + $rows = array_values(array_filter($allReminderRows, function ($row) use ($reminderType) { + if ($reminderType === 'all') return true; + if ($reminderType === 'due') return $row['type'] === '贷款到期(60天)'; + if ($reminderType === 'plan') return $row['type'] === '还款计划(30天)'; + if ($reminderType === 'overdue') return $row['type'] === '逾期中'; + return true; + })); + if ($exportFormat !== '') { + $headers = ['类型', '档案类型', '贷款机构', '借款主体', '金额', '日期']; + if ($exportFormat === 'xlsx' && class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet') && class_exists('\PhpOffice\PhpSpreadsheet\Writer\Xlsx')) { + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray($headers, null, 'A1'); + $sheet->fromArray(array_map(fn($x) => [$x['type'], $x['archive_kind'], $x['loan_institution'], $x['borrower'], $x['amount'], $x['date']], $rows), null, 'A2'); + foreach (range('A', 'F') as $col) { $sheet->getColumnDimension($col)->setWidth(20); } + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . rawurlencode('提醒明细_' . date('Y-m-d') . '.xlsx') . '"'); + (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output'); + exit; + } + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . rawurlencode('提醒明细_' . date('Y-m-d') . '.csv') . '"'); + echo "\xEF\xBB\xBF"; + $out = fopen('php://output', 'wb'); + fputcsv($out, $headers); + foreach ($rows as $r) { fputcsv($out, [$r['type'], $r['archive_kind'], $r['loan_institution'], $r['borrower'], $r['amount'], $r['date']]); } + fclose($out); + exit; + } + pageHeader($me); ?> +
    +

    到期提醒详情

    +
    + + + + 清空 + + +
    +
    + + + + + + + + + + + + + + + + + +
    类型档案类型贷款机构借款主体金额日期操作
    + 0): ?> + 查看档案 + + 0 && $r['type'] === '贷款到期(60天)' && isAdmin()): ?> +
    + + + + + +
    + + 0 && $r['type'] === '还款计划(30天)' && isAdmin()): ?> +
    + + + + + + + +
    + + 0 && $r['type'] === '逾期中' && isAdmin()): ?> +
    + + + + + + +
    + + + - + +
    暂无提醒内容
    +
    + $totalPages) { + $currentPage = $totalPages; +} +$offset = ($currentPage - 1) * $perPage; +$archivesPage = array_slice($archives, $offset, $perPage); + +$baseQuery = ['q' => $q, 'search_field' => $searchField, 'status' => $statusFilter, 'kind' => $kindFilter, 'per_page' => $perPage, 'sort_by' => $sortBy, 'sort_order' => $sortOrder]; +if ($dirId) { + $baseQuery['dir_id'] = $dirId; +} +unset($baseQuery['page']); +$pathIds = collectAncestorIds($dirs, $dirId ?: null); + +pageHeader($me); +?> +
    +
    +
    +

    目录结构

    + + 一级目录 +
    + +
    +

    便捷查询

    + +
    +
    +
    +
    +
    当前结果总数
    +
    履行中
    +
    已完成
    +
    待更新
    +
    贷款到期
    +
    还款计划
    +
    贷款逾期
    +
    +
    +
    +

    档案列表

    +
    + 当前目录筛选中 + + + 新建档案 + + +
    + + +
    + 模板下载 + +
    + + + + + + +
    +
    +
    +
    +
    + + + + + + + + + + 一键清空 +
    +
    +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    类型档案名称机构贷款行/债权人借款主体授信额度剩余额度起始日到期日担保人年利率状态操作
    担保贷款 + + + +
    +

    + + + + +

    +
    + +
    +
    / 页,共
    +
    + max(1, $currentPage - 1)])); + $nextQuery = http_build_query(array_merge($baseQuery, ['page' => min($totalPages, $currentPage + 1)])); + ?> + 1): ?>上一页上一页 + | + 下一页下一页 +
    +
    +
    +
    +
    + 0 +- 贴现日 < 到期日 +- 利率 > 0 +- 期限月数为正整数(1-12) + +4.增加历史还款记录查询,统计所有已归还的还本计划本金及到期的贷款本金 diff --git a/部署说明.md b/部署说明.md new file mode 100755 index 0000000..10e48eb --- /dev/null +++ b/部署说明.md @@ -0,0 +1,104 @@ +# 金牛集团贷款档案管理系统 - 部署说明 + +## 1. 环境要求 + +- 宝塔面板(Linux) +- Nginx 或 Apache +- PHP 8.0+(建议 8.2) +- 必需扩展:`pdo_sqlite`、`sqlite3`、`mbstring`、`fileinfo` +- 推荐扩展:`zip`、`xml`(用于 xlsx 导出/导入) + +## 2. 目录结构 + +- `public/`:网站入口目录(宝塔运行目录设置到这里) +- `app/`:配置与基础逻辑 +- `data/`:业务数据(SQLite + JSON配置) +- `uploads/`:上传文件目录(按目录层级自动分文件夹) + +### `data/` 下的关键文件 + +- `archive.sqlite`:主业务数据库 +- `repayment_plans.json`:分期还款计划(免迁移方案) +- `archive_finance.json`:结息/逾期配置(免迁移方案) +- `delete_approvals.json`:双管理员删除审批记录 +- `import_template.xlsx`:可选,自定义导入模板(手工放置) + +## 3. 宝塔部署步骤 + +1. 新建 PHP 站点 +2. 上传项目到站点目录 +3. 网站运行目录设置为:`/public` +4. 在 PHP 扩展中启用上面的必需扩展 +5. 给 `data/` 与 `uploads/` 赋予可写权限 +6. 访问域名初始化系统 + +## 4. 默认账号 + +- 用户名:`admin` +- 密码:`jinniu123` + +> 登录后建议立即修改默认密码。 + +## 5. 导出说明 + +- 导出支持:`xlsx` / `csv` +- 页面可自由选择格式 +- 若服务器安装了 `PhpSpreadsheet`,`xlsx` 可直接使用: + +```bash +composer require phpoffice/phpspreadsheet +``` + +## 6. 导入说明 + +### 6.1 下载模板 + +- 点击页面“模板下载” +- 系统优先下载:`data/import_template.xlsx`(如果你手工放了该文件) +- 若该文件不存在,系统会动态生成模板(需要 `PhpSpreadsheet`) + +### 6.2 模板字段规则 + +#### 必填字段 + +- 一级目录 +- 二级目录 +- 三级目录 +- 档案名称 +- 贷款机构 +- 借款主体 +- 授信额度 +- 起止日 +- 到期日 +- 年利率 +- 担保类型 +- 担保人 + +#### 选填字段 + +- 计息方式(按月计息 / 按季付息 / 到期一次性付清) +- 结息日(1-28) +- 是否有分期还款计划(是/否) + +## 7. 文件上传与存储 + +文件上传后按“目录层级 + 档案”自动存储,例如: + +`uploads/一级目录/二级目录/三级目录/archive_档案ID_档案名/文件` + +## 8. 删除审批(双管理员) + +- 管理员A点击删除 -> 仅提交申请 +- 管理员B在“删除审核”页面审批通过后才执行真实删除 +- 申请人本人不能审批自己的删除申请 + +## 9. 试运行建议 + +上线前建议至少验证: + +1. 新建/编辑/查看档案 +2. 新建页上传文件、编辑页文件管理(上传/重命名/删除) +3. 分期计划与剩余额度联动 +4. 到期提醒详情页筛选与导出 +5. 模板导入(先用 2-3 条测试数据) +6. 双管理员删除闭环流程