Files

2989 lines
135 KiB
PHP
Executable File
Raw Permalink 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 (portalEnabled() && in_array($action, ['login','logout','profile','users'], true)) {
portalFail(403, '账号和登录请前往统一门户管理。');
}
if ($action === 'logout') {
session_destroy();
header('Location: index.php?action=login');
exit;
}
if ($action === 'login') { require __DIR__ . '/../app/views/pages/login.php'; exit; }
requireLogin();
$me = currentUser();
if ($action === 'home') {
$qs = (string)($_SERVER['QUERY_STRING'] ?? '');
$_SESSION['last_list_url'] = 'index.php' . ($qs !== '' ? ('?' . $qs) : '');
}
/* ---------- 视觉层辅助(仅呈现,不改业务语义) ---------- */
if (!function_exists('fmtMoney')) {
function fmtMoney(?float $v): string
{
return $v === null ? '—' : number_format($v, 2, '.', ',');
}
}
if (!function_exists('fmtWan')) {
function fmtWan(?float $v): string
{
if ($v === null) {
return '—';
}
if (abs($v) >= 10000) {
$s = number_format($v / 10000, 2, '.', ',');
$s = rtrim(rtrim($s, '0'), '.');
return $s . ' 万';
}
return number_format($v, 2, '.', ',');
}
}
if (!function_exists('statusPillCls')) {
function statusPillCls(string $s): string
{
return ['履行中' => 'ok', '逾期中' => 'over', '待更新' => 'pending', '已完成' => 'done'][$s] ?? 'pending';
}
}
function navActiveKey(string $action): string
{
$map = [
'workbench' => 'workbench',
'home' => 'list',
'archive_detail' => 'list',
'archive_form' => 'list',
'new_dir' => 'list',
'edit_dir' => 'list',
'tasks' => 'tasks',
'reminders' => 'tasks',
'pending_updates' => 'tasks',
'query_totals' => 'query',
'query_payable_interest' => 'query',
'query_external_guarantee' => 'query',
'repayment_history' => 'query',
'quick_queries' => 'query',
'interest_calculator' => 'tools',
'acceptance_discount_calculator' => 'tools',
'delete_approvals' => 'approvals',
'users' => 'users',
'profile' => 'profile',
];
return $map[$action] ?? 'list';
}
function pageHeader(array $me, ?int $taskCount = null): void
{
global $action;
$flash = getFlash();
$navKey = navActiveKey((string)$action);
$isAdmin = ($me['role'] ?? '') === 'admin';
$who = maskUsername((string)$me['username']);
$roleTxt = $isAdmin ? '管理员' : '经办';
$avatar = $isAdmin ? '管' : '办';
$navItem = function (string $key, string $href, string $label, string $short, ?int $badge = null) use ($navKey): string {
$active = $navKey === $key ? ' data-active' : '';
$b = $badge !== null && $badge > 0 ? '<span class="nav-badge num">' . $badge . '</span>' : '';
return '<a class="nav-item" href="' . e($href) . '" data-short="' . e($short) . '"' . $active . '><span class="dot"></span>' . e($label) . $b . '</a>';
};
?>
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>金牛集团贷款档案管理系统</title>
<link rel="stylesheet" href="<?= assetUrl('assets/fusion.css') ?>">
<link rel="stylesheet" href="<?= assetUrl('assets/portal-return.css') ?>">
<script src="<?= assetUrl('assets/fusion.js') ?>" defer></script>
</head><body>
<div class="v-fusion" data-variant-root>
<script>(function(){try{if(localStorage.getItem('jn_theme')==='night'){document.currentScript.parentElement.setAttribute('data-theme','night');}}catch(e){}})();</script>
<aside class="sidebar">
<div class="brand">
<img src="assets/logo-day.png" alt="金牛实业 OA 系统图标" data-brand-logo data-logo-day="assets/logo-day.png" data-logo-night="assets/logo-night.png">
<div><div class="brand-name">金牛贷款档案</div><div class="brand-sub">JINNIU ARCHIVE</div></div>
</div>
<div class="nav-label">档案</div>
<?= $navItem('workbench', 'index.php?action=workbench', '工作台', '台') ?>
<?= $navItem('list', 'index.php', '档案库', '库') ?>
<?= $navItem('tasks', 'index.php?action=tasks', '任务中心', '任', $taskCount) ?>
<div class="nav-label">分析</div>
<?= $navItem('query', 'index.php?action=query_totals', '统计查询', '查') ?>
<?= $navItem('tools', 'index.php?action=interest_calculator', '计算工具', '算') ?>
<?php if ($isAdmin): ?>
<div class="nav-label">管理</div>
<?= $navItem('approvals', 'index.php?action=delete_approvals', '删除审批', '审') ?>
<?php if (!portalEnabled()): ?>
<?= $navItem('users', 'index.php?action=users', '用户管理', '用') ?>
<?php endif; ?>
<?php endif; ?>
<?php if (!portalEnabled()): ?>
<div class="nav-label">账户</div>
<?= $navItem('profile', 'index.php?action=profile', '个人设置', '我') ?>
<?php endif; ?>
<div class="sidebar-account">
<div class="theme-toggle" role="group" aria-label="日间 / 夜间主题切换">
<button type="button" data-theme-btn="day" aria-pressed="true">☀ 日间</button>
<button type="button" data-theme-btn="night" aria-pressed="false">☾ 夜间</button>
</div>
<?php if (!portalEnabled()): ?>
<div class="account-box">
<div class="account-id">
<div class="account-meta">
<div class="account-name"><?= e($who) ?><span class="num">(<?= e($roleTxt) ?>)</span></div>
</div>
<a class="icon-btn" href="index.php?action=logout" aria-label="退出登录" title="退出登录" onclick="return confirm('确认退出登录?')">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2H3.5A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14H6"/><path d="M10.5 5l3 3-3 3"/><path d="M13.5 8H6.5"/></svg>
</a>
</div>
<div class="account-note">金牛集团 · 内部档案系统</div>
</div>
<?php endif; ?>
</div>
</aside>
<div class="content-shell<?= portalEnabled() ? ' has-portal-bar' : '' ?>">
<?php if (portalEnabled()): ?>
<header class="loan-portal-header jn-portal-bar"><span>贷款档案管理</span><a class="jn-portal-return" data-return-portal href="<?= e(portalPublic()) ?>" title="返回统一门户" aria-label="返回统一门户"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></svg><span>统一门户</span></a></header>
<?php endif; ?>
<main class="content">
<?php if ($flash): ?>
<?php if ($flash['type'] === 'ok'): ?>
<script>window.addEventListener('load',function(){if(window.jnToast)window.jnToast(<?= json_encode((string)$flash['msg'], JSON_UNESCAPED_UNICODE) ?>);});</script>
<?php else: ?>
<div class="flash-banner flash-banner--err" role="alert"><?= e($flash['msg']) ?></div>
<?php endif; ?>
<?php endif; ?>
<?php
}
function pageFooter(bool $withDrawer = true, ?string $extraModalHtml = null): void
{
$isAdmin = isAdmin();
?>
</main>
</div>
<?php if ($withDrawer): ?>
<div class="scrim" data-close-drawer></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="档案详情" data-drawer></aside>
<?php endif; ?>
<div class="modal-scrim" data-close-modal></div>
<?php if ($isAdmin): ?>
<div class="modal-card" data-modal="import" role="dialog" aria-modal="true" aria-label="导入档案">
<form method="post" action="index.php?action=import_archives" enctype="multipart/form-data">
<div class="modal-head">
<div class="modal-title">导入档案</div>
<button type="button" class="drawer-close" data-close-modal aria-label="关闭">✕</button>
</div>
<div class="modal-body">
<div class="section-title" style="margin-top:0;">第一步 · 下载模板</div>
<div style="display:flex; gap:10px;">
<a class="btn btn--plain" href="index.php?action=download_import_template">xlsx 模板</a>
</div>
<div class="section-title">第二步 · 上传文件</div>
<input type="file" name="import_file" accept=".xlsx,.xls,.csv" required style="width:100%;">
<div class="field-hint" style="margin-top:6px;">xlsx / csv · 单文件 ≤ 10MB</div>
<div class="section-title">校验规则</div>
<div class="calc-formula">
必填列:借款人 / 机构 / 类型 / 授信额度 / 起止日期<br>
重复档案编号自动跳过;错误行生成报告,不中断整批导入
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn btn--plain" data-close-modal>取消</button>
<button type="submit" class="btn btn--primary">开始导入</button>
</div>
</form>
</div>
<?php endif; ?>
<?= $extraModalHtml ?? '' ?>
</div>
</body></html>
<?php
}
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));
}
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') { require __DIR__ . '/../app/views/pages/new_dir.php'; exit; }
if ($action === 'edit_dir') { require __DIR__ . '/../app/views/pages/edit_dir.php'; 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') { require __DIR__ . '/../app/views/pages/delete_approvals.php'; 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') { require __DIR__ . '/../app/views/pages/archive_detail.php'; exit; }
if ($action === 'archive_form') { require __DIR__ . '/../app/views/pages/archive_form.php'; exit; }
if ($action === 'users') { require __DIR__ . '/../app/views/pages/users.php'; exit; }
if ($action === 'profile') { require __DIR__ . '/../app/views/pages/profile.php'; exit; }
if ($action === 'quick_queries') { require __DIR__ . '/../app/views/pages/quick_queries.php'; exit; }
if ($action === 'repayment_history') { require __DIR__ . '/../app/views/pages/repayment_history.php'; 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') { require __DIR__ . '/../app/views/pages/query_totals.php'; exit; }
if ($action === 'query_payable_interest') { require __DIR__ . '/../app/views/pages/query_payable_interest.php'; exit; }
if ($action === 'query_external_guarantee') { require __DIR__ . '/../app/views/pages/query_external_guarantee.php'; 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 = normalizeSearchField($_GET['search_field'] ?? 'all');
$statusFilter = $_GET['status'] ?? 'not_done';
$kindFilter = (string)($_GET['kind'] ?? 'all');
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 (!archiveMatchesKeyword($r, $q, $searchField)) {
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);
$dueSoonLoansFull = $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);
$overdueLoansFull = $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);
$repayPlanSoonFull = $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();
// 待更新已并入任务中心(样板 IA),保留旧链接跳转
header('Location: index.php?action=tasks&tab=pending');
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;
}
// 提醒列表已并入任务中心(样板 IA),旧链接按类型跳转;导出仍由上方分支处理
$tabMap = ['due' => 'due', 'plan' => 'plan', 'overdue' => 'over'];
header('Location: index.php?action=tasks&tab=' . ($tabMap[$reminderType] ?? 'all'));
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);
$dirPickerItems = directoryPickerItems($dirs);
$dirPickerLabel = $dirId > 0 ? (string)($dirPath[$dirId] ?? '已选目录') : '全部目录';
$searchPlaceholder = [
'borrower' => '搜索借款人',
'guarantor' => '搜索担保人',
][$searchField] ?? '搜索借款人 / 名称 / 机构';
$taskTotal = $pendingUpdateCount + $dueSoonCount + $repaySoonCount + $overdueCount;
$listUrl = function (array $ov = []) use ($baseQuery): string {
$q = array_merge($baseQuery, $ov);
foreach ($q as $k => $v) {
if ($v === '' || $v === null) {
unset($q[$k]);
}
}
return 'index.php' . ($q ? ('?' . http_build_query($q)) : '');
};
/* ============================================================
工作台(样板 workbenchPage:四张风险入口卡 + 今日待办 + 快捷入口)
============================================================ */
if ($action === 'workbench') {
requireLogin();
$isAdmin = isAdmin();
$todos = [];
foreach (array_slice($pendingUpdateRows, 0, 2) as $r) {
$todos[] = ['date' => (string)$r['date'], 'what' => '结果待确认 · ' . (string)$r['type_label'], 'ref' => '#' . (int)$r['archive_id'] . ' · ' . (string)$r['borrower'], 'tab' => 'pending', 'pill' => 'pending', 'pillTxt' => '待更新'];
}
foreach (array_slice($repayPlanSoon, 0, 2) as $r) {
$todos[] = ['date' => (string)$r['due_date'], 'what' => '还款计划到期 · ' . fmtMoney((float)$r['amount']) . ' 元', 'ref' => '#' . (int)$r['archive_id'] . ' · ' . (string)$r['borrower'], 'tab' => 'plan', 'pill' => 'due', 'pillTxt' => '未还'];
}
foreach (array_slice($dueSoonLoans, 0, 2) as $r) {
$todos[] = ['date' => (string)$r['end_date'], 'what' => (string)$r['end_date'] . ' 到期 · 需安排资金', 'ref' => '#' . (int)$r['id'] . ' · ' . (string)$r['borrower'], 'tab' => 'due', 'pill' => 'due', 'pillTxt' => '即将到期'];
}
foreach (array_slice($overdueLoans, 0, 1) as $r) {
$todos[] = ['date' => (string)$r['end_date'], 'what' => '逾期跟进 · 应于 ' . (string)$r['end_date'], 'ref' => '#' . (int)$r['id'] . ' · ' . (string)$r['borrower'], 'tab' => 'over', 'pill' => 'over', 'pillTxt' => '逾期'];
}
foreach (array_slice($overduePlanRows, 0, 1) as $r) {
$todos[] = ['date' => (string)$r['due_date'], 'what' => '逾期计划跟进 · 应于 ' . (string)$r['due_date'], 'ref' => '#' . (int)$r['archive_id'] . ' · ' . (string)$r['borrower'], 'tab' => 'over', 'pill' => 'over', 'pillTxt' => '逾期'];
}
$weekMap = ['日', '一', '二', '三', '四', '五', '六'];
pageHeader($me, $taskTotal);
?>
<div class="page-head">
<div>
<h1 class="page-title">工作台</h1>
<div class="page-sub">每日巡检 · <?= e($today) ?> 星期<?= $weekMap[(int)date('w')] ?></div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<?php if ($isAdmin): ?><button type="button" class="btn btn--plain" data-open-modal="import">导入</button><?php endif; ?>
<a class="btn btn--plain" href="<?= e($listUrl(['action' => 'export', 'mode' => 'all', 'format' => 'xlsx'])) ?>">导出</a>
<?php if ($isAdmin): ?><a class="btn btn--primary" href="index.php?action=archive_form<?= $dirId ? '&dir_id=' . $dirId : '' ?>">+ 新建档案</a><?php endif; ?>
</div>
</div>
<div class="stats">
<a class="stat stat--link stagger-in" href="index.php?action=tasks&tab=pending">
<div class="stat-label">待更新</div>
<div class="stat-value num"><?= $pendingUpdateCount ?><small>笔</small></div>
<div class="stat-foot">已到结息日,待经办确认 →</div>
</a>
<a class="stat stat--link stat--warn stagger-in" style="animation-delay:45ms" href="index.php?action=tasks&tab=due">
<div class="stat-label">60 日内贷款到期</div>
<div class="stat-value num"><?= $dueSoonCount ?><small>笔</small></div>
<div class="stat-foot">需提前安排资金 →</div>
</a>
<a class="stat stat--link stat--gold stagger-in" style="animation-delay:90ms" href="index.php?action=tasks&tab=plan">
<div class="stat-label">待处理还款计划</div>
<div class="stat-value num"><?= $repaySoonCount ?><small>笔</small></div>
<div class="stat-foot">本金 / 利息到期未还 →</div>
</a>
<a class="stat stat--link stat--alert stagger-in" style="animation-delay:135ms" href="index.php?action=tasks&tab=over">
<div class="stat-label">贷款逾期</div>
<div class="stat-value num"><?= $overdueCount ?><small>笔</small></div>
<div class="stat-foot">已计逾期利息 →</div>
</a>
</div>
<div class="section-title" style="margin-top:0;">今日待办</div>
<div class="table-card">
<table>
<thead><tr><th style="width:96px;">日期</th><th>事项</th><th>关联</th><th>状态</th><th class="cell-right">操作</th></tr></thead>
<tbody>
<?php foreach ($todos as $i => $t): ?>
<tr class="stagger-in" style="animation-delay:<?= min($i, 8) * 45 ?>ms; cursor:default;">
<td class="num"><?= e($t['date']) ?></td>
<td><div class="cell-main"><?= e($t['what']) ?></div></td>
<td class="num" style="white-space:normal;"><?= e($t['ref']) ?></td>
<td><span class="pill pill--<?= $t['pill'] ?>"><?= e($t['pillTxt']) ?></span></td>
<td class="cell-right"><a class="btn btn--plain btn--sm" href="index.php?action=tasks&tab=<?= e($t['tab']) ?>">去处理</a></td>
</tr>
<?php endforeach; ?>
<?php if (!$todos): ?>
<tr><td colspan="5"><div class="empty" style="margin:14px;">今日暂无待办事项</div></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="section-title">快捷入口</div>
<div class="quick-dock">
<?php if ($isAdmin): ?>
<a class="qd-item stagger-in" href="index.php?action=archive_form">
<span class="qd-ico"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 2h5l3.5 3.5V16H5z"/><path d="M10 2v3.5h3.5"/><path d="M9 9v4M7 11h4"/></svg></span>
<span class="qd-main">新建档案</span>
<span class="qd-sub">五步录入 · 自动归档</span>
</a>
<button type="button" class="qd-item stagger-in" style="animation-delay:45ms" data-open-modal="import">
<span class="qd-ico"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2.5v7"/><path d="m6 6.5 3 3 3-3"/><path d="M3 11v3.5A1.5 1.5 0 0 0 4.5 16h9a1.5 1.5 0 0 0 1.5-1.5V11"/></svg></span>
<span class="qd-main">导入档案</span>
<span class="qd-sub">xlsx / csv 模板批量导入</span>
</button>
<?php endif; ?>
<a class="qd-item stagger-in" style="animation-delay:90ms" href="index.php?action=query_totals">
<span class="qd-ico"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 15.5h12"/><path d="M5.5 12V8M9 12V4.5M12.5 12V6.5"/></svg></span>
<span class="qd-main">统计查询</span>
<span class="qd-sub">总金额 / 应付本息 / 担保 / 历史</span>
</a>
<a class="qd-item stagger-in" style="animation-delay:135ms" href="index.php?action=interest_calculator">
<span class="qd-ico"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.2"/><circle cx="12" cy="12" r="2.2"/><path d="M13.5 4.5 4.5 13.5"/></svg></span>
<span class="qd-main">利息计算</span>
<span class="qd-sub">单利 · 360 / 365 天基准</span>
</a>
<a class="qd-item stagger-in" style="animation-delay:180ms" href="index.php?action=acceptance_discount_calculator">
<span class="qd-ico"><svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5.5h12A1.5 1.5 0 0 1 16.5 7v1.5a2 2 0 0 0 0 4V14A1.5 1.5 0 0 1 15 15.5H3A1.5 1.5 0 0 1 1.5 14v-1.5a2 2 0 0 0 0-4V7A1.5 1.5 0 0 1 3 5.5z"/><path d="M11 5.5v10" stroke-dasharray="2 2.5"/></svg></span>
<span class="qd-main">承兑贴现</span>
<span class="qd-sub">异地加天 · 节假日顺延</span>
</a>
</div>
<?php
pageFooter();
exit;
}
/* ============================================================
任务中心(样板 tasksPage:到期提醒与待更新合并,四组分段)
============================================================ */
if ($action === 'tasks') {
requireLogin();
$isAdmin = isAdmin();
$taskTab = (string)($_GET['tab'] ?? 'all');
if (!in_array($taskTab, ['all', 'due', 'plan', 'over', 'pending'], true)) {
$taskTab = 'all';
}
$groups = [];
$dueRows = [];
foreach ($dueSoonLoansFull as $r) {
$dueRows[] = [
'what' => (string)$r['end_date'] . ' 到期 · ' . archiveKindLabel(normalizeArchiveKind((string)($r['archive_kind'] ?? ''))),
'aid' => (int)$r['id'], 'plan_id' => '',
'name' => (string)$r['borrower'],
'amt' => $r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null,
'pill' => 'due', 'pillTxt' => '即将到期', 'kind' => 'due',
];
}
$groups[] = ['key' => 'due', 'name' => '贷款到期', 'rows' => $dueRows];
$planRows = [];
foreach ($repayPlanSoonFull as $r) {
$planRows[] = [
'what' => '还款计划到期 · ' . (string)$r['due_date'],
'aid' => (int)$r['archive_id'], 'plan_id' => (string)$r['plan_id'],
'name' => (string)$r['borrower'],
'amt' => (float)$r['amount'],
'pill' => 'due', 'pillTxt' => '未还', 'kind' => 'plan',
];
}
$groups[] = ['key' => 'plan', 'name' => '还款计划', 'rows' => $planRows];
$overRows = [];
foreach ($overdueLoansFull as $r) {
$overRows[] = [
'what' => '逾期贷款 · 应于 ' . (string)$r['end_date'],
'aid' => (int)$r['id'], 'plan_id' => '',
'name' => (string)$r['borrower'],
'amt' => $r['remaining_limit'] !== null ? (float)$r['remaining_limit'] : null,
'pill' => 'over', 'pillTxt' => '逾期', 'kind' => 'over',
];
}
foreach ($overduePlanRows as $r) {
$overRows[] = [
'what' => '逾期计划 · 应于 ' . (string)$r['due_date'],
'aid' => (int)$r['archive_id'], 'plan_id' => (string)$r['plan_id'],
'name' => (string)$r['borrower'],
'amt' => (float)$r['amount'],
'pill' => 'over', 'pillTxt' => '逾期', 'kind' => 'over_plan',
];
}
$groups[] = ['key' => 'over', 'name' => '逾期中', 'rows' => $overRows];
$pendRows = [];
foreach ($pendingUpdateRows as $r) {
$pendRows[] = [
'what' => '结果待确认 · ' . (string)$r['type_label'] . ' · ' . (string)$r['date'],
'aid' => (int)$r['archive_id'], 'plan_id' => (string)$r['plan_id'],
'name' => (string)$r['borrower'],
'amt' => isset($r['amount']) && $r['amount'] !== null ? (float)$r['amount'] : null,
'pill' => 'pending', 'pillTxt' => '待更新', 'kind' => ($r['row_kind'] ?? '') === 'loan' ? 'pending_loan' : 'pending_plan',
];
}
$groups[] = ['key' => 'pending', 'name' => '待更新', 'rows' => $pendRows];
$tabs = [['all', '全部'], ['due', '贷款到期'], ['plan', '还款计划'], ['over', '逾期中'], ['pending', '待更新']];
$exportType = ['all' => 'all', 'due' => 'due', 'plan' => 'plan', 'over' => 'overdue', 'pending' => 'all'][$taskTab];
pageHeader($me, $taskTotal);
?>
<div class="page-head">
<div>
<h1 class="page-title">任务中心</h1>
<div class="page-sub">到期提醒与待更新合并处理 · 行内登记后剩余额度自动联动</div>
</div>
<div style="display:flex; gap:10px;">
<a class="btn btn--plain" href="index.php?action=reminders&type=<?= e($exportType) ?>&format=xlsx">导出 xlsx</a>
<a class="btn btn--plain" href="index.php?action=reminders&type=<?= e($exportType) ?>&format=csv">导出 csv</a>
</div>
</div>
<div class="seg" role="tablist" aria-label="任务分组">
<?php foreach ($tabs as [$k, $v]): ?>
<a role="tab" href="index.php?action=tasks&tab=<?= e($k) ?>" <?= $taskTab === $k ? 'data-active' : '' ?>><?= e($v) ?></a>
<?php endforeach; ?>
</div>
<?php foreach ($groups as $gi => $g): ?>
<?php if ($taskTab !== 'all' && $g['key'] !== $taskTab) { continue; } ?>
<div class="section-title" <?= $gi === 0 ? 'style="margin-top:20px;"' : '' ?>><?= e($g['name']) ?><span class="nav-badge num"><?= count($g['rows']) ?></span></div>
<div class="table-card">
<table>
<thead><tr><th>事项</th><th>关联档案</th><th>借款主体</th><th class="cell-right">金额</th><th>状态</th><th class="cell-right">行内处理</th></tr></thead>
<tbody>
<?php foreach ($g['rows'] as $i => $r): ?>
<tr class="stagger-in" style="animation-delay:<?= min($i, 8) * 45 ?>ms; cursor:default;">
<td><div class="cell-main"><?= e($r['what']) ?></div></td>
<td class="num"><a href="index.php?action=archive_detail&id=<?= $r['aid'] ?>">#<?= $r['aid'] ?></a></td>
<td><?= e($r['name']) ?></td>
<td class="cell-right num"><?= e(fmtMoney($r['amt'])) ?></td>
<td><span class="pill pill--<?= $r['pill'] ?>"><?= e($r['pillTxt']) ?></span></td>
<td class="cell-right">
<a class="btn btn--plain btn--sm" href="index.php?action=archive_detail&id=<?= $r['aid'] ?>">查看档案</a>
<?php if ($isAdmin && $r['kind'] === 'due'): ?>
<form method="post" action="index.php?action=reminder_action" class="inline" style="display:inline" onsubmit="return confirm('确认设置该档案为提前还款并标记已完成?')">
<input type="hidden" name="op" value="early_settle">
<input type="hidden" name="archive_id" value="<?= $r['aid'] ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">提前还款</button>
</form>
<?php endif; ?>
<?php if ($isAdmin && $r['kind'] === 'plan'): ?>
<form method="post" action="index.php?action=reminder_action" class="inline" style="display:inline-flex;gap:6px;align-items:center">
<input type="hidden" name="op" value="plan_delay">
<input type="hidden" name="archive_id" value="<?= $r['aid'] ?>">
<input type="hidden" name="plan_id" value="<?= e($r['plan_id']) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<input type="date" name="new_due_date" required style="height:26px;font-size:12px;padding:0 6px;">
<button type="submit" class="btn btn--plain btn--sm">延期</button>
</form>
<?php endif; ?>
<?php if ($isAdmin && ($r['kind'] === 'over' || $r['kind'] === 'over_plan')): ?>
<form method="post" action="index.php?action=reminder_action" class="inline" style="display:inline" onsubmit="return confirm('确认将该逾期项标记为已归还?')">
<input type="hidden" name="op" value="overdue_paid">
<input type="hidden" name="archive_id" value="<?= $r['aid'] ?>">
<input type="hidden" name="plan_id" value="<?= e($r['plan_id']) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">已归还</button>
</form>
<?php endif; ?>
<?php if ($isAdmin && $r['kind'] === 'pending_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="<?= $r['aid'] ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">已还款</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="<?= $r['aid'] ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">已逾期</button>
</form>
<?php endif; ?>
<?php if ($isAdmin && $r['kind'] === 'pending_plan'): ?>
<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="<?= $r['aid'] ?>">
<input type="hidden" name="plan_id" value="<?= e($r['plan_id']) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">已还款</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="<?= $r['aid'] ?>">
<input type="hidden" name="plan_id" value="<?= e($r['plan_id']) ?>">
<input type="hidden" name="redirect_to" value="<?= e(currentUrlFromServer()) ?>">
<button type="submit" class="btn btn--plain btn--sm">已逾期</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$g['rows']): ?>
<tr><td colspan="6"><div class="empty" style="margin:14px;">本组暂无待办</div></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<?php
pageFooter();
exit;
}
/* ============================================================
档案库 · 目录管理(样板 dirsPage:三级目录三栏 + 所选目录档案预览)
============================================================ */
$view = (string)($_GET['view'] ?? 'files');
if ($action === 'home' && $view === 'dirs') {
requireLogin();
$isAdmin = isAdmin();
$d1 = (int)($_GET['d1'] ?? 0);
$d2 = (int)($_GET['d2'] ?? 0);
$d3 = (int)($_GET['d3'] ?? 0);
$byLevel = [1 => [], 2 => [], 3 => []];
$byParent = [];
$dirById = [];
foreach ($dirs as $d) {
$dirById[(int)$d['id']] = $d;
$byLevel[(int)$d['level']][] = $d;
$byParent[(int)($d['parent_id'] ?? 0)][] = $d;
}
if (!isset($dirById[$d1])) { $d1 = 0; }
if (!isset($dirById[$d2]) || (int)$dirById[$d2]['parent_id'] !== $d1) { $d2 = 0; }
if (!isset($dirById[$d3]) || (int)$dirById[$d3]['parent_id'] !== $d2) { $d3 = 0; }
if ($d1 === 0 && !empty($byLevel[1])) {
$d1 = (int)$byLevel[1][0]['id'];
}
$l2nodes = $d1 ? array_values($byParent[$d1] ?? []) : null;
if ($d2 === 0 && $l2nodes) {
$d2 = (int)$l2nodes[0]['id'];
}
$l3nodes = $d2 ? array_values($byParent[$d2] ?? []) : null;
$dirArchiveCount = function (int $id) use ($rows, $dirs): int {
$ids = array_flip(collectSubDirIds($dirs, $id));
$n = 0;
foreach ($rows as $r) {
if (isset($ids[(int)$r['directory_id']])) {
$n++;
}
}
return $n;
};
$dirCol = function (int $level, string $title, ?array $nodes, int $selId, string $parentHint) use ($isAdmin, $d1, $d2, $d3, $dirArchiveCount): string {
$names = [1 => '一级目录', 2 => '二级目录', 3 => '三级目录'];
$addHref = 'index.php?action=new_dir' . ($level === 2 ? '&parent_id=' . $d1 : ($level === 3 ? '&parent_id=' . $d2 : ''));
$addDisabled = $nodes === null;
$html = '<div class="dir-col"><div class="dir-col-head">'
. '<div class="dir-col-title">' . $names[$level] . '<small>' . e($title) . ' · ' . ($nodes === null ? 0 : count($nodes)) . ' 项</small></div>';
if ($isAdmin) {
$html .= $addDisabled
? '<button class="btn btn--plain btn--sm" disabled>+ 新增</button>'
: '<a class="btn btn--plain btn--sm" href="' . e($addHref) . '">+ 新增</a>';
}
$html .= '</div>';
if ($nodes === null) {
return $html . '<div class="dir-empty">' . e($parentHint) . '</div></div>';
}
if (!$nodes) {
return $html . '<div class="dir-empty">暂无' . $names[$level] . ($isAdmin ? ',可点「+ 新增」创建' : '') . '</div></div>';
}
foreach ($nodes as $n) {
$id = (int)$n['id'];
$qs = ['view' => 'dirs'];
if ($level >= 1) { $qs['d1'] = $level === 1 ? $id : $d1; }
if ($level >= 2) { $qs['d2'] = $level === 2 ? $id : $d2; }
if ($level >= 3) { $qs['d3'] = $id; }
$href = 'index.php?' . http_build_query($qs);
$name = (string)$n['name'];
$html .= '<div class="dir-row-wrap"' . ($id === $selId ? ' data-active' : '') . '>'
. '<a class="dir-row" href="' . e($href) . '" title="查看下级"'
. ($id === $selId ? ' aria-current="true"' : '')
. ' aria-label="查看下级:' . e($name) . '">'
. '<span class="dir-name">' . e($name) . '</span>'
. '<span class="nav-badge num">' . $dirArchiveCount($id) . '</span>'
. '</a>';
if ($isAdmin) {
$html .= '<span class="dir-acts">'
. '<a href="index.php?action=edit_dir&id=' . $id . '" title="改名">改名</a>'
. '<a href="index.php?action=delete_dir&id=' . $id . '" title="删除" onclick="return confirm(\'仅空目录可删除,确认?\')">删除</a>'
. '</span>';
}
$html .= '</div>';
}
return $html . '</div>';
};
$selDirId = $d3 ?: ($d2 ?: $d1);
$previewRows = [];
if ($selDirId) {
$scope = array_flip(collectSubDirIds($dirs, $selDirId));
foreach ($rows as $r) {
if (isset($scope[(int)$r['directory_id']])) {
$previewRows[] = $r;
}
}
}
$selPath = $selDirId ? (string)($dirPath[$selDirId] ?? '') : '';
pageHeader($me, $taskTotal);
?>
<div class="page-head">
<div>
<h1 class="page-title">档案库</h1>
<div class="page-sub">三级目录归档 · 一级机构主体 / 二级业务类型 / 三级贷款机构</div>
<div class="seg head-switch" role="tablist" aria-label="档案库视图切换">
<a role="tab" href="index.php">档案列表</a>
<a role="tab" href="index.php?view=dirs" data-active>目录管理</a>
</div>
</div>
</div>
<div class="dir-hint">点击目录行查看下级;<?= $isAdmin ? '新增、改名、删除直接作用于目录树。' : '目录维护由管理员进行。' ?>列表「目录路径」、按目录筛选与新增档案第 1 步均使用同一套目录</div>
<div class="dir-cols">
<?= $dirCol(1, '机构主体', array_values($byLevel[1]), $d1, '') ?>
<?= $dirCol(2, '业务类型', $l2nodes, $d2, '请先选择左侧一级目录') ?>
<?= $dirCol(3, '贷款机构', $l3nodes, $d3, '请先选择左侧二级目录') ?>
</div>
<?php if ($selDirId): ?>
<div class="section-title">所选目录档案<span class="nav-badge num"><?= count($previewRows) ?></span></div>
<div class="filter-note" style="margin:-8px 0 10px;"><?= e($selPath) ?></div>
<?php if ($previewRows): ?>
<div class="table-card">
<table>
<thead><tr><th>档案名称</th><th>借款人</th><th>贷款机构</th><th>类型</th><th class="cell-right">剩余额度</th><th>状态</th></tr></thead>
<tbody>
<?php foreach ($previewRows as $a):
$s = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id'])); ?>
<tr data-open-detail="<?= (int)$a['id'] ?>" tabindex="0" role="button" aria-label="查看 <?= e((string)$a['borrower']) ?> 档案详情">
<td><div class="cell-main"><?= e((string)$a['name']) ?></div></td>
<td><?= e((string)$a['borrower']) ?></td>
<td><?= e((string)$a['loan_institution']) ?></td>
<td><?= e(archiveKindLabel(normalizeArchiveKind((string)($a['archive_kind'] ?? '')))) ?></td>
<td class="cell-right num cell-remaining"><?= e(fmtMoney($a['remaining_limit'] !== null ? (float)$a['remaining_limit'] : null)) ?></td>
<td><span class="pill pill--<?= statusPillCls($s) ?>"><?= e($s) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="filter-bar" style="margin:12px 0 0;">
<span class="filter-note">共 <?= count($previewRows) ?> 笔</span>
<span class="filter-spacer"></span>
<a class="btn btn--plain" href="index.php?dir_id=<?= $selDirId ?>">在档案列表中查看 ›</a>
</div>
<?php else: ?>
<div class="empty-action">
<div class="ea-title">该目录下暂无档案</div>
新增档案时在第 1 步选择此三级目录,即可归档到此处
<?php if ($isAdmin): ?><br><a class="btn btn--plain" href="index.php?action=archive_form<?= $selDirId ? '&dir_id=' . $selDirId : '' ?>">+ 新建档案</a><?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
pageFooter();
exit;
}
/* ============================================================
档案库 · 档案列表(样板 listPage:统计卡 + 分段筛选 + 表格 + 抽屉)
============================================================ */
$sumAmount = 0.0;
$sumRemaining = 0.0;
$ongoingCount = 0;
foreach ($archives as $a) {
$st = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id']));
if ($st === '已完成') {
continue;
}
$ongoingCount++;
$sumAmount += (float)($a['credit_limit'] ?? 0);
$sumRemaining += (float)($a['remaining_limit'] ?? 0);
}
pageHeader($me, $taskTotal);
?>
<div class="page-head">
<div>
<h1 class="page-title">档案库</h1>
<div class="page-sub">金牛集团 · 银行贷款与对外担保归档</div>
<div class="seg head-switch" role="tablist" aria-label="档案库视图切换">
<a role="tab" href="index.php" data-active>档案列表</a>
<a role="tab" href="index.php?view=dirs">目录管理</a>
</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<form method="get" action="index.php" class="search-cluster">
<?php if ($dirId): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<input type="hidden" name="kind" value="<?= e($kindFilter) ?>">
<input type="hidden" name="status" value="<?= e($statusFilter) ?>">
<input type="hidden" name="sort_by" value="<?= e($sortBy) ?>">
<input type="hidden" name="sort_order" value="<?= e($sortOrder) ?>">
<input type="hidden" name="per_page" value="<?= $perPage ?>">
<input type="hidden" name="search_field" value="<?= e($searchField) ?>">
<div class="seg" role="group" aria-label="搜索范围">
<a href="<?= e($listUrl(['search_field' => 'all', 'page' => null])) ?>" data-search-scope="all" <?= $searchField === 'all' ? 'data-active' : '' ?>>全部</a>
<a href="<?= e($listUrl(['search_field' => 'borrower', 'page' => null])) ?>" data-search-scope="borrower" <?= $searchField === 'borrower' ? 'data-active' : '' ?>>借款人</a>
<a href="<?= e($listUrl(['search_field' => 'guarantor', 'page' => null])) ?>" data-search-scope="guarantor" <?= $searchField === 'guarantor' ? 'data-active' : '' ?>>担保人</a>
</div>
<div class="search"><span aria-hidden="true">⌕</span><input type="search" name="q" value="<?= e($q) ?>" placeholder="<?= e($searchPlaceholder) ?>" aria-label="搜索档案"></div>
</form>
<?php if (isAdmin()): ?><button type="button" class="btn btn--plain" data-open-modal="import">导入</button><?php endif; ?>
<a class="btn btn--plain" href="<?= e($listUrl(['action' => 'export', 'mode' => 'all', 'format' => 'xlsx'])) ?>">导出</a>
<?php if (isAdmin()): ?><a class="btn btn--primary" href="index.php?action=archive_form<?= $dirId ? '&dir_id=' . $dirId : '' ?>">+ 新建档案</a><?php endif; ?>
</div>
</div>
<div class="stats">
<div class="stat stat--gold"><div class="stat-label">在保剩余额度</div><div class="stat-value num"><?= e(fmtWan($sumRemaining)) ?></div><div class="stat-foot">授信合计 <?= e(fmtWan($sumAmount)) ?></div></div>
<div class="stat"><div class="stat-label">在保档案</div><div class="stat-value num"><?= $ongoingCount ?><small>笔</small></div><div class="stat-foot">含对外担保 · 待更新 <?= $pendingUpdateCount ?> 笔</div></div>
<a class="stat stat--link" href="index.php?action=tasks&tab=due"><div class="stat-label">60 日内到期</div><div class="stat-value num"><?= $dueSoonCount ?><small>笔</small></div><div class="stat-foot">需提前安排资金 →</div></a>
<a class="stat stat--link stat--alert" href="index.php?action=tasks&tab=over"><div class="stat-label">逾期中</div><div class="stat-value num"><?= $overdueCount ?><small>笔</small></div><div class="stat-foot">已计逾期利息 →</div></a>
</div>
<div class="seg" role="tablist" aria-label="业务类型筛选">
<a role="tab" href="<?= e($listUrl(['kind' => 'all', 'page' => null])) ?>" <?= $kindFilter === 'all' ? 'data-active' : '' ?>>全部</a>
<a role="tab" href="<?= e($listUrl(['kind' => ARCHIVE_KIND_BANK_LOAN, 'page' => null])) ?>" <?= $kindFilter === ARCHIVE_KIND_BANK_LOAN ? 'data-active' : '' ?>>银行贷款</a>
<a role="tab" href="<?= e($listUrl(['kind' => ARCHIVE_KIND_EXTERNAL_GUARANTEE, 'page' => null])) ?>" <?= $kindFilter === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? 'data-active' : '' ?>>对外担保</a>
</div>
<form method="get" action="index.php" class="filter-bar" style="margin-top:12px;">
<div class="dir-picker" data-dir-picker data-selected="<?= $dirId ?>" data-dirs="<?= e(json_encode($dirPickerItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>">
<input type="hidden" name="dir_id" value="<?= $dirId ?>">
<button type="button" class="dir-picker-trigger" data-dir-picker-trigger aria-haspopup="tree" aria-expanded="false" aria-controls="jn-dir-picker" aria-label="按归档目录筛选" title="<?= e($dirPickerLabel) ?>">
<span class="dir-picker-trigger-text"><?= e($dirPickerLabel) ?></span>
</button>
</div>
<input type="hidden" name="kind" value="<?= e($kindFilter) ?>">
<input type="hidden" name="search_field" value="<?= e($searchField) ?>">
<select name="status" aria-label="按状态筛选" onchange="this.form.submit()">
<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" aria-label="排序方式" onchange="this.form.submit()">
<option value="end_date" <?= $sortBy === 'end_date' ? 'selected' : '' ?>>按到期日</option>
<option value="created_at" <?= $sortBy === 'created_at' ? 'selected' : '' ?>>按创建时间</option>
<option value="loan_institution" <?= $sortBy === 'loan_institution' ? 'selected' : '' ?>>按贷款机构</option>
</select>
<input type="hidden" name="sort_order" value="<?= e($sortOrder) ?>">
<div class="seg" role="group" aria-label="排序方向">
<a href="<?= e($listUrl(['sort_order' => 'asc', 'page' => null])) ?>" <?= $sortOrder === 'asc' ? 'data-active' : '' ?>>正序</a>
<a href="<?= e($listUrl(['sort_order' => 'desc', 'page' => null])) ?>" <?= $sortOrder === 'desc' ? 'data-active' : '' ?>>倒序</a>
</div>
<input type="hidden" name="per_page" value="<?= $perPage ?>">
<input type="hidden" name="q" value="<?= e($q) ?>">
<span class="filter-note"><?= $totalCount ?> 笔结果<?= $dirId ? ' · 目录筛选中' : '' ?><?= $searchField !== 'all' && $q !== '' ? ' · ' . ($searchField === 'borrower' ? '借款人' : '担保人') : '' ?></span>
<?php if ($q !== '' || $statusFilter !== 'not_done' || $dirId || $searchField !== 'all'): ?>
<span class="filter-spacer"></span>
<a class="btn btn--plain btn--sm" href="index.php">清除全部筛选</a>
<?php endif; ?>
</form>
<form method="get" action="index.php" id="batch-export-form">
<input type="hidden" name="action" value="export">
<input type="hidden" name="mode" value="selected">
<input type="hidden" name="format" value="xlsx">
<input type="hidden" name="kind" value="<?= e($kindFilter) ?>">
<?php if ($dirId): ?><input type="hidden" name="dir_id" value="<?= $dirId ?>"><?php endif; ?>
<div data-batch-slot style="display:none;">
<div class="batch-bar">
已选 <b class="num" data-batch-count>0</b> 笔
<span style="flex:1"></span>
<button type="submit" class="btn btn--plain">批量导出</button>
<?php if (isAdmin()): ?><button type="button" class="btn btn--danger" onclick="requestDeleteSelected()">申请删除</button><?php endif; ?>
<button type="button" class="btn btn--plain btn--sm" data-clear-check>取消选择</button>
</div>
</div>
<div class="table-card">
<table>
<thead><tr>
<th class="cell-check"><input type="checkbox" data-check-all aria-label="全选当前筛选结果"></th>
<th>档案名称</th><th>借款人</th><th>贷款机构</th><th>类型</th>
<th class="cell-right">授信额度</th><th class="cell-right">剩余额度</th>
<th class="cell-right">年利率</th><th>到期日</th><th>状态</th>
</tr></thead>
<tbody>
<?php foreach ($archivesPage as $i => $a):
$s = archiveStatusWithConfig($a['end_date'], getArchiveFinanceConfig($financeAllGlobal, (int)$a['id']));
$rowKind = normalizeArchiveKind($a['archive_kind'] ?? '');
$stKey = ['履行中' => 'ok', '逾期中' => 'over', '待更新' => 'pending', '已完成' => 'done'][$s] ?? 'pending';
?>
<tr class="stagger-in" style="animation-delay:<?= min($i, 8) * 45 ?>ms" data-open-detail="<?= (int)$a['id'] ?>" data-status="<?= $stKey ?>" tabindex="0" role="button" aria-label="查看 <?= e((string)$a['borrower']) ?> 档案详情">
<td class="cell-check"><input type="checkbox" name="ids[]" value="<?= (int)$a['id'] ?>" data-check="<?= (int)$a['id'] ?>" aria-label="选择档案 <?= (int)$a['id'] ?>" tabindex="-1"></td>
<td><div class="cell-main"><?= e((string)$a['name']) ?></div><div class="cell-sub"><?= e((string)($dirPath[(int)$a['directory_id']] ?? '')) ?></div></td>
<td><?= e((string)$a['borrower']) ?></td>
<td><?= e((string)$a['loan_institution']) ?></td>
<td><?= e(archiveKindLabel($rowKind)) ?></td>
<td class="cell-right num"><?= e(fmtMoney($a['credit_limit'] !== null ? (float)$a['credit_limit'] : null)) ?></td>
<td class="cell-right num cell-remaining"><?= e(fmtMoney($a['remaining_limit'] !== null ? (float)$a['remaining_limit'] : null)) ?></td>
<td class="cell-right num"><?= $a['annual_rate'] === null ? '—' : e(number_format((float)$a['annual_rate'], 2)) . '%' ?></td>
<td class="num"><?= e((string)$a['end_date']) ?></td>
<td><span class="pill pill--<?= statusPillCls($s) ?>"><?= e($s) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$archivesPage): ?>
<div style="padding:16px;">
<div class="empty-action">
<div class="ea-title">当前筛选无匹配档案</div>
尝试调整目录、搜索范围或状态筛选,或清空关键词
<br><a class="btn btn--plain" href="index.php">清除全部筛选</a>
</div>
</div>
<?php endif; ?>
</div>
</form>
<script>
function requestDeleteSelected(){
var ids=[].slice.call(document.querySelectorAll('[data-check]:checked')).map(function(x){return x.value;});
if(ids.length===0){if(window.jnToast)window.jnToast('请先选择要删除的档案');return;}
if(!confirm('将提交双管理员删除申请,确认?')){return;}
window.location.href='index.php?action=request_delete_selected&ids='+encodeURIComponent(ids.join(','));
}
</script>
<div class="filter-bar" style="margin:12px 0 0;">
<span class="filter-note">第 <?= $currentPage ?> / <?= $totalPages ?> 页 · 共 <?= $totalCount ?> 条</span>
<span class="filter-spacer"></span>
<?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 class="btn btn--plain btn--sm" href="index.php?<?= $prevQuery ?>">‹ 上一页</a><?php endif; ?>
<form method="get" action="index.php" style="display:inline-flex;gap:6px;align-items:center;margin:0;">
<?php foreach ($baseQuery as $bk => $bv): if ($bk === 'per_page') { continue; } ?><input type="hidden" name="<?= e((string)$bk) ?>" value="<?= e((string)$bv) ?>"><?php endforeach; ?>
<select name="per_page" onchange="this.form.submit()" aria-label="每页条数" style="height:26px;font-size:12px;padding:0 6px;">
<?php foreach ([10, 15, 30, 50] as $pp): ?>
<option value="<?= $pp ?>" <?= $perPage === $pp ? 'selected' : '' ?>>每页 <?= $pp ?> 条</option>
<?php endforeach; ?>
</select>
</form>
<?php if ($currentPage < $totalPages): ?><a class="btn btn--plain btn--sm" href="index.php?<?= $nextQuery ?>">下一页 ›</a><?php endif; ?>
</div>
<?php
pageFooter();