Files
jinniu-daikuan/public/index.php
T
梁铭洋 97fea48393 Initial commit: 金牛集团贷款档案管理系统
导入现有代码与文档,排除 data/uploads 运行时数据。
2026-08-29 11:52:22 +08:00

4064 lines
201 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/auth.php';
require_once __DIR__ . '/../app/helpers.php';
require_once __DIR__ . '/../app/services/quick_queries.php';
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php';
}
$pdo = db();
$action = $_GET['action'] ?? 'home';
if (!defined('ARCHIVE_KIND_BANK_LOAN')) {
define('ARCHIVE_KIND_BANK_LOAN', 'bank_loan');
define('ARCHIVE_KIND_EXTERNAL_GUARANTEE', 'external_guarantee');
}
function normalizeArchiveKind(?string $v): string
{
$v = trim((string) $v);
return $v === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? ARCHIVE_KIND_EXTERNAL_GUARANTEE : ARCHIVE_KIND_BANK_LOAN;
}
function archiveKindLabel(string $k): string
{
return $k === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? '对外担保' : '银行贷款';
}
if ($action === 'logout') {
session_destroy();
header('Location: index.php?action=login');
exit;
}
if ($action === 'login') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$stmt = $pdo->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();
?>
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>金牛集团贷款档案管理系统 - 登录</title>
<style>
body{font-family:Inter,Arial,sans-serif;background:linear-gradient(130deg,#edf3ff 0%,#f8fbff 42%,#f1f5f9 100%);margin:0;color:#1f2937}
.box{max-width:420px;margin:88px auto;background:rgba(255,255,255,.95);padding:28px;border-radius:16px;box-shadow:0 18px 45px rgba(31,59,100,.14);backdrop-filter:blur(2px)}
h2{margin:0 0 8px;font-size:24px;color:#1f3b64}
.sub{margin:0 0 16px;color:#64748b;font-size:14px}
input{width:100%;padding:11px 12px;margin:8px 0;border:1px solid #d2d9e6;border-radius:10px;box-sizing:border-box;background:#fff}
input:focus{border-color:#2f5da8;outline:none;box-shadow:0 0 0 3px rgba(47,93,168,.15)}
button{width:100%;background:#1f3b64;color:#fff;border:0;padding:11px;border-radius:10px;cursor:pointer;font-weight:600}
button:hover{background:#234578}
.err{background:#ffe8e8;color:#a40000;padding:8px;border-radius:6px}
</style>
</head><body>
<div class="box">
<h2>金牛集团贷款档案管理系统</h2>
<p class="sub">请输入账号密码登录系统</p>
<?php if ($flash): ?><div class="err"><?= e($flash['msg']) ?></div><?php endif; ?>
<form method="post">
<input name="username" placeholder="用户名" required>
<input type="password" name="password" placeholder="密码" required>
<button type="submit">登录</button>
</form>
</div>
</body></html>
<?php
exit;
}
requireLogin();
$me = currentUser();
if ($action === 'home') {
$qs = (string)($_SERVER['QUERY_STRING'] ?? '');
$_SESSION['last_list_url'] = 'index.php' . ($qs !== '' ? ('?' . $qs) : '');
}
function pageHeader(array $me): void
{
$flash = getFlash();
$lastListUrl = (string)($_SESSION['last_list_url'] ?? 'index.php');
if ($lastListUrl === '' || strpos($lastListUrl, 'index.php') !== 0) {
$lastListUrl = 'index.php';
}
?>
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>金牛集团贷款档案管理系统</title>
<style>
:root{
--bg:#f3f6fb;--text:#1f2937;--card:#fff;--line:#edf0f5;--head:#f8fafc;--muted:#6b7280;
--top1:#1d355c;--top2:#254776;--primary:#1f3b64;--primary-hover:#234578;
--input-bg:#fff;--input-border:#d8e0ea;--shadow:0 8px 24px rgba(15,23,42,.08);
}
body.dark{
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--line:#1f2937;--head:#172033;--muted:#9ca3af;
--top1:#0b1222;--top2:#172441;--primary:#2a4c80;--primary-hover:#355d99;
--input-bg:#0b1324;--input-border:#2a3a55;--shadow:0 8px 24px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
html,body{max-width:100%;overflow-x:hidden}
body{font-family:Inter,Arial,sans-serif;background:var(--bg);margin:0;color:var(--text)}
.top{background:linear-gradient(90deg,var(--top1),var(--top2));color:#fff;padding:10px 10px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 3px 18px rgba(20,44,84,.22);position:sticky;top:0;z-index:40;gap:8px;flex-wrap:wrap}
.brand{font-size:18px;font-weight:700;letter-spacing:.2px}
.a{color:#fff;text-decoration:none;margin-left:12px;padding:7px 10px;border-radius:8px;transition:background .2s}
.a:hover{background:rgba(255,255,255,.16)}
.wrap{padding:12px 16px 6px;max-width:100%;margin:0 auto;width:100%;box-sizing:border-box}
.grid{display:grid;grid-template-columns:minmax(180px,250px) minmax(0,1fr);gap:12px;min-width:0}
.card{background:var(--card);border-radius:14px;padding:14px;box-shadow:var(--shadow);min-width:0;box-sizing:border-box}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid var(--line);padding:7px 6px;font-size:13px;text-align:left}
th{color:#475569;font-weight:600;background:var(--head)}
input,select{padding:8px 10px;border:1px solid var(--input-border);border-radius:8px;background:var(--input-bg);color:var(--text)}
input:focus,select:focus{border-color:#2f5da8;outline:none;box-shadow:0 0 0 3px rgba(47,93,168,.12)}
button{padding:8px 12px;border:0;border-radius:8px;background:var(--primary);color:#fff;cursor:pointer}
button:hover{background:var(--primary-hover)}
.muted{color:var(--muted)}
.badge{padding:3px 9px;border-radius:999px;font-size:12px}
.b1{background:#e8f1ff;color:#1d4ed8}.b2{background:#eee;color:#374151}.b3{background:#fff;border:1px solid #e5e7eb;color:#374151}.b4{background:#fee2e2;color:#b91c1c}
.flash-ok{background:#e7ffe8;color:#0b7a1a;padding:8px;border-radius:6px;margin-bottom:8px}
.flash-err{background:#ffe8e8;color:#a40000;padding:8px;border-radius:6px;margin-bottom:8px}
.tree ul{list-style:none;padding-left:14px;margin:0}
.tree li{margin:2px 0;position:relative}
.inline{display:inline}
.small{font-size:12px}
.path{font-size:12px;color:var(--muted)}
.btn-danger{background:#b91c1c}
.btn-gray{background:#4b5563}
.table-wrap{overflow-x:hidden;max-width:100%;width:100%}
.table-wrap table{min-width:0;width:100%}
.table-wrap thead th{position:sticky;top:0;z-index:2}
.table-wrap th:last-child,.table-wrap td:last-child{position:sticky;right:0;background:var(--card);z-index:1}
.table-wrap thead th:last-child{z-index:3}
.fixed-table{table-layout:fixed}
.truncate{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.col-check{width:34px}
.col-kind{width:64px;text-align:center}
.badge-k-loan{font-size:11px;padding:2px 6px;border-radius:6px;background:#eef4ff;color:#1e40af;font-weight:600}
.badge-k-guar{font-size:11px;padding:2px 6px;border-radius:6px;background:#ecfdf5;color:#166534;font-weight:600}
.th-stack{display:flex;flex-direction:column;gap:2px;line-height:1.2;align-items:flex-start}
.th-sub{font-size:11px;font-weight:400;color:#64748b}
.col-name{width:108px}
.col-inst{width:96px}
.col-borrower{width:88px}
.col-guarantor{width:88px}
.col-amount{width:84px}
.col-date{width:68px}
.col-rate{width:46px}
.col-status{width:52px}
.col-action{width:138px}
.archive-form-grid{display:grid;grid-template-columns:1fr;gap:10px 12px;max-width:920px}
.archive-form-grid .form-actions{grid-column:1/-1;display:flex;gap:10px;align-items:center}
.archive-form-grid .full{grid-column:1/-1}
.archive-form-grid .form-row-2{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px;align-items:start}
.archive-form-grid .form-row-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 12px;align-items:start}
.archive-form-grid .form-row-overdue{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(52px,76px) minmax(0,1.15fr);gap:10px 12px;align-items:start}
.archive-form-grid .form-col-float .form-field-stack>label{font-size:12px;color:#64748b}
.archive-form-grid .form-col-float .form-field-stack input{text-align:center;font-size:13px;padding:6px 4px}
.archive-form-grid .form-field-stack{display:flex;flex-direction:column;gap:4px;min-width:0}
.archive-form-grid .form-field-stack>label{font-size:13px;color:#475569}
.archive-form-grid .form-field-stack input,.archive-form-grid .form-field-stack select,.archive-form-grid .form-field-stack textarea{width:100%;box-sizing:border-box}
.archive-form-grid .form-field-stack textarea{resize:vertical;min-height:72px;font-family:inherit;font-size:14px;line-height:1.45;padding:8px 10px;border-radius:8px;border:1px solid var(--input-border);background:var(--input-bg);color:var(--text)}
.archive-form-grid .date-labeled{display:flex;align-items:center;gap:10px;min-width:0}
.archive-form-grid .date-labeled .date-fixed-label{flex:0 0 auto;font-size:14px;color:var(--text);white-space:nowrap}
.archive-form-grid .date-labeled input[type=date]{flex:1 1 auto;min-width:0}
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.toolbar-left{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.toolbar-right{display:flex;gap:10px;align-items:center}
.pill{background:#eef4ff;color:#1e3a8a;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600}
.metric-wrap{display:grid;grid-template-columns:repeat(7,minmax(110px,1fr));gap:10px;margin-bottom:12px}
.metric{background:linear-gradient(180deg,#ffffff,#f8fbff);border:1px solid #e3eaf5;border-radius:12px;padding:10px}
.metric .k{font-size:12px;color:#64748b}
.metric .v{margin-top:4px;font-size:22px;color:#1f3b64;font-weight:700}
.metric.m-all{background:#f8fafc}
.metric.m-active{background:#eff6ff}
.metric.m-done{background:#f3f4f6}
.metric.m-unset{background:#ffffff}
.metric.m-due{background:#fff7ed}
.metric.m-plan{background:#ecfeff}
.metric.m-overdue{background:#fef2f2}
.tree{position:sticky;top:72px;max-height:calc(100vh - 90px);overflow:auto}
.section-title{margin:0 0 10px;font-size:18px;color:#1f3b64}
.subtle-link{color:#1d4ed8;text-decoration:none}
.subtle-link:hover{text-decoration:underline}
.toolbar-btn,.icon-btn{
display:inline-flex;align-items:center;justify-content:center;gap:4px;
height:28px;padding:0 10px;border:1px solid var(--input-border);
border-radius:8px;background:var(--input-bg);
color:var(--text);text-decoration:none;font-size:12px;
transition:all .2s ease;box-sizing:border-box;
}
.toolbar-btn:hover,.icon-btn:hover{border-color:#2f5da8;color:#1d4ed8;background:rgba(59,130,246,.06)}
.toolbar-btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.toolbar-btn.primary:hover{background:var(--primary-hover);border-color:var(--primary-hover);color:#fff}
.tree details{margin:2px 0}
.tree summary{cursor:pointer;color:var(--text);list-style:none}
.tree summary::-webkit-details-marker{display:none}
.tree li::before{content:"";position:absolute;left:-9px;top:-6px;bottom:-6px;border-left:1px solid var(--line)}
.tree li::after{content:"";position:absolute;left:-9px;top:15px;width:9px;border-top:1px solid var(--line)}
.tree > ul > li::before,.tree > ul > li::after{display:none}
.tree-item{display:flex;align-items:center;gap:6px;min-height:28px;padding:2px 4px;border-radius:8px}
.tree-item:hover{background:rgba(59,130,246,.06)}
.tree-toggle{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:4px;color:#64748b;font-size:11px;user-select:none}
details[open] > summary .tree-toggle{transform:rotate(90deg)}
.tree-link{color:var(--text);text-decoration:none;display:inline-block;max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tree-link:hover{text-decoration:underline}
.tree-link.current{font-weight:700;color:#1d4ed8}
.tree-link.current::before{content:"";display:inline-block;width:3px;height:14px;background:#3b82f6;border-radius:3px;margin-right:6px;vertical-align:-2px}
.quick-links-col{display:flex;flex-direction:column;align-items:flex-start;gap:8px;min-width:0}
.quick-links-col .tree-link{padding:4px 2px}
.quick-menu-details{border:1px solid var(--line);border-radius:10px;background:var(--head);overflow:hidden}
.quick-menu-details>summary{list-style:none;cursor:pointer;padding:8px 10px;font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;justify-content:space-between;gap:8px;user-select:none}
.quick-menu-details>summary::-webkit-details-marker{display:none}
.quick-menu-details>summary::after{content:"▶";font-size:10px;color:var(--muted);transition:transform .2s}
.quick-menu-details[open]>summary::after{transform:rotate(90deg)}
.quick-menu-hint{font-weight:400;color:var(--muted);font-size:12px}
.quick-menu-details .quick-links-col{padding:4px 10px 12px}
.fin-tool-page{max-width:1100px}
.fin-two-col{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:16px;align-items:start}
@media (max-width:900px){.fin-two-col{grid-template-columns:1fr}}
.fin-panel{border:1px solid var(--line);border-radius:12px;padding:14px;background:var(--card)}
.fin-panel h4{margin:0 0 10px;font-size:15px;color:#1f3b64}
.fin-result-card{background:linear-gradient(165deg,#f0f7ff 0%,#e8f4fc 100%);border:1px solid #c7d9ee;border-radius:12px;padding:14px;box-shadow:inset 0 1px 0 rgba(255,255,255,.6)}
body.dark .fin-result-card{background:linear-gradient(165deg,#172554 0%,#1e3a5f 100%);border-color:#334e80}
.fin-row{display:grid;grid-template-columns:120px 1fr;gap:8px 12px;align-items:center;margin-bottom:10px;font-size:13px}
@media (max-width:520px){.fin-row{grid-template-columns:1fr}}
.fin-row label{color:var(--muted)}
.fin-muted{font-size:12px;color:var(--muted);margin-top:4px}
.fin-out-num{font-size:22px;font-weight:700;color:#1f3b64;letter-spacing:.02em}
body.dark .fin-out-num{color:#e2e8f0}
.fin-cn-cap{font-size:13px;color:#475569;margin-top:6px;line-height:1.5}
.fin-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.tree-link.ancestor{font-weight:600;color:#334155}
.tree-actions{display:inline-flex;gap:4px;margin-left:auto;opacity:.25;transition:opacity .2s}
.tree-item:hover .tree-actions,.tree-link.current + .tree-actions{opacity:1}
.tree-btn{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:1px solid transparent;border-radius:5px;text-decoration:none;font-size:11px;background:transparent;color:var(--muted)}
.tree-btn:hover{border-color:var(--input-border);background:var(--input-bg)}
.tree-btn.edit:hover{color:#1d4ed8}
.tree-btn.add:hover{color:#0f766e}
.tree-btn.del{opacity:0;visibility:hidden;transition:opacity .18s}
.tree-item:hover .tree-btn.del,.tree-link.current + .tree-actions .tree-btn.del{opacity:1;visibility:visible}
.tree-btn.del:hover{color:#b91c1c;border-color:#fecaca;background:#fff1f2}
.pager{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap}
.pager a{color:#1d4ed8;text-decoration:none}
.pager a:hover{text-decoration:underline}
.theme-btn{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.32)}
.theme-btn:hover{background:rgba(255,255,255,.26)}
.icon-actions{display:flex;gap:4px;flex-wrap:nowrap;white-space:nowrap}
.icon-btn.danger{border-color:#fecaca;color:#b91c1c}
.icon-btn.danger:hover{background:#fee2e2;border-color:#fecaca;color:#b91c1c}
.clear-link{padding:8px 10px;border:1px solid var(--input-border);border-radius:8px;text-decoration:none;color:var(--muted);font-size:13px}
.clear-link:hover{border-color:#2f5da8;color:#1d4ed8}
@media (max-width:1280px){
.grid{grid-template-columns:minmax(170px,220px) minmax(0,1fr)}
.tree-link{max-width:130px}
}
@media (max-width:1024px){.grid{grid-template-columns:1fr}.tree{position:static;max-height:none}.metric-wrap{grid-template-columns:repeat(3,minmax(120px,1fr))}.top{align-items:flex-start}.wrap{padding:10px 12px 4px}}
@media (max-width:720px){.metric-wrap{grid-template-columns:repeat(2,minmax(120px,1fr))}}
@media (max-width:780px){
.archive-form-grid .form-row-2,.archive-form-grid .form-row-3,.archive-form-grid .form-row-overdue{grid-template-columns:1fr}
}
@media (max-width:1366px){
th,td{padding:6px 5px;font-size:12px}
.col-name{width:100px}.col-inst{width:90px}.col-borrower{width:82px}.col-guarantor{width:78px}.col-amount{width:76px}
.col-date{width:64px}.col-rate{width:42px}.col-status{width:48px}.col-action{width:126px}
.icon-btn,.toolbar-btn{height:26px;padding:0 8px;font-size:11px}
}
@media (max-width:1180px){
.archive-table .hide-md{display:none}
.col-name{width:140px}.col-inst{width:110px}.col-borrower{width:100px}.col-guarantor{width:92px}.col-action{width:132px}
}
@media (max-width:900px){
.archive-table thead{display:none}
.archive-table,.archive-table tbody,.archive-table tr,.archive-table td{display:block;width:100%}
.archive-table tr{border:1px solid var(--line);border-radius:10px;padding:8px;margin-bottom:8px;background:var(--card)}
.archive-table td{border:0;padding:4px 0}
.archive-table td::before{
content:attr(data-label);
display:inline-block;
min-width:84px;
color:var(--muted);
font-size:12px;
margin-right:6px;
}
.archive-table td.col-check::before{content:"选择"}
.archive-table td.col-kind::before{content:"类型"}
.archive-table td.col-check{padding-bottom:6px;border-bottom:1px dashed var(--line);margin-bottom:6px}
.archive-table td.col-action{position:static;background:transparent;width:100%;padding-top:8px;border-top:1px dashed var(--line);margin-top:6px}
.archive-table td.col-action::before{content:"操作"}
.archive-table .hide-md{display:block}
.icon-actions{flex-wrap:wrap;white-space:normal}
}
</style>
</head><body>
<div class="top">
<div class="brand">金牛集团贷款档案管理系统</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;row-gap:6px">
<span><?= e(maskUsername((string)$me['username'])) ?> (<?= e(roleLabel((string)$me['role'])) ?>)</span>
<button type="button" id="theme_toggle_btn" class="a theme-btn" onclick="toggleTheme()">🌙</button>
<a class="a" href="<?= e($lastListUrl) ?>" onclick="if(window.history.length>1){window.history.back();return false;}">返回上页</a>
<a class="a" href="index.php">返回列表</a>
<a class="a" href="index.php?action=profile">个人设置</a>
<?php if ($me['role'] === 'admin'): ?><a class="a" href="index.php?action=users">用户管理</a><?php endif; ?>
<?php if ($me['role'] === 'admin'): ?><a class="a" href="index.php?action=delete_approvals">删除审核</a><?php endif; ?>
<a class="a" href="index.php?action=logout">退出</a>
</div>
</div>
<div class="wrap">
<?php if ($flash): ?>
<div id="flash-banner" class="<?= $flash['type'] === 'ok' ? 'flash-ok' : 'flash-err' ?>"><?= e($flash['msg']) ?></div>
<script>
window.addEventListener('load', function () {
var el = document.getElementById('flash-banner');
if (el) {
el.scrollIntoView({behavior: 'smooth', block: 'center'});
setTimeout(function () {
el.style.transition = 'opacity 0.5s ease, transform 0.5s ease, max-height 0.5s ease, margin 0.5s ease, padding 0.5s ease';
el.style.opacity = '0';
el.style.transform = 'translateY(-6px)';
el.style.maxHeight = '0';
el.style.margin = '0';
el.style.paddingTop = '0';
el.style.paddingBottom = '0';
el.style.overflow = 'hidden';
}, 3000);
}
});
</script>
<?php endif; ?>
<?php
}
function pageFooter(): void
{
echo <<<HTML
</div>
<script>
(function(){
var saved=localStorage.getItem('theme_mode');
if(saved==='dark'){document.body.classList.add('dark');}
refreshThemeIcon();
})();
function refreshThemeIcon(){
var btn=document.getElementById('theme_toggle_btn');
if(!btn){return;}
btn.textContent=document.body.classList.contains('dark')?'☀':'🌙';
btn.title=document.body.classList.contains('dark')?'切换浅色模式':'切换深色模式';
}
function toggleTheme(){
document.body.classList.toggle('dark');
localStorage.setItem('theme_mode', document.body.classList.contains('dark') ? 'dark' : 'light');
refreshThemeIcon();
}
</script>
</body></html>
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 = '<ul>';
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 .= '<span class="tree-actions">';
$actions .= '<a class="tree-btn edit" title="编辑目录" href="index.php?action=edit_dir&id=' . $id . '">✎</a>';
if ((int)$d['level'] < 3) {
$actions .= '<a class="tree-btn add" title="新增子目录" href="index.php?action=new_dir&parent_id=' . $id . '"></a>';
}
$actions .= '<a class="tree-btn del" title="删除目录" href="index.php?action=delete_dir&id=' . $id . '" onclick="return confirm(\'仅空目录可删除,确认?\')">🗑</a>';
$actions .= '</span>';
}
$html .= '<li>';
if ($hasChildren) {
$open = $isAncestor ? ' open' : '';
$html .= '<details' . $open . '><summary><div class="tree-item"><span class="tree-toggle">▶</span><a class="' . $linkClass . '" href="index.php?dir_id=' . $id . $kindQ . '" title="' . $name . '">' . $name . '</a>' . $actions . '</div></summary>';
} else {
$html .= '<div class="tree-item"><span class="tree-toggle"></span><a class="' . $linkClass . '" href="index.php?dir_id=' . $id . $kindQ . '" title="' . $name . '">' . $name . '</a>' . $actions . '</div>';
}
$html .= $render($id);
if ($hasChildren) {
$html .= '</details>';
}
$html .= '</li>';
}
$html .= '</ul>';
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 .= '<a class="tree-link" href="index.php?action=' . e($pair[0]) . '">' . e($pair[1]) . '</a>';
}
return '<details class="quick-menu-details">'
. '<summary class="quick-menu-summary"><span>便捷查询</span><span class="quick-menu-hint">点击展开</span></summary>'
. '<div class="quick-links-col">' . $inner . '</div>'
. '</details>';
}
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); ?>
<div class="card">
<h3>新建目录</h3>
<form method="post">
<input type="hidden" name="parent_id" value="<?= $parentId ?>">
<input name="name" placeholder="目录名称" required>
<button type="submit">保存</button>
<a href="index.php">返回</a>
</form>
</div>
<?php pageFooter(); exit;
}
if ($action === 'edit_dir') {
requireAdmin();
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->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); ?>
<div class="card"><h3>编辑目录</h3>
<form method="post">
<input name="name" value="<?= e($d['name']) ?>" required>
<button type="submit">保存</button>
<a href="index.php?dir_id=<?= $id ?>">返回</a>
</form>
</div>
<?php pageFooter(); exit;
}
if ($action === 'delete_dir') {
requireAdmin();
$id = (int)($_GET['id'] ?? 0);
$c1 = $pdo->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); ?>
<div class="card">
<h3 class="section-title">删除审核(双管理员)</h3>
<form method="get" class="toolbar-left" style="margin-bottom:10px">
<input type="hidden" name="action" value="delete_approvals">
<select name="status">
<option value="pending" <?= $filterStatus === 'pending' ? 'selected' : '' ?>>待审核</option>
<option value="approved" <?= $filterStatus === 'approved' ? 'selected' : '' ?>>已通过</option>
<option value="rejected" <?= $filterStatus === 'rejected' ? 'selected' : '' ?>>已驳回</option>
<option value="all" <?= $filterStatus === 'all' ? 'selected' : '' ?>>全部</option>
</select>
<input name="target_id" placeholder="目标ID" value="<?= e($filterTarget) ?>">
<input name="requester" placeholder="申请人" value="<?= e($filterRequester) ?>">
<button type="submit">筛选</button>
<a class="clear-link" href="index.php?action=delete_approvals">清空</a>
</form>
<table>
<tr><th>申请时间</th><th>类型</th><th>目标</th><th>申请人</th><th>状态</th><th>审核人</th><th>审核时间</th><th>操作</th></tr>
<?php foreach ($approvals as $ap): ?>
<tr>
<td><?= e((string)$ap['requested_at']) ?></td>
<td><?= e((string)$ap['target_type']) ?></td>
<td><?= e(approvalTargetLabel($ap, $archiveMap)) ?></td>
<td><?= e((string)$ap['requested_name']) ?></td>
<td><?= e((string)$ap['status']) ?></td>
<td><?php if (isset($usersMap[(int)($ap['approved_by'] ?? 0)])): ?><?= e($usersMap[(int)$ap['approved_by']]) ?><?php else: ?><span class="muted">-</span><?php endif; ?></td>
<td><?php if (!empty($ap['approved_at'])): ?><?= e((string)$ap['approved_at']) ?><?php else: ?><span class="muted">-</span><?php endif; ?></td>
<td>
<?php if (($ap['status'] ?? '') === 'pending'): ?>
<form class="inline" method="post">
<input type="hidden" name="approval_id" value="<?= e((string)$ap['id']) ?>">
<input type="hidden" name="do" value="approve">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit">审核通过并删除</button>
</form>
<form class="inline" method="post">
<input type="hidden" name="approval_id" value="<?= e((string)$ap['id']) ?>">
<input type="hidden" name="do" value="reject">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button class="btn-gray" type="submit">驳回</button>
</form>
<?php else: ?>
<span class="muted">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($approvals)): ?>
<tr><td colspan="8" class="muted">暂无符合条件的审核记录</td></tr>
<?php endif; ?>
</table>
</div>
<?php pageFooter(); exit;
}
if ($action === 'delete_archive') {
requireAdmin();
flash('error', '请通过双管理员删除流程操作');
header('Location: index.php');
exit;
}
if ($action === 'upload_file') {
requireAdmin();
$archiveId = (int)($_GET['archive_id'] ?? $_POST['archive_id'] ?? 0);
if ($archiveId <= 0 || empty($_FILES['files'])) {
flash('error', '上传参数错误');
header('Location: index.php');
exit;
}
uploadArchiveFiles($pdo, $archiveId, $_FILES['files']);
flash('ok', '上传完成');
header('Location: index.php?action=archive_form&id=' . $archiveId);
exit;
}
if ($action === 'delete_file') {
requireAdmin();
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->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); ?>
<div class="card">
<h3><?= e($a['name']) ?> <span class="muted">(<?= e($a['dir_name']) ?>)</span></h3>
<p>状态:<span class="badge <?= $status === '履行中' ? 'b1' : ($status === '已完成' ? 'b2' : ($status === '逾期中' ? 'b4' : 'b3')) ?>"><?= e($status) ?></span>
<span class="muted" style="margin-left:10px">类型:<?= e(archiveKindLabel($detailKind)) ?></span></p>
<table>
<tr><th><?= $detailKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? '债权人' : '贷款机构' ?></th><td><?= e((string)$a['loan_institution']) ?></td><th>借款主体</th><td><?= e((string)$a['borrower']) ?></td></tr>
<tr><th>授信额度</th><td><?= e(amount($a['credit_limit'] !== null ? (float)$a['credit_limit'] : null)) ?></td><th>剩余额度</th><td><?= e(amount($a['remaining_limit'] !== null ? (float)$a['remaining_limit'] : null)) ?></td></tr>
<tr><th>起止日</th><td><?= e((string)$a['start_date']) ?></td><th>到期日</th><td><?= e((string)$a['end_date']) ?></td></tr>
<tr><th>年利率</th><td><?= $detailKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? '<span class="muted">—</span>' : e($a['annual_rate'] === null ? '' : ((string)$a['annual_rate'] . '%')) ?></td><th>担保类型</th><td><?= e((string)$a['guarantee_type']) ?></td></tr>
<tr><th>担保人</th><td colspan="3"><?= e((string)$a['guarantor']) ?></td></tr>
<tr>
<th>结息方式</th>
<td><?= e($financeCfg['interest_mode'] === 'quarterly' ? '按季计息' : ($financeCfg['interest_mode'] === 'bullet' ? '到期一次性付清' : '按月计息')) ?></td>
<th>结息日</th>
<td>
<?php
$rule = (string)$financeCfg['settlement_rule'];
if ($rule === 'quarterly_day') {
echo '每季' . e((string)$financeCfg['settlement_day']) . '日';
} elseif ($rule === 'maturity_once') {
echo '到期一次性付息';
} else {
echo '每月' . e((string)$financeCfg['settlement_day']) . '日';
}
?>
</td>
</tr>
<tr><th>逾期利率上浮</th><td><?= e(amount((float)$financeCfg['overdue_float_pct'])) ?>%</td><th>当前应付利息</th><td><?= e(amount($interestPayable)) ?> 元</td></tr>
<?php if ($detailKind === ARCHIVE_KIND_BANK_LOAN): ?>
<tr><th>结息日应付利息</th><td colspan="3"><?= e(amount($settlementInterestPayable)) ?> 元 <span class="muted small">(本结息周期整段)</span></td></tr>
<?php endif; ?>
<?php $detailRemark = trim((string)($financeCfg['remark'] ?? '')); ?>
<?php if ($detailRemark !== ''): ?>
<tr><th>备注</th><td colspan="3" style="white-space:pre-wrap"><?= e($detailRemark) ?></td></tr>
<?php endif; ?>
</table>
</div>
<div class="card" style="margin-top:12px">
<h3>分期还款计划</h3>
<?php if (!($planCfg['enabled'] ?? false)): ?>
<p class="muted">该档案未启用分期还款计划</p>
<?php else: ?>
<table>
<tr><th>还款日期</th><th>还款金额</th><th>状态</th></tr>
<?php foreach ($repaymentPlans as $p): ?>
<tr>
<td><?= e((string)$p['due_date']) ?></td>
<td><?= e(amount((float)($p['amount'] ?? 0))) ?></td>
<td>
<?php
$st = (string)($p['status'] ?? 'pending');
if ($st === 'paid') {
echo '<span class="badge b1">已还款</span>';
} elseif ($st === 'delayed') {
echo '<span class="badge b2">已延期</span>';
} elseif ($st === 'overdue') {
echo '<span class="badge b4">已逾期</span>';
} else {
echo '<span class="badge b3">待处理</span>';
}
?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php if (isAdmin()): ?><p class="small muted">还款计划状态和延期调整请在“编辑档案”页面操作。</p><?php endif; ?>
<?php endif; ?>
</div>
<div class="card" style="margin-top:12px">
<h3>文件列表</h3>
<table>
<tr><th>文件名</th><th>大小</th><th>操作</th></tr>
<?php foreach ($files as $f): ?>
<tr>
<td><?= e($f['original_name']) ?></td>
<td><?= number_format(((int)$f['file_size']) / 1024, 1) ?> KB</td>
<td>
<div class="icon-actions">
<?php if (canPreview($f['file_ext'])): ?><a class="icon-btn" target="_blank" href="index.php?action=preview_file&id=<?= (int)$f['id'] ?>">👁 预览</a><?php endif; ?>
<a class="icon-btn" href="index.php?action=download_file&id=<?= (int)$f['id'] ?>">⬇ 下载</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php pageFooter(); exit;
}
if ($action === 'archive_form') {
requireAdmin();
$id = (int)($_GET['id'] ?? 0);
$editing = null;
if ($id > 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); ?>
<div class="card">
<h3 class="section-title"><?= $editing ? '编辑档案' : '新建档案' ?></h3>
<?php $formKind = $editing ? normalizeArchiveKind($editing['archive_kind'] ?? '') : ARCHIVE_KIND_BANK_LOAN; ?>
<form method="post" action="index.php?action=save_archive" class="archive-form-grid" enctype="multipart/form-data">
<input type="hidden" name="id" value="<?= (int)($editing['id'] ?? 0) ?>">
<div class="form-row-2">
<div class="form-field-stack">
<label for="archive_kind">档案类型</label>
<select name="archive_kind" id="archive_kind">
<option value="<?= e(ARCHIVE_KIND_BANK_LOAN) ?>" <?= $formKind === ARCHIVE_KIND_BANK_LOAN ? 'selected' : '' ?>>银行贷款</option>
<option value="<?= e(ARCHIVE_KIND_EXTERNAL_GUARANTEE) ?>" <?= $formKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? 'selected' : '' ?>>对外担保</option>
</select>
</div>
<div class="form-field-stack">
<label class="muted" style="font-size:12px">自动目录说明</label>
<p class="small muted" id="archive_auto_dir_hint" style="margin:0;line-height:1.5"></p>
</div>
</div>
<div class="form-row-2">
<div class="form-field-stack">
<label for="archive_name_auto">档案名称</label>
<input id="archive_name_auto" placeholder="根据档案类型自动生成" readonly value="<?= e((string)($editing['name'] ?? '')) ?>">
</div>
<div class="form-field-stack">
<label for="archive_directory_id">三级目录</label>
<select name="directory_id" id="archive_directory_id">
<option value="">自动(按档案类型与表单字段创建三级目录)</option>
<?php foreach ($dirs as $d): if ((int)$d['level'] !== 3) continue; ?>
<option value="<?= (int)$d['id'] ?>" <?= ((int)($editing['directory_id'] ?? $dirId) === (int)$d['id']) ? 'selected' : '' ?>>
<?= e($dirPath[(int)$d['id']] ?? $d['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row-2">
<div class="form-field-stack">
<label for="loan_institution_select" id="loan_institution_label">贷款机构</label>
<select name="loan_institution" id="loan_institution_select">
<option value="">请选择贷款机构</option>
<?php foreach ($loanInstitutions as $li): ?>
<option value="<?= e($li) ?>" <?= ((string)($editing['loan_institution'] ?? '') === $li) ? 'selected' : '' ?>><?= e($li) ?></option>
<?php endforeach; ?>
</select>
<input type="hidden" name="loan_institutions_json" id="loan_institutions_json" value="">
<p class="small muted" style="margin:0">
<a href="javascript:void(0)" onclick="addInstitutionOption()">+ 添加机构</a>
<span class="muted"> | </span>
<a href="javascript:void(0)" onclick="removeInstitutionOption()">- 删除当前机构</a>
</p>
</div>
<div class="form-field-stack">
<label for="borrower_input">借款主体</label>
<input name="borrower" id="borrower_input" placeholder="借款主体" value="<?= e((string)($editing['borrower'] ?? '')) ?>">
</div>
</div>
<div class="form-row-2">
<div class="form-field-stack">
<label for="credit_limit_input">授信额度</label>
<input type="number" step="0.01" name="credit_limit" id="credit_limit_input" placeholder="授信额度" value="<?= e((string)($editing['credit_limit'] ?? '')) ?>">
</div>
<div class="form-field-stack">
<label for="remaining_limit_display">剩余额度</label>
<input type="text" id="remaining_limit_display" value="<?= e(amount($editing && $editing['remaining_limit'] !== null ? (float)$editing['remaining_limit'] : null)) ?>" placeholder="剩余额度自动计算" readonly>
</div>
</div>
<div class="form-row-2">
<div class="date-labeled">
<span class="date-fixed-label">起始日</span>
<input type="date" name="start_date" id="archive_start_date" value="<?= e((string)($editing['start_date'] ?? '')) ?>">
</div>
<div class="date-labeled">
<span class="date-fixed-label">到期日</span>
<input type="date" name="end_date" id="loan_end_date" value="<?= e((string)($editing['end_date'] ?? '')) ?>">
</div>
</div>
<div class="form-row-2">
<div class="form-field-stack">
<label for="guarantee_type_input">担保类型</label>
<input name="guarantee_type" id="guarantee_type_input" placeholder="担保类型" value="<?= e((string)($editing['guarantee_type'] ?? '')) ?>">
</div>
<div class="form-field-stack">
<label for="guarantor_input">担保人</label>
<input name="guarantor" id="guarantor_input" placeholder="担保人" value="<?= e((string)($editing['guarantor'] ?? '')) ?>">
</div>
</div>
<div class="form-row-3">
<div class="form-field-stack">
<label for="annual_rate_input">年利率</label>
<input type="number" step="0.01" name="annual_rate" id="annual_rate_input" placeholder="年利率" value="<?= e((string)($editing['annual_rate'] ?? '')) ?>">
</div>
<div class="form-field-stack">
<label for="interest_mode_select">结息方式</label>
<select name="interest_mode" id="interest_mode_select">
<option value="" disabled <?= $financeCfg['interest_mode'] === '' ? 'selected' : '' ?> style="color:#9ca3af">请选择</option>
<option value="monthly" <?= $financeCfg['interest_mode'] === 'monthly' ? 'selected' : '' ?>>按月计息</option>
<option value="quarterly" <?= $financeCfg['interest_mode'] === 'quarterly' ? 'selected' : '' ?>>按季计息</option>
<option value="bullet" <?= $financeCfg['interest_mode'] === 'bullet' ? 'selected' : '' ?>>到期一次性付清</option>
</select>
</div>
<div class="form-field-stack">
<label for="settlement_day">结息日</label>
<input type="number" min="1" max="28" name="settlement_day" id="settlement_day" placeholder="1-28" value="<?= (int)$financeCfg['settlement_day'] ?>">
</div>
</div>
<?php
$endDateVal = (string)($editing['end_date'] ?? '');
$overdueSelectable = $endDateVal !== '' && $endDateVal < date('Y-m-d');
$earlySettleSelectable = $endDateVal !== '' && $endDateVal > date('Y-m-d');
?>
<div class="form-row-overdue">
<div class="form-field-stack">
<label for="is_overdue">是否逾期</label>
<select name="is_overdue" id="is_overdue" <?= $overdueSelectable ? '' : 'disabled' ?>>
<option value="0" <?= empty($financeCfg['manual_overdue']) ? 'selected' : '' ?>><?= $overdueSelectable ? '否' : '否(未超到期日不可选)' ?></option>
<option value="1" <?= !empty($financeCfg['manual_overdue']) ? 'selected' : '' ?>>是</option>
</select>
</div>
<div class="form-field-stack form-col-float">
<label for="overdue_float_pct">上浮%</label>
<input type="number" step="0.01" min="0" name="overdue_float_pct" id="overdue_float_pct" value="<?= e((string)$financeCfg['overdue_float_pct']) ?>" title="逾期利息上浮百分比">
</div>
<div class="form-field-stack">
<label for="plan_enabled">是否有分期还款计划</label>
<select name="plan_enabled" id="plan_enabled">
<option value="0" <?= empty($planCfg['enabled']) ? 'selected' : '' ?>>否</option>
<option value="1" <?= !empty($planCfg['enabled']) ? 'selected' : '' ?>>是</option>
</select>
</div>
</div>
<div class="form-row-2">
<div class="form-field-stack">
<label for="is_early_settled">是否提前还款</label>
<select name="is_early_settled" id="is_early_settled" <?= $earlySettleSelectable ? '' : 'disabled' ?>>
<option value="0" <?= empty($financeCfg['manual_early_settled']) ? 'selected' : '' ?>><?= $earlySettleSelectable ? '否' : '否(仅到期日前可选提前还款/标已完成)' ?></option>
<option value="1" <?= !empty($financeCfg['manual_early_settled']) ? 'selected' : '' ?>>是(状态设为已完成)</option>
</select>
</div>
</div>
<div class="full form-field-stack">
<label for="archive_remark">备注</label>
<textarea name="archive_remark" id="archive_remark" rows="3" placeholder="可填写补充说明、内部备忘等"><?= e((string)($financeCfg['remark'] ?? '')) ?></textarea>
</div>
<div class="full" id="plan_wrap" style="<?= empty($planCfg['enabled']) ? 'display:none' : '' ?>">
<table id="plan_table">
<tr><th>还款日期</th><th>还款金额</th><th>状态操作</th><th>延期日期</th><th>行操作</th></tr>
<?php foreach ($planRows as $row): ?>
<tr>
<td>
<input type="hidden" name="plan_id[]" value="<?= e((string)($row['id'] ?? '')) ?>">
<input type="date" name="plan_due_date[]" value="<?= e((string)($row['due_date'] ?? '')) ?>" onchange="syncPlanAction(this)">
</td>
<td><input type="number" step="0.01" name="plan_amount[]" value="<?= e((string)($row['amount'] ?? '')) ?>"></td>
<td>
<?php $st = (string)($row['status'] ?? 'pending'); ?>
<select name="plan_status[]" onchange="syncDelayInput(this)">
<option value="pending" <?= $st === 'pending' ? 'selected' : '' ?>>未还款</option>
<option value="paid" <?= $st === 'paid' ? 'selected' : '' ?>><?= (!empty($row['due_date']) && $row['due_date'] > date('Y-m-d')) ? '提前还款' : '按期还款' ?></option>
<option value="delayed" <?= $st === 'delayed' ? 'selected' : '' ?>>延期</option>
<option value="overdue" <?= $st === 'overdue' ? 'selected' : '' ?>>逾期</option>
</select>
</td>
<td><input type="date" name="plan_delay_date[]" value="" <?= $st === 'delayed' ? '' : 'disabled' ?>></td>
<td><button type="button" class="btn-danger" onclick="this.closest('tr').remove()">删除</button></td>
</tr>
<?php endforeach; ?>
</table>
<p><button type="button" class="btn-gray" onclick="addPlanRow()">增加一期</button></p>
<p class="small muted">说明:到期日前可选“提前还款”;到期日后可选“按期还款/延期/逾期”。逾期未归还计划本金按上浮利率计息,剩余额度按已还款计划与手动归还本金自动计算。</p>
</div>
<?php if ($editing): ?>
<div class="full" style="border-top:1px solid var(--line);padding-top:12px;margin-top:4px">
<h4 class="section-title" style="margin:0 0 10px;font-size:15px">贷款延期与归还</h4>
<div class="form-row-2">
<div class="form-field-stack">
<label for="loan_extend_to">延期新到期日(选填)</label>
<input type="date" name="loan_extend_to" id="loan_extend_to" value="">
<p class="small muted" style="margin:0">填写后保存为新的到期日,须不早于当前到期日与起始日。</p>
</div>
<?php if ($formKind === ARCHIVE_KIND_BANK_LOAN): ?>
<div class="form-field-stack">
<label>已累计归还本金 / 利息(元)</label>
<p class="small" style="margin:0"><?= e(amount((float)$financeCfg['manual_principal_repaid'])) ?> / <?= e(amount((float)$financeCfg['manual_interest_paid'])) ?></p>
</div>
<?php else: ?>
<div class="form-field-stack">
<label class="muted">对外担保</label>
<p class="small muted" style="margin:0">可仅使用延期;本金利息归还适用于银行贷款。</p>
</div>
<?php endif; ?>
</div>
<?php if ($formKind === ARCHIVE_KIND_BANK_LOAN): ?>
<div class="form-row-2">
<div class="form-field-stack">
<label for="principal_repayment_now">本次归还本金(元)</label>
<input type="number" step="0.01" min="0" name="principal_repayment_now" id="principal_repayment_now" placeholder="0" value="">
</div>
<div class="form-field-stack">
<label for="interest_repayment_now">本次归还利息(元)</label>
<input type="number" step="0.01" min="0" name="interest_repayment_now" id="interest_repayment_now" placeholder="0" value="">
</div>
</div>
<p class="small muted" style="margin:0">归还本金从剩余额度中扣减;归还利息计入「已归还利息」,减少当前应付与结息日应付利息。</p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$editing): ?>
<div class="full">
<label>上传文件(新建时可直接选择)</label>
<input type="file" name="new_files[]" multiple>
<p class="small muted">点击“创建”后会自动上传并在编辑页文件管理列表中显示。</p>
</div>
<?php endif; ?>
<div class="form-actions">
<button type="submit"><?= $editing ? '保存' : '创建' ?></button>
</div>
</form>
</div>
<?php if ($editArchiveId > 0): ?>
<div class="card" style="margin-top:12px">
<h3 class="section-title">文件管理(编辑页)</h3>
<form method="post" action="index.php?action=upload_file" enctype="multipart/form-data">
<input type="hidden" name="archive_id" value="<?= $editArchiveId ?>">
<input type="file" name="files[]" multiple required>
<button type="submit">上传并刷新列表</button>
</form>
<table style="margin-top:8px">
<tr><th>文件名</th><th>大小</th><th>重命名</th><th>操作</th></tr>
<?php foreach ($editFiles as $f): ?>
<tr>
<td><?= e((string)$f['original_name']) ?></td>
<td><?= number_format(((int)$f['file_size']) / 1024, 1) ?> KB</td>
<td>
<form method="post" action="index.php?action=rename_file" class="inline">
<input type="hidden" name="file_id" value="<?= (int)$f['id'] ?>">
<input name="new_name" value="<?= e((string)pathinfo((string)$f['original_name'], PATHINFO_FILENAME)) ?>" placeholder="新文件名" required>
<button class="btn-gray" type="submit">重命名</button>
</form>
</td>
<td>
<div class="icon-actions">
<?php if (canPreview((string)$f['file_ext'])): ?><a class="icon-btn" target="_blank" href="index.php?action=preview_file&id=<?= (int)$f['id'] ?>">👁 预览</a><?php endif; ?>
<a class="icon-btn" href="index.php?action=download_file&id=<?= (int)$f['id'] ?>">⬇ 下载</a>
<a class="icon-btn danger" href="index.php?action=delete_file&id=<?= (int)$f['id'] ?>" onclick="return confirm('确认删除?')">🗑 删除</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($editFiles)): ?>
<tr><td colspan="4" class="muted">暂无文件</td></tr>
<?php endif; ?>
</table>
</div>
<?php endif; ?>
<script>
(function(){
var sel=document.getElementById('plan_enabled');
var wrap=document.getElementById('plan_wrap');
function setPlanVisibility(){
if(sel&&wrap){wrap.style.display=sel.value==='1'?'block':'none';}
}
if(sel&&wrap){
sel.addEventListener('change',setPlanVisibility);
}
setPlanVisibility();
var archiveKind=document.getElementById('archive_kind');
var loanInstLabel=document.getElementById('loan_institution_label');
var dirHint=document.getElementById('archive_auto_dir_hint');
function syncArchiveKindUi(){
if(!dirHint){return;}
if(archiveKind&&archiveKind.value==='external_guarantee'){
dirHint.textContent='未手动选择目录时:将按「对外担保 / 起始日年份 / 担保人」创建或归入目录(三级目录为担保人名称,非借款主体)。下方「债权人」为选填。';
if(loanInstLabel){loanInstLabel.textContent='债权人(选填)';}
}else{
dirHint.textContent='未手动选择目录时:将按「银行贷款 / 起始日年份 / 贷款机构」创建或归入目录。';
if(loanInstLabel){loanInstLabel.textContent='贷款机构';}
}
syncAutoArchiveName();
}
if(archiveKind){archiveKind.addEventListener('change',syncArchiveKindUi);}
syncArchiveKindUi();
document.querySelectorAll('input[name="plan_due_date[]"]').forEach(function(x){syncPlanAction(x);});
document.querySelectorAll('select[name="plan_status[]"]').forEach(function(x){syncDelayInput(x);});
var interestMode=document.querySelector('select[name="interest_mode"]');
var settlementDay=document.getElementById('settlement_day');
var borrowerInput=document.getElementById('borrower_input');
var creditInput=document.getElementById('credit_limit_input');
var archiveNameAuto=document.getElementById('archive_name_auto');
var guarantorInput=document.getElementById('guarantor_input');
var institutionSelect=document.getElementById('loan_institution_select');
var institutionsJson=document.getElementById('loan_institutions_json');
var loanEndDate=document.getElementById('loan_end_date');
var isOverdue=document.getElementById('is_overdue');
var isEarlySettled=document.getElementById('is_early_settled');
var overduePct=document.getElementById('overdue_float_pct');
function syncInterestMode(){
if(!interestMode||!settlementDay){return;}
if(interestMode.value==='bullet'){
settlementDay.value='';
settlementDay.disabled=true;
settlementDay.placeholder='到期一次性付息';
}else{
settlementDay.disabled=false;
if(!settlementDay.value){settlementDay.value='21';}
settlementDay.placeholder=interestMode.value==='quarterly'?'每季几号(1-28':'每月几号(1-28';
}
}
if(interestMode){interestMode.addEventListener('change',syncInterestMode);}
syncInterestMode();
function syncOverdueAvailability(){
if(!loanEndDate||!isOverdue){return;}
var endDate=loanEndDate.value;
var today=new Date().toISOString().slice(0,10);
var canSelect=(endDate && endDate<today);
if(!canSelect){
isOverdue.value='0';
isOverdue.disabled=true;
if(isOverdue.options.length>0){isOverdue.options[0].text='否(未超到期日不可选)';}
}else{
isOverdue.disabled=false;
if(isOverdue.options.length>0 && isOverdue.options[0].value==='0'){isOverdue.options[0].text='否';}
}
}
if(loanEndDate){loanEndDate.addEventListener('change',syncOverdueAvailability);}
syncOverdueAvailability();
function syncEarlySettleAvailability(){
if(!loanEndDate||!isEarlySettled){return;}
var endDate=loanEndDate.value;
var today=new Date().toISOString().slice(0,10);
var canSelect=(endDate && endDate>today);
if(!canSelect){
isEarlySettled.value='0';
isEarlySettled.disabled=true;
if(isEarlySettled.options.length>0){isEarlySettled.options[0].text='否(仅到期日前可选)';}
}else{
isEarlySettled.disabled=false;
if(isEarlySettled.options.length>0 && isEarlySettled.options[0].value==='0'){isEarlySettled.options[0].text='否';}
}
}
function syncMutualStatusSelection(){
if(!isOverdue||!isEarlySettled){return;}
if(isEarlySettled.value==='1'){
isOverdue.value='0';
isOverdue.disabled=true;
}else{
syncOverdueAvailability();
}
}
if(loanEndDate){loanEndDate.addEventListener('change',syncEarlySettleAvailability);}
if(isEarlySettled){isEarlySettled.addEventListener('change',syncMutualStatusSelection);}
syncEarlySettleAvailability();
syncMutualStatusSelection();
function syncAutoArchiveName(){
if(!creditInput||!archiveNameAuto||!archiveKind){return;}
var c=parseFloat(creditInput.value||'');
if(isNaN(c)){ archiveNameAuto.value=''; return; }
var wan=(c/10000).toFixed(2).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1');
if(archiveKind.value==='external_guarantee'){
if(!guarantorInput){return;}
var g=(guarantorInput.value||'').trim();
if(!g){ archiveNameAuto.value=''; return; }
archiveNameAuto.value=g+'对外担保'+wan+'万元';
}else{
if(!borrowerInput){return;}
var b=(borrowerInput.value||'').trim();
if(!b){ archiveNameAuto.value=''; return; }
archiveNameAuto.value=b+wan+'万元';
}
}
if(borrowerInput){borrowerInput.addEventListener('input',syncAutoArchiveName);}
if(guarantorInput){guarantorInput.addEventListener('input',syncAutoArchiveName);}
if(creditInput){creditInput.addEventListener('input',syncAutoArchiveName);}
syncAutoArchiveName();
function syncInstitutionsJson(){
if(!institutionSelect||!institutionsJson){return;}
var arr=[...institutionSelect.options].map(function(o){return o.value;}).filter(function(v){return v!=='';});
institutionsJson.value=JSON.stringify(arr);
}
window.addInstitutionOption=function(){
if(!institutionSelect){return;}
var v=prompt('请输入贷款机构名称');
if(!v){return;}
v=v.trim();
if(!v){return;}
var exists=[...institutionSelect.options].some(function(o){return o.value===v;});
if(!exists){
var op=document.createElement('option');
op.value=v;op.textContent=v;
institutionSelect.appendChild(op);
}
institutionSelect.value=v;
syncInstitutionsJson();
};
window.removeInstitutionOption=function(){
if(!institutionSelect){return;}
var v=institutionSelect.value;
if(!v){return;}
if(!confirm('确认删除当前机构选项?')){return;}
[...institutionSelect.options].forEach(function(o){if(o.value===v){o.remove();}});
institutionSelect.value='';
syncInstitutionsJson();
};
if(institutionSelect){institutionSelect.addEventListener('change',syncInstitutionsJson);}
syncInstitutionsJson();
})();
function syncPlanAction(el){
var tr=el.closest('tr'); if(!tr){return;}
var sel=tr.querySelector('select[name="plan_status[]"]');
if(!sel){return;}
var due=el.value;
var today=new Date().toISOString().slice(0,10);
var paidOpt=sel.querySelector('option[value="paid"]');
if(paidOpt){paidOpt.textContent=(due && due>today)?'提前还款':'按期还款';}
var delayedOpt=sel.querySelector('option[value="delayed"]');
var overdueOpt=sel.querySelector('option[value="overdue"]');
var canAfterDue=(due && due<=today);
if(delayedOpt){
delayedOpt.disabled=!canAfterDue;
if(!canAfterDue && sel.value==='delayed'){sel.value='pending';}
}
if(overdueOpt){
overdueOpt.disabled=!canAfterDue;
if(!canAfterDue && sel.value==='overdue'){sel.value='pending';}
}
syncDelayInput(sel);
}
function syncDelayInput(el){
var tr=el.closest('tr'); if(!tr){return;}
var d=tr.querySelector('input[name="plan_delay_date[]"]');
if(!d){return;}
d.disabled=(el.value!=='delayed');
if(d.disabled){d.value='';}
}
function addPlanRow(){
var tb=document.getElementById('plan_table');
if(!tb){return;}
var tr=document.createElement('tr');
tr.innerHTML='<td><input type="hidden" name="plan_id[]" value=""><input type="date" name="plan_due_date[]" onchange="syncPlanAction(this)"></td><td><input type="number" step="0.01" name="plan_amount[]"></td><td><select name="plan_status[]" onchange="syncDelayInput(this)"><option value="pending">未还款</option><option value="paid">提前还款</option><option value="delayed">延期</option><option value="overdue">逾期</option></select></td><td><input type="date" name="plan_delay_date[]" disabled></td><td><button type="button" class="btn-danger" onclick="this.closest(\'tr\').remove()">删除</button></td>';
tb.appendChild(tr);
}
</script>
<?php pageFooter(); exit;
}
if ($action === 'users') {
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$do = $_POST['do'] ?? '';
if ($do === 'create') {
$u = trim((string)$_POST['username']);
$p = (string)$_POST['password'];
if (!preg_match('/^[a-zA-Z0-9_]{4,20}$/', $u) || strlen($p) < 6 || strlen($p) > 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); ?>
<div class="card">
<h3>用户管理</h3>
<form method="post" style="margin-bottom:12px">
<input type="hidden" name="do" value="create">
<input name="username" placeholder="用户名(4-20位字母数字下划线)" required>
<input name="password" placeholder="初始密码(6-20位)" required>
<button type="submit">创建用户</button>
</form>
<table>
<tr><th>ID</th><th>用户名</th><th>角色</th><th>创建时间</th><th>操作</th></tr>
<?php foreach ($users as $u): ?>
<tr>
<td><?= (int)$u['id'] ?></td><td><?= e(maskUsername((string)$u['username'])) ?></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="do" value="role">
<input type="hidden" name="uid" value="<?= (int)$u['id'] ?>">
<select name="role" onchange="this.form.submit()">
<option value="user" <?= $u['role'] === 'user' ? 'selected' : '' ?>>普通用户</option>
<option value="admin" <?= $u['role'] === 'admin' ? 'selected' : '' ?>>管理员</option>
</select>
</form>
</td>
<td><?= e($u['created_at']) ?></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="do" value="reset">
<input type="hidden" name="uid" value="<?= (int)$u['id'] ?>">
<input name="new_password" placeholder="新密码" required>
<button class="btn-gray" type="submit">重置密码</button>
</form>
<form method="post" class="inline" onsubmit="return confirm('确认删除用户?')">
<input type="hidden" name="do" value="delete">
<input type="hidden" name="uid" value="<?= (int)$u['id'] ?>">
<button class="btn-danger" type="submit">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php pageFooter(); exit;
}
if ($action === 'profile') {
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$cur = (string)($_POST['current_password'] ?? '');
$new = (string)($_POST['new_password'] ?? '');
$confirm = (string)($_POST['confirm_password'] ?? '');
$stmt = $pdo->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); ?>
<div class="card">
<h3>个人设置 - 修改密码</h3>
<form method="post">
<p><input type="password" name="current_password" placeholder="当前密码" required></p>
<p><input type="password" name="new_password" placeholder="新密码(6-20位)" required></p>
<p><input type="password" name="confirm_password" placeholder="确认新密码" required></p>
<button type="submit">保存修改</button>
<a href="index.php">返回</a>
</form>
</div>
<?php pageFooter(); exit;
}
if ($action === 'quick_queries') {
requireLogin();
pageHeader($me); ?>
<div class="card fin-tool-page">
<h3 class="section-title">便捷查询</h3>
<?= quickQueryMenuHtml() ?>
<p class="small muted" style="margin-top:12px">应付本息:统计<strong>履行中及逾期中</strong>银行贷款按机构当前应付利息、本结息周期整段结息日应付利息,以及当月还款计划金额。</p>
<p class="small muted">对外担保:按主体名称检索“该主体作为担保人”的<strong>履行中及逾期中</strong>银行贷款与对外担保档案。</p>
<p class="small muted">查询总金额:按选项汇总<strong>履行中及逾期中</strong>档案的授信总额与剩余额度合计。</p>
<p class="small muted">利息计算器 / 承兑贴现计算器:本地浏览器计算,结果仅供参考。</p>
<p class="small muted">历史还款记录:汇总分期计划中「已还款」本金及状态为「已完成」档案的授信本金规模。</p>
<p style="margin-top:12px"><a class="clear-link" href="index.php">返回首页</a></p>
</div>
<?php pageFooter(); exit;
}
if ($action === 'repayment_history') {
requireLogin();
$rowsAll = $pdo->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); ?>
<div class="card fin-tool-page">
<h3 class="section-title">历史还款记录查询</h3>
<div class="quick-links-col" style="margin-bottom:14px">
<a class="tree-link" href="index.php?action=quick_queries">← 返回便捷查询</a>
</div>
<div class="fin-panel" style="margin-bottom:14px">
<h4 style="margin:0 0 8px">汇总</h4>
<table>
<tr><th>项目</th><th>笔数</th><th>金额合计(元)</th></tr>
<tr>
<td>分期还款计划中「已还款」本金</td>
<td><?= count($planRows) ?></td>
<td><strong><?= e(amount((float)$hr['plan_paid_total'])) ?></strong></td>
</tr>
<tr>
<td>已完成 — 银行贷款(授信本金规模)</td>
<td><?= (int)$cb['count'] ?></td>
<td><strong><?= e(amount((float)$cb['sum_credit'])) ?></strong></td>
</tr>
<tr>
<td>已完成 — 对外担保(授信本金规模)</td>
<td><?= (int)$cg['count'] ?></td>
<td><strong><?= e(amount((float)$cg['sum_credit'])) ?></strong></td>
</tr>
</table>
<p class="fin-muted" style="margin:10px 0 0">「已完成」指合同状态为已完成;授信本金规模为档案授信额度合计,用于对应到期/结清贷款本金规模参考。分期已还与整笔结清可能同时存在,请勿简单相加避免重复理解。</p>
</div>
<h4 class="section-title" style="font-size:16px">分期计划已还款明细</h4>
<div class="table-wrap">
<table>
<tr><th>档案ID</th><th>档案名称</th><th>机构</th><th>借款主体</th><th>计划到期日</th><th>标记还款日</th><th>还款金额</th><th>操作</th></tr>
<?php foreach ($planRows as $pr): ?>
<tr>
<td><?= (int)$pr['archive_id'] ?></td>
<td><?= e((string)$pr['archive_name']) ?></td>
<td><?= e((string)$pr['loan_institution']) ?></td>
<td><?= e((string)$pr['borrower']) ?></td>
<td><?= e((string)$pr['due_date']) ?></td>
<td><?= e((string)$pr['paid_at']) ?></td>
<td><?= e(amount((float)$pr['amount'])) ?></td>
<td><?php if ((int)$pr['archive_id'] > 0): ?><a class="icon-btn" href="index.php?action=archive_detail&id=<?= (int)$pr['archive_id'] ?>">查看</a><?php else: ?>—<?php endif; ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($planRows)): ?><tr><td colspan="8" class="muted">暂无已标记还款的分期计划</td></tr><?php endif; ?>
</table>
</div>
</div>
<?php pageFooter(); exit;
}
if ($action === 'interest_calculator') {
requireLogin();
pageHeader($me);
require __DIR__ . '/../app/views/interest_calculator.php';
pageFooter();
exit;
}
if ($action === 'acceptance_discount_calculator') {
requireLogin();
pageHeader($me);
require __DIR__ . '/../app/views/acceptance_discount_calculator.php';
pageFooter();
exit;
}
if ($action === 'query_totals') {
requireLogin();
$scope = (string)($_GET['scope'] ?? 'all_bank');
$inst = trim((string)($_GET['inst'] ?? ''));
$allowedScopes = ['all_bank', 'all_guar', 'bank_inst', 'guar_inst'];
if (!in_array($scope, $allowedScopes, true)) {
$scope = 'all_bank';
}
$rowsAll = $pdo->query('SELECT * FROM archives ORDER BY id DESC')->fetchAll();
$financeAll = loadFinanceConfigs();
$tot = qqBuildTotalsQuery($rowsAll, $financeAll, $scope, $inst !== '' ? $inst : null);
$loanInstitutions = loadLoanInstitutions();
pageHeader($me); ?>
<div class="card">
<h3 class="section-title">查询总金额</h3>
<div class="quick-links-col" style="margin-bottom:12px">
<a class="tree-link" href="index.php?action=quick_queries">← 返回便捷查询</a>
</div>
<form method="get" class="toolbar-left" style="flex-wrap:wrap;gap:10px;align-items:flex-end">
<input type="hidden" name="action" value="query_totals">
<div class="form-field-stack" style="min-width:200px">
<label for="qt_scope">统计范围</label>
<select name="scope" id="qt_scope">
<option value="all_bank" <?= $scope === 'all_bank' ? 'selected' : '' ?>>履行中及逾期中 — 全部银行贷款</option>
<option value="all_guar" <?= $scope === 'all_guar' ? 'selected' : '' ?>>履行中及逾期中 — 全部对外担保</option>
<option value="bank_inst" <?= $scope === 'bank_inst' ? 'selected' : '' ?>>履行中及逾期中 — 指定机构的银行贷款</option>
<option value="guar_inst" <?= $scope === 'guar_inst' ? 'selected' : '' ?>>履行中及逾期中 — 指定机构的对外担保</option>
</select>
</div>
<div class="form-field-stack" id="qt_inst_wrap" style="min-width:220px;<?= ($scope === 'bank_inst' || $scope === 'guar_inst') ? '' : 'opacity:.45' ?>">
<label for="qt_inst">机构名称(后两项必填)</label>
<input name="inst" id="qt_inst" list="qt_inst_list" value="<?= e($inst) ?>" placeholder="与档案中贷款机构/债权人一致">
<datalist id="qt_inst_list">
<?php foreach ($loanInstitutions as $li): ?><option value="<?= e($li) ?>"><?php endforeach; ?>
</datalist>
</div>
<button type="submit">查询</button>
</form>
<script>
(function(){
var sel=document.getElementById('qt_scope');
var wrap=document.getElementById('qt_inst_wrap');
function sync(){if(!sel||!wrap)return;var need=sel.value==='bank_inst'||sel.value==='guar_inst';wrap.style.opacity=need?'1':'.45';}
if(sel){sel.addEventListener('change',sync);}sync();
})();
</script>
</div>
<div class="card" style="margin-top:12px">
<h3 class="section-title">汇总结果</h3>
<table>
<tr><th>笔数(履行+逾期)</th><th>授信总额合计</th><th>剩余额度合计</th></tr>
<tr>
<td><?= (int)($tot['count'] ?? 0) ?></td>
<td><?= e(amount((float)($tot['sum_credit'] ?? 0))) ?></td>
<td><?= e(amount((float)($tot['sum_remaining'] ?? 0))) ?></td>
</tr>
</table>
<p class="small muted" style="margin-top:10px">统计范围含状态为「履行中」与「逾期中」的档案。授信总额、剩余额度均来自各档案字段;剩余额度含分期已还与手动归还本金后的结果。</p>
</div>
<?php pageFooter(); exit;
}
if ($action === 'query_payable_interest') {
requireLogin();
$exportFormat = (string)($_GET['format'] ?? '');
$rowsAll = $pdo->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); ?>
<div class="card">
<h3 class="section-title">应付本息查询</h3>
<div class="quick-links-col" style="margin-bottom:10px">
<a class="tree-link" href="index.php?action=quick_queries">← 返回便捷查询</a>
<a class="tree-link" href="index.php?action=query_totals">查询总金额</a>
<a class="toolbar-btn" href="index.php?action=query_payable_interest&format=xlsx" style="margin-top:4px">导出xlsx</a>
</div>
</div>
<div class="card" style="margin-top:12px">
<h3 class="section-title">一、银行贷款机构利息统计(履行中及逾期中)</h3>
<table>
<tr><th>机构</th><th>笔数</th><th>当前应付利息</th><th>结息日</th><th title="本结息周期整段应计,非自今日至结息日">结息日应付利息</th></tr>
<?php foreach ($instRows as $r): ?>
<tr>
<td><?= e((string)$r['loan_institution']) ?></td>
<td><?= (int)$r['loan_count'] ?></td>
<td><?= e(amount((float)$r['current_interest'])) ?></td>
<td><?= e((string)($r['settlement_date'] ?? '')) ?></td>
<td><?= e(amount((float)$r['settlement_interest'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (!empty($instRows)): ?>
<?php
$sumCur = 0.0;
$sumSettle = 0.0;
foreach ($instRows as $x) {
$sumCur += (float)$x['current_interest'];
$sumSettle += (float)$x['settlement_interest'];
}
?>
<tr><td><strong>合计</strong></td><td>-</td><td><strong><?= e(amount($sumCur)) ?></strong></td><td>-</td><td><strong><?= e(amount($sumSettle)) ?></strong></td></tr>
<?php else: ?><tr><td colspan="5" class="muted">暂无数据</td></tr><?php endif; ?>
</table>
</div>
<div class="card" style="margin-top:12px">
<h3 class="section-title">二、当月还款计划金额</h3>
<div class="table-wrap" style="margin-top:10px">
<table>
<tr><th>机构</th><th>档案名称</th><th>借款主体</th><th>到期日</th><th>金额</th><th>状态</th></tr>
<?php foreach ($planRows as $r): ?>
<tr>
<td><?= e((string)$r['loan_institution']) ?></td>
<td><?= e((string)$r['archive_name']) ?></td>
<td><?= e((string)$r['borrower']) ?></td>
<td><?= e((string)$r['due_date']) ?></td>
<td><?= e(amount((float)$r['amount'])) ?></td>
<td><?= e((string)$r['status']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (!empty($planRows)): ?><tr><td><strong>合计</strong></td><td colspan="3">-</td><td><strong><?= e(amount($planTotal)) ?></strong></td><td>-</td></tr><?php endif; ?>
<?php if (empty($planRows)): ?><tr><td colspan="6" class="muted">暂无当月还款计划</td></tr><?php endif; ?>
</table>
</div>
</div>
<?php pageFooter(); exit;
}
if ($action === 'query_external_guarantee') {
requireLogin();
$kw = trim((string)($_GET['kw'] ?? ''));
$format = (string)($_GET['format'] ?? '');
$rows = $pdo->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); ?>
<div class="card">
<h3 class="section-title">对外担保查询</h3>
<div class="quick-links-col" style="margin-bottom:10px">
<a class="tree-link" href="index.php?action=quick_queries">← 返回便捷查询</a>
<a class="tree-link" href="index.php?action=query_totals">查询总金额</a>
</div>
<form method="get" class="toolbar-left">
<input type="hidden" name="action" value="query_external_guarantee">
<input name="kw" value="<?= e($kw) ?>" placeholder="输入主体名称(匹配担保人)" required>
<button type="submit">查询</button>
<?php if ($kw !== ''): ?><a class="toolbar-btn" href="index.php?action=query_external_guarantee&kw=<?= urlencode($kw) ?>&format=xlsx">导出xlsx</a><?php endif; ?>
</form>
<p class="small muted" style="margin-top:8px">仅列出状态为「履行中」或「逾期中」的档案。</p>
</div>
<div class="card" style="margin-top:12px">
<div class="table-wrap"><table>
<tr><th>档案类型</th><th>档案名称</th><th>贷款机构/债权人</th><th>借款主体</th><th>担保人</th><th>授信额度</th><th>剩余额度</th><th>到期日</th><th>状态</th></tr>
<?php foreach ($resultRows as $r): ?>
<?php $st = archiveStatusWithConfig((string)($r['end_date'] ?? ''), getArchiveFinanceConfig($financeAll, (int)$r['id'])); ?>
<tr>
<td><?= e(archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? '')))) ?></td>
<td><?= e((string)($r['name'] ?? '')) ?></td>
<td><?= e((string)($r['loan_institution'] ?? '')) ?></td>
<td><?= e((string)($r['borrower'] ?? '')) ?></td>
<td><?= e((string)($r['guarantor'] ?? '')) ?></td>
<td><?= e(amount($r['credit_limit'] !== null ? (float)$r['credit_limit'] : null)) ?></td>
<td><?= e(amount($r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null)) ?></td>
<td><?= e((string)($r['end_date'] ?? '')) ?></td>
<td><?= e($st) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($resultRows)): ?><tr><td colspan="9" class="muted"><?= $kw === '' ? '请输入主体名称后查询' : '未命中履行中或逾期中的相关档案(已完成等已排除)' ?></td></tr><?php endif; ?>
</table></div>
</div>
<?php pageFooter(); exit;
}
if ($action === 'export') {
requireLogin();
$mode = $_GET['mode'] ?? 'all';
$format = $_GET['format'] ?? 'xlsx';
$kindFilter = (string)($_GET['kind'] ?? 'bank_loan');
if (!in_array($kindFilter, ['all', ARCHIVE_KIND_BANK_LOAN, ARCHIVE_KIND_EXTERNAL_GUARANTEE], true)) {
$kindFilter = ARCHIVE_KIND_BANK_LOAN;
}
$selectedRaw = $_GET['ids'] ?? [];
if (!is_array($selectedRaw)) {
$selectedRaw = explode(',', (string)$selectedRaw);
}
$selected = array_values(array_filter(array_map('intval', $selectedRaw)));
$dirId = (int)($_GET['dir_id'] ?? 0);
$dirs = fetchDirectories($pdo);
$dirFilter = [];
if ($dirId > 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); ?>
<div class="card">
<h3 class="section-title">待更新详情</h3>
<p class="small muted">以下为已过到期日且尚未在系统中标记结果的贷款与还款计划。处理后将从本列表移除。</p>
<p style="margin:10px 0"><a class="clear-link" href="<?= e($puBack) ?>">← 返回首页</a></p>
<div class="table-wrap"><table>
<thead><tr><th>类型</th><th>贷款机构</th><th>借款主体</th><th>担保人</th><th>金额</th><th>日期</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($pendingUpdateRows as $r): ?>
<tr>
<td><span class="badge <?= ($r['row_kind'] ?? '') === 'loan' ? 'b1' : 'b2' ?>"><?= e((string)($r['type_label'] ?? '')) ?></span></td>
<td><?= e((string)($r['loan_institution'] ?? '')) ?></td>
<td><?= e((string)($r['borrower'] ?? '')) ?></td>
<td><?= e((string)($r['guarantor'] ?? '')) ?></td>
<td><?= e(amount(isset($r['amount']) && $r['amount'] !== null ? (float)$r['amount'] : null)) ?></td>
<td><?= e((string)($r['date'] ?? '')) ?></td>
<td>
<a class="icon-btn" href="index.php?action=archive_detail&id=<?= (int)($r['archive_id'] ?? 0) ?>">查看档案</a>
<?php if (isAdmin()): ?>
<?php if (($r['row_kind'] ?? '') === 'loan'): ?>
<form method="post" action="index.php?action=pending_update_action" class="inline" style="display:inline" onsubmit="return confirm('确认标记为已还款?状态将改为已完成。')">
<input type="hidden" name="op" value="pending_loan_repaid">
<input type="hidden" name="archive_id" value="<?= (int)($r['archive_id'] ?? 0) ?>">
<?php if ($dirId > 0): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">已还款</button>
</form>
<form method="post" action="index.php?action=pending_update_action" class="inline" style="display:inline" onsubmit="return confirm('确认标记为已逾期?状态将改为逾期中。')">
<input type="hidden" name="op" value="pending_loan_overdue">
<input type="hidden" name="archive_id" value="<?= (int)($r['archive_id'] ?? 0) ?>">
<?php if ($dirId > 0): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">已逾期</button>
</form>
<?php else: ?>
<form method="post" action="index.php?action=pending_update_action" class="inline" style="display:inline" onsubmit="return confirm('确认该期已还款?状态将改为按期还款(已还款)。')">
<input type="hidden" name="op" value="pending_plan_repaid">
<input type="hidden" name="archive_id" value="<?= (int)($r['archive_id'] ?? 0) ?>">
<input type="hidden" name="plan_id" value="<?= e((string)($r['plan_id'] ?? '')) ?>">
<?php if ($dirId > 0): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">已还款</button>
</form>
<form method="post" action="index.php?action=pending_update_action" class="inline" style="display:inline" onsubmit="return confirm('确认该期已逾期?状态将改为逾期中。')">
<input type="hidden" name="op" value="pending_plan_overdue">
<input type="hidden" name="archive_id" value="<?= (int)($r['archive_id'] ?? 0) ?>">
<input type="hidden" name="plan_id" value="<?= e((string)($r['plan_id'] ?? '')) ?>">
<?php if ($dirId > 0): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">已逾期</button>
</form>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($pendingUpdateRows)): ?>
<tr><td colspan="7" class="muted">暂无待更新项</td></tr>
<?php endif; ?>
</tbody>
</table></div>
</div>
<?php pageFooter();
exit;
}
if ($action === 'reminders') {
requireLogin();
$reminderType = (string)($_GET['type'] ?? 'due');
$exportFormat = (string)($_GET['format'] ?? '');
$allReminderRows = [];
foreach ($dueSoonLoans as $r) {
$allReminderRows[] = ['type' => '贷款到期(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); ?>
<div class="card">
<h3 class="section-title">到期提醒详情</h3>
<form method="get" class="toolbar-left" style="margin-bottom:10px">
<input type="hidden" name="action" value="reminders">
<select name="type">
<option value="all" <?= $reminderType === 'all' ? 'selected' : '' ?>>全部类型</option>
<option value="due" <?= $reminderType === 'due' ? 'selected' : '' ?>>贷款到期</option>
<option value="plan" <?= $reminderType === 'plan' ? 'selected' : '' ?>>还款计划</option>
<option value="overdue" <?= $reminderType === 'overdue' ? 'selected' : '' ?>>贷款逾期</option>
</select>
<button type="submit">筛选</button>
<a class="clear-link" href="index.php?action=reminders&type=due">清空</a>
<button type="submit" name="format" value="xlsx" class="toolbar-btn">导出xlsx</button>
<button type="submit" name="format" value="csv" class="toolbar-btn">导出csv</button>
</form>
<div class="table-wrap"><table>
<thead><tr><th>类型</th><th>档案类型</th><th>贷款机构</th><th>借款主体</th><th>金额</th><th>日期</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><span class="badge <?= $r['type'] === '贷款到期(60天)' ? 'b1' : ($r['type'] === '还款计划(30天)' ? 'b2' : 'b4') ?>"><?= e($r['type']) ?></span></td>
<td><?= e((string)($r['archive_kind'] ?? '')) ?></td>
<td><?= e($r['loan_institution']) ?></td>
<td><?= e($r['borrower']) ?></td>
<td><?= e($r['amount']) ?></td>
<td><?= e($r['date']) ?></td>
<td>
<?php if ((int)($r['archive_id'] ?? 0) > 0): ?>
<a class="icon-btn" href="index.php?action=archive_detail&id=<?= (int)$r['archive_id'] ?>">查看档案</a>
<?php endif; ?>
<?php if ((int)($r['archive_id'] ?? 0) > 0 && $r['type'] === '贷款到期(60天)' && isAdmin()): ?>
<form method="post" action="index.php?action=reminder_action" class="inline" onsubmit="return confirm('确认设置该档案为提前还款并标记已完成?')">
<input type="hidden" name="op" value="early_settle">
<input type="hidden" name="archive_id" value="<?= (int)$r['archive_id'] ?>">
<input type="hidden" name="redirect_type" value="<?= e($reminderType) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">提前还款</button>
</form>
<?php endif; ?>
<?php if ((int)($r['archive_id'] ?? 0) > 0 && $r['type'] === '还款计划(30天)' && isAdmin()): ?>
<form method="post" action="index.php?action=reminder_action" class="inline">
<input type="hidden" name="op" value="plan_delay">
<input type="hidden" name="archive_id" value="<?= (int)$r['archive_id'] ?>">
<input type="hidden" name="plan_id" value="<?= e((string)($r['plan_id'] ?? '')) ?>">
<input type="hidden" name="redirect_type" value="<?= e($reminderType) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<input type="date" name="new_due_date" required style="max-width:140px">
<button type="submit" class="btn-gray">延期</button>
</form>
<?php endif; ?>
<?php if ((int)($r['archive_id'] ?? 0) > 0 && $r['type'] === '逾期中' && isAdmin()): ?>
<form method="post" action="index.php?action=reminder_action" class="inline" onsubmit="return confirm('确认将该逾期项标记为已归还?')">
<input type="hidden" name="op" value="overdue_paid">
<input type="hidden" name="archive_id" value="<?= (int)$r['archive_id'] ?>">
<input type="hidden" name="plan_id" value="<?= e((string)($r['plan_id'] ?? '')) ?>">
<input type="hidden" name="redirect_type" value="<?= e($reminderType) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn-gray">已归还</button>
</form>
<?php endif; ?>
<?php if ((int)($r['archive_id'] ?? 0) <= 0): ?>
<span class="muted">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="7" class="muted">暂无提醒内容</td></tr>
<?php endif; ?>
</tbody>
</table></div>
</div>
<?php pageFooter(); exit;
}
$perPage = (int)($_GET['per_page'] ?? 10);
if (!in_array($perPage, [10, 15, 30, 50], true)) {
$perPage = 10;
}
$currentPage = max(1, (int)($_GET['page'] ?? 1));
$totalPages = max(1, (int)ceil($totalCount / $perPage));
if ($currentPage > $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);
?>
<div class="grid">
<div class="card tree">
<div class="toolbar" style="margin-bottom:8px">
<h3 class="section-title" style="margin:0">目录结构</h3>
<?php if (isAdmin()): ?><a class="toolbar-btn" href="index.php?action=new_dir"> 一级目录</a><?php endif; ?>
</div>
<?= buildTreeHtml($dirs, $dirId ?: null, isAdmin(), $pathIds) ?>
<div style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
<h3 class="section-title" style="margin:0 0 8px 0">便捷查询</h3>
<?= quickQueryMenuHtml() ?>
</div>
</div>
<div>
<div class="metric-wrap">
<div class="metric m-all"><div class="k">当前结果总数</div><div class="v"><?= $totalCount ?></div></div>
<div class="metric m-active"><div class="k">履行中</div><div class="v"><?= $activeCount ?></div></div>
<div class="metric m-done"><div class="k">已完成</div><div class="v"><?= $doneCount ?></div></div>
<a class="metric m-unset subtle-link" href="index.php?action=pending_updates<?= $dirId ? '&dir_id=' . $dirId : '' ?>"><div class="k">待更新</div><div class="v"><?= $pendingUpdateCount ?></div></a>
<a class="metric m-due subtle-link" href="index.php?action=reminders&type=due"><div class="k">贷款到期</div><div class="v"><?= $dueSoonCount ?></div></a>
<a class="metric m-plan subtle-link" href="index.php?action=reminders&type=plan"><div class="k">还款计划</div><div class="v"><?= $repaySoonCount ?></div></a>
<a class="metric m-overdue subtle-link" href="index.php?action=reminders&type=overdue"><div class="k">贷款逾期</div><div class="v"><?= $overdueCount ?></div></a>
</div>
<div class="card" style="margin-top:12px">
<div class="toolbar">
<h3 class="section-title" style="margin:0">档案列表</h3>
<div class="toolbar-right">
<?php if ($dirId): ?><span class="pill">当前目录筛选中</span><?php endif; ?>
<?php if (isAdmin()): ?>
<a class="toolbar-btn primary" href="index.php?action=archive_form<?= $dirId ? '&dir_id=' . $dirId : '' ?>"> 新建档案</a>
<?php endif; ?>
<?php if (isAdmin()): ?>
<form method="post" action="index.php?action=import_archives" enctype="multipart/form-data" class="inline">
<input type="file" name="import_file" required>
<button type="submit" class="toolbar-btn">导入模板</button>
</form>
<a class="toolbar-btn" href="index.php?action=download_import_template">模板下载</a>
<?php endif; ?>
<form method="get" action="index.php" class="inline">
<input type="hidden" name="action" value="export">
<input type="hidden" name="mode" value="all">
<?php if ($dirId): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="kind" value="<?= e($kindFilter) ?>">
<select name="format">
<option value="xlsx">xlsx</option>
<option value="csv">csv</option>
</select>
<button type="submit" class="toolbar-btn">⭳ 导出全部</button>
</form>
</div>
</div>
<form method="get" style="margin-bottom:10px">
<div class="toolbar-left">
<?php if ($dirId): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<select name="kind">
<option value="<?= e(ARCHIVE_KIND_BANK_LOAN) ?>" <?= $kindFilter === ARCHIVE_KIND_BANK_LOAN ? 'selected' : '' ?>>仅银行贷款</option>
<option value="<?= e(ARCHIVE_KIND_EXTERNAL_GUARANTEE) ?>" <?= $kindFilter === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? 'selected' : '' ?>>仅对外担保</option>
<option value="all" <?= $kindFilter === 'all' ? 'selected' : '' ?>>全部类型</option>
</select>
<select name="search_field">
<option value="all" <?= $searchField === 'all' ? 'selected' : '' ?>>全部</option>
<option value="borrower" <?= $searchField === 'borrower' ? 'selected' : '' ?>>借款主体</option>
<option value="guarantor" <?= $searchField === 'guarantor' ? 'selected' : '' ?>>担保人</option>
</select>
<input name="q" placeholder="输入关键词后回车或点筛选" value="<?= e($q) ?>">
<select name="status">
<option value="all" <?= $statusFilter === 'all' ? 'selected' : '' ?>>全部状态</option>
<option value="not_done" <?= $statusFilter === 'not_done' ? 'selected' : '' ?>>默认(隐藏已完成)</option>
<option value="履行中" <?= $statusFilter === '履行中' ? 'selected' : '' ?>>履行中</option>
<option value="逾期中" <?= $statusFilter === '逾期中' ? 'selected' : '' ?>>逾期中</option>
<option value="已完成" <?= $statusFilter === '已完成' ? 'selected' : '' ?>>已完成</option>
<option value="待更新" <?= $statusFilter === '待更新' ? 'selected' : '' ?>>待更新</option>
</select>
<select name="sort_by">
<option value="created_at" <?= $sortBy === 'created_at' ? 'selected' : '' ?>>按创建时间</option>
<option value="end_date" <?= $sortBy === 'end_date' ? 'selected' : '' ?>>按到期日</option>
<option value="loan_institution" <?= $sortBy === 'loan_institution' ? 'selected' : '' ?>>按贷款机构</option>
</select>
<select name="sort_order">
<option value="desc" <?= $sortOrder === 'desc' ? 'selected' : '' ?>>倒序</option>
<option value="asc" <?= $sortOrder === 'asc' ? 'selected' : '' ?>>正序</option>
</select>
<select name="per_page">
<option value="10" <?= $perPage === 10 ? 'selected' : '' ?>>每页10条</option>
<option value="15" <?= $perPage === 15 ? 'selected' : '' ?>>每页15条</option>
<option value="30" <?= $perPage === 30 ? 'selected' : '' ?>>每页30条</option>
<option value="50" <?= $perPage === 50 ? 'selected' : '' ?>>每页50条</option>
</select>
<button type="submit">筛选</button>
<a class="clear-link" href="index.php<?= $dirId ? '?dir_id=' . $dirId : '' ?>">一键清空</a>
</div>
</form>
<form method="get" action="index.php">
<input type="hidden" name="action" value="export">
<input type="hidden" name="mode" value="selected">
<input type="hidden" name="kind" value="<?= e($kindFilter) ?>">
<select name="format">
<option value="xlsx">xlsx</option>
<option value="csv">csv</option>
</select>
<?php if ($dirId): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<div class="table-wrap"><table class="fixed-table archive-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" onclick="document.querySelectorAll('.cid').forEach(x=>x.checked=this.checked)"></th>
<th class="col-kind">类型</th>
<th class="col-name">档案名称</th>
<th class="col-inst"><span class="th-stack"><span>机构</span><span class="th-sub">贷款行/债权人</span></span></th>
<th class="col-borrower">借款主体</th>
<th class="col-amount hide-md">授信额度</th>
<th class="col-amount">剩余额度</th>
<th class="col-date hide-md">起始日</th>
<th class="col-date">到期日</th>
<th class="col-guarantor">担保人</th>
<th class="col-rate">年利率</th>
<th class="col-status">状态</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($archivesPage as $a):
$s = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id']));
$rowKind = normalizeArchiveKind($a['archive_kind'] ?? '');
$isExtGuarantee = $rowKind === ARCHIVE_KIND_EXTERNAL_GUARANTEE;
?>
<tr data-archive-kind="<?= e($rowKind) ?>">
<td class="col-check" data-label="选择"><input class="cid" type="checkbox" name="ids[]" value="<?= (int)$a['id'] ?>"></td>
<td class="col-kind" data-label="类型"><?php if ($isExtGuarantee): ?><span class="badge-k-guar">担保</span><?php else: ?><span class="badge-k-loan">贷款</span><?php endif; ?></td>
<td class="col-name" data-label="档案名称"><span class="truncate" title="<?= e((string)$a['name']) ?>"><?= e((string)$a['name']) ?></span></td>
<td class="col-inst" data-label="贷款机构"><span class="truncate" title="<?= e((string)$a['loan_institution']) ?>"><?= e((string)$a['loan_institution']) ?></span></td>
<td class="col-borrower" data-label="借款主体"><span class="truncate" title="<?= e((string)$a['borrower']) ?>"><?= e((string)$a['borrower']) ?></span></td>
<td class="hide-md" data-label="授信额度"><?= e(amount($a['credit_limit'] !== null ? (float)$a['credit_limit'] : null)) ?></td>
<td data-label="剩余额度"><?= e(amount($a['remaining_limit'] !== null ? (float)$a['remaining_limit'] : null)) ?></td>
<td class="col-date hide-md" data-label="起始日"><span class="truncate" title="<?= e((string)$a['start_date']) ?>"><?= e((string)$a['start_date']) ?></span></td>
<td class="col-date" data-label="到期日"><span class="truncate" title="<?= e((string)$a['end_date']) ?>"><?= e((string)$a['end_date']) ?></span></td>
<td class="col-guarantor" data-label="担保人"><span class="truncate" title="<?= e((string)$a['guarantor']) ?>"><?= e((string)$a['guarantor']) ?></span></td>
<td class="col-rate" data-label="年利率"><?= e($a['annual_rate'] === null ? '' : ((string)$a['annual_rate'] . '%')) ?></td>
<td class="col-status" data-label="状态">
<span class="badge <?= $s === '履行中' ? 'b1' : ($s === '已完成' ? 'b2' : ($s === '逾期中' ? 'b4' : 'b3')) ?>"><?= e($s) ?></span>
</td>
<td class="col-action" data-label="操作">
<div class="icon-actions">
<a class="icon-btn" href="index.php?action=archive_detail&id=<?= (int)$a['id'] ?>">👁 查看</a>
<?php if (isAdmin()): ?>
<a class="icon-btn" href="index.php?action=archive_form&id=<?= (int)$a['id'] ?>">✎ 编辑</a>
<a class="icon-btn danger" href="index.php?action=request_delete_archive&id=<?= (int)$a['id'] ?>" onclick="return confirm('将提交双管理员删除申请,确认?')">🗑 删除</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<p style="display:flex;gap:8px;flex-wrap:wrap">
<button type="submit">导出选中档案</button>
<?php if (isAdmin()): ?>
<button type="button" class="btn-danger" onclick="requestDeleteSelected()">删除选中档案(双管理员)</button>
<?php endif; ?>
</p>
</form>
<script>
function requestDeleteSelected(){
var ids=[...document.querySelectorAll('.cid:checked')].map(function(x){return x.value;});
if(ids.length===0){alert('请先选择要删除的档案');return;}
if(!confirm('将提交双管理员删除申请,确认?')){return;}
window.location.href='index.php?action=request_delete_selected&ids='+encodeURIComponent(ids.join(','));
}
</script>
<div class="pager">
<div class="small">第 <?= $currentPage ?> / <?= $totalPages ?> 页,共 <?= $totalCount ?> 条</div>
<div>
<?php
$prevQuery = http_build_query(array_merge($baseQuery, ['page' => max(1, $currentPage - 1)]));
$nextQuery = http_build_query(array_merge($baseQuery, ['page' => min($totalPages, $currentPage + 1)]));
?>
<?php if ($currentPage > 1): ?><a href="index.php?<?= $prevQuery ?>">上一页</a><?php else: ?><span class="muted">上一页</span><?php endif; ?>
<span class="muted"> | </span>
<?php if ($currentPage < $totalPages): ?><a href="index.php?<?= $nextQuery ?>">下一页</a><?php else: ?><span class="muted">下一页</span><?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<?php
pageFooter();