feat(快递资料): 归档合同管理与到期提醒 (#336)
This commit is contained in:
parent
8d7f6da49f
commit
fd4b155979
163
app/Business/CourierContractPageData.php
Normal file
163
app/Business/CourierContractPageData.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/** Read the signed snapshot directly: no duplicate contracts, attachment copies or archive backfill. */
|
||||
class CourierContractPageData
|
||||
{
|
||||
public const REMINDER_DAYS = 30;
|
||||
|
||||
public const STATUSES = [
|
||||
'due' => '即将到期',
|
||||
'expired' => '约定期限已届满',
|
||||
'active' => '有效期内',
|
||||
'upcoming' => '未到开始日期',
|
||||
'unknown' => '期限待核对',
|
||||
];
|
||||
|
||||
public function page(bool $includeRows = true): array
|
||||
{
|
||||
$search = mb_substr(trim((string) request()->query('contract_search', '')), 0, 255);
|
||||
$status = (string) request()->query('contract_status', '');
|
||||
$status = array_key_exists($status, self::STATUSES) ? $status : '';
|
||||
$from = $this->date(request()->query('contract_end_from'));
|
||||
$to = $this->date(request()->query('contract_end_to'));
|
||||
if ($from && $to && $from > $to) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
$filters = ['contract_search' => $search, 'contract_status' => $status,
|
||||
'contract_end_from' => $from ?? '', 'contract_end_to' => $to ?? ''];
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$page = max((int) request()->query('page', 1), 1);
|
||||
$options = ['path' => request()->url(), 'query' => request()->except('page')];
|
||||
$data = [
|
||||
'courierContractFilters' => $filters,
|
||||
'courierContractRows' => new LengthAwarePaginator([], 0, $limit, $page, $options),
|
||||
'courierContractCounts' => array_fill_keys(array_keys(self::STATUSES), 0),
|
||||
'courierContractUnreadable' => 0,
|
||||
'courierContractError' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
if (! Schema::hasTable('business_courier_agreement_signatures')) {
|
||||
$data['courierContractError'] = '协议签署数据表尚未建立,合同管理暂时无法读取。';
|
||||
return $data;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$today = Carbon::today(config('app.timezone'));
|
||||
// Dates and partner types are encrypted. Stream bounded chunks instead of SQL JSON queries
|
||||
// or persisting a second, potentially stale copy of the signed business snapshot.
|
||||
$signatures = CourierAgreementSignature::query()->where('company_id', (int) company_id())
|
||||
->where('document_type', 'cooperation-contract')->where('status', CourierAgreementSignature::STATUS_ARCHIVED)
|
||||
->select(['id', 'company_id', 'business_no', 'courier_name', 'payload', 'document_type', 'status',
|
||||
'signed_pdf_path', 'certificate_path', 'archived_at', 'created_by', 'created_at'])
|
||||
->lazyById(250);
|
||||
foreach ($signatures as $signature) {
|
||||
$payload = $signature->archivedCourierContractPayload();
|
||||
if ($payload === null) {
|
||||
try {
|
||||
$signature->payload;
|
||||
} catch (Throwable) {
|
||||
$data['courierContractUnreadable']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$row = $this->snapshot($signature, $payload, $today);
|
||||
$data['courierContractCounts'][$row['status']]++;
|
||||
if (! $includeRows || ($status !== '' && $status !== $row['status'])) {
|
||||
continue;
|
||||
}
|
||||
if (($from && (! $row['end_date'] || $row['end_date'] < $from))
|
||||
|| ($to && (! $row['end_date'] || $row['end_date'] > $to))) {
|
||||
continue;
|
||||
}
|
||||
if ($search !== '' && ! str_contains(mb_strtolower(implode(' ', [
|
||||
$row['courier_name'], $row['number'], $row['business_no'], $row['party_a'], $row['party_b'],
|
||||
])), mb_strtolower($search))) {
|
||||
continue;
|
||||
}
|
||||
$matched[] = $row;
|
||||
}
|
||||
if ($includeRows) {
|
||||
$priority = ['due' => 0, 'expired' => 1, 'unknown' => 2, 'active' => 3, 'upcoming' => 4];
|
||||
usort($matched, static fn (array $a, array $b): int =>
|
||||
[$priority[$a['status']], $a['end_date'] ?? '9999-12-31', -$a['id']]
|
||||
<=> [$priority[$b['status']], $b['end_date'] ?? '9999-12-31', -$b['id']]);
|
||||
$total = count($matched);
|
||||
$items = array_slice($matched, ($page - 1) * $limit, $limit);
|
||||
$owners = user_model_class()::query()->whereIn('id', array_filter(array_column($items, 'created_by')))
|
||||
->pluck('name', 'id');
|
||||
foreach ($items as &$row) {
|
||||
$row['creator'] = $owners[$row['created_by']] ?? '未记录';
|
||||
$row['has_signed_file'] = $this->listedFileAvailable($row['signed_path']);
|
||||
$row['has_certificate'] = $this->listedFileAvailable($row['certificate_path']);
|
||||
unset($row['signed_path'], $row['certificate_path']);
|
||||
}
|
||||
unset($row);
|
||||
$data['courierContractRows'] = new LengthAwarePaginator($items, $total, $limit, $page, $options);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$data['courierContractCounts'] = array_fill_keys(array_keys(self::STATUSES), 0);
|
||||
$data['courierContractError'] = '归档合同暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function snapshot(CourierAgreementSignature $signature, array $payload, Carbon $today): array
|
||||
{
|
||||
$start = $this->date($payload['cooperation_start_date'] ?? null);
|
||||
$end = $this->date($payload['cooperation_end_date'] ?? null);
|
||||
$days = $end ? (int) $today->diffInDays(Carbon::parse($end, config('app.timezone')), false) : null;
|
||||
$status = match (true) {
|
||||
! $start || ! $end || $start > $end => 'unknown',
|
||||
$end < $today->toDateString() => 'expired',
|
||||
$start > $today->toDateString() => 'upcoming',
|
||||
$days <= self::REMINDER_DAYS => 'due',
|
||||
default => 'active',
|
||||
};
|
||||
$person = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
|
||||
|
||||
return [
|
||||
'id' => (int) $signature->id,
|
||||
'courier_name' => (string) $signature->courier_name,
|
||||
'business_no' => (string) $signature->business_no,
|
||||
'number' => trim((string) ($payload['cooperation_contract_number'] ?? '')),
|
||||
'party_a' => trim((string) ($payload['cooperation_party_a_name'] ?? '')),
|
||||
'party_b' => trim((string) ($payload['cooperation_party_b_name'] ?? '')),
|
||||
'legal_representative' => $person ? '不适用(个人签署)' : trim((string) ($payload['cooperation_party_b_legal_representative'] ?? '')),
|
||||
'start_date' => $start, 'end_date' => $end, 'days' => $status === 'unknown' ? null : $days,
|
||||
'status' => $status, 'created_by' => $signature->created_by,
|
||||
'created_at' => $signature->created_at, 'archived_at' => $signature->archived_at,
|
||||
'signed_path' => $signature->signed_pdf_path, 'certificate_path' => $signature->certificate_path,
|
||||
];
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return null;
|
||||
}
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
return checkdate($month, $day, $year) ? $value : null;
|
||||
}
|
||||
|
||||
private function listedFileAvailable(?string $path): bool
|
||||
{
|
||||
if (blank($path)) {
|
||||
return false;
|
||||
}
|
||||
// Do not issue up to 50 sequential object-storage HEAD requests for one list page.
|
||||
// Downloads always verify the actual file; local files can be checked cheaply here.
|
||||
return config('filesystems.disks.uploads.driver') !== 'local' || Storage::disk('uploads')->exists($path);
|
||||
}
|
||||
}
|
||||
@ -523,6 +523,8 @@ class CourierAgreementSignatures extends Controller
|
||||
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
'cooperation_party_b_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
|
||||
'cooperation_party_b_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
'cooperation_contract_number' => ['nullable', 'string', 'max:100'],
|
||||
'cooperation_party_b_legal_representative' => ['nullable', 'string', 'max:100'],
|
||||
'cooperation_start_date' => ['required_if:document_type,cooperation-contract', 'date'],
|
||||
'cooperation_end_date' => ['required_if:document_type,cooperation-contract', 'date', 'after_or_equal:cooperation_start_date'],
|
||||
'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
|
||||
@ -545,6 +547,11 @@ class CourierAgreementSignatures extends Controller
|
||||
$payload = array_intersect_key($data, array_flip($allowed));
|
||||
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']);
|
||||
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
|
||||
if (($data['document_type'] ?? '') === 'cooperation-contract' && ($data['partner_type'] ?? 'courier') === 'courier') {
|
||||
$payload['cooperation_contract_number'] = trim((string) ($data['cooperation_contract_number'] ?? ''));
|
||||
$payload['cooperation_party_b_legal_representative'] = ($data['counterparty_signer_type'] ?? 'organization') === 'person'
|
||||
? null : trim((string) ($data['cooperation_party_b_legal_representative'] ?? ''));
|
||||
}
|
||||
if (($data['document_type'] ?? null) === 'entity-change') {
|
||||
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
|
||||
$payload['entity_proof_date'] = $data['signature_date'] ?? null;
|
||||
|
||||
28
app/Http/Controllers/Business/CourierContracts.php
Normal file
28
app/Http/Controllers/Business/CourierContracts.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierContracts extends Controller
|
||||
{
|
||||
/** A read-only archive entry point with courier-information page access, not signing-management access. */
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse
|
||||
{
|
||||
abort_unless($agreementSignature->company_id === (int) company_id()
|
||||
&& $agreementSignature->archivedCourierContractPayload() !== null, 404);
|
||||
$path = match ($kind) {
|
||||
'signed' => $agreementSignature->signed_pdf_path,
|
||||
'certificate' => $agreementSignature->certificate_path,
|
||||
default => abort(404),
|
||||
};
|
||||
abort_if(blank($path) || ! Storage::disk('uploads')->exists($path), 404, '归档附件不存在,请核对协议签署记录。');
|
||||
$suffix = $kind === 'signed' ? '已签署合作合同' : '签署完成证书';
|
||||
|
||||
return Storage::disk('uploads')->response($path, $agreementSignature->business_no . '-' . $suffix . '.pdf',
|
||||
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
|
||||
}
|
||||
}
|
||||
@ -6754,9 +6754,16 @@ class Pages extends Controller
|
||||
protected function contentForCourierInformation(): array
|
||||
{
|
||||
$filters = $this->courierInformationFilters(request());
|
||||
// 快递资料四类:快递资料(主信息)/ 网点 / 打款信息 / 操作日志;点开主信息在下方看这家快递的明细。
|
||||
// 合同管理独立读取协议归档,不依赖上游快递镜像的可用性。
|
||||
$tab = (string) request()->query('courier_tab', 'express');
|
||||
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express';
|
||||
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs', 'contracts'], true) ? $tab : 'express';
|
||||
$contracts = app(\App\Business\CourierContractPageData::class)->page($tab === 'contracts');
|
||||
if ($tab === 'contracts') {
|
||||
return $contracts + [
|
||||
'view' => 'business.pages.courier-payables.courier-information',
|
||||
'courierTab' => $tab, 'courierFilters' => [], 'courierError' => null, 'courierPending' => false,
|
||||
];
|
||||
}
|
||||
$page = max((int) request()->query('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
@ -6874,7 +6881,7 @@ class Pages extends Controller
|
||||
'courierError' => $error,
|
||||
'courierPending' => $pending,
|
||||
'courierRelatedAvailability' => $relatedAvailability,
|
||||
];
|
||||
] + $contracts;
|
||||
}
|
||||
|
||||
/** 选中一行主信息(?selected=快递公司代码)后,下方显示这家快递的网点 / 打款信息 / 操作日志。 */
|
||||
|
||||
@ -100,6 +100,23 @@ class CourierAgreementSignature extends Model
|
||||
return $partnerType === 'supplier' ? '供应商' : '快递公司';
|
||||
}
|
||||
|
||||
/** Only completed archives with a readable courier snapshot belong to contract management. */
|
||||
public function archivedCourierContractPayload(): ?array
|
||||
{
|
||||
if ($this->status !== self::STATUS_ARCHIVED || $this->document_type !== 'cooperation-contract') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->payload;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Before partner_type was introduced all signing tasks were courier tasks.
|
||||
return is_array($payload) && ($payload['partner_type'] ?? 'courier') === 'courier' ? $payload : null;
|
||||
}
|
||||
|
||||
public function credentialDocuments(): array
|
||||
{
|
||||
try {
|
||||
|
||||
35
docs/快递合同管理.md
Normal file
35
docs/快递合同管理.md
Normal file
@ -0,0 +1,35 @@
|
||||
# 快递合同管理
|
||||
|
||||
入口:快递应付 → 快递资料 → 操作日志后的「合同管理」。标签切换、筛选、分页在当前页面局部更新。
|
||||
|
||||
## 收录规则
|
||||
|
||||
直接读取当前公司的协议签署归档记录,不复制合同或附件、不新增业务表。合作方为快递公司、文件类型为合作合同、状态为「已签署并归档」时自动显示,历史记录同样适用。待签署、签署完成但待归档、供应商合同、其他证明、已删除记录不显示。
|
||||
|
||||
合作方分类上线前的记录按原有快递任务处理;加密快照无法读取时不猜测合作方,提示未纳入列表和提醒。快递资料的工作平台镜像不可用不影响合同管理。
|
||||
|
||||
## 字段口径
|
||||
|
||||
| 字段 | 来源与注意事项 |
|
||||
| --- | --- |
|
||||
| 快递公司 | 发起签署时选择的快递名称 |
|
||||
| 合同编号 | 合作合同发起表单中的正式编号;选填,填写后显示在合同预览和签署 PDF 中;不冒用系统签署单号 |
|
||||
| 开始、结束日期 | 合作合同签署快照里的协议期限;不使用签署流程截止时间 |
|
||||
| 乙方合同签署主体 | 合同乙方名称快照,可为企业或个人 |
|
||||
| 乙方法定代表人 | 发起合作合同时选填,以营业执照为准;不取经办人姓名,个人签署标记不适用 |
|
||||
| 创建人、创建时间 | 签署任务创建账号及时间,不使用合同列表首次打开时间 |
|
||||
| 甲方签署主体 | 合同甲方名称快照 |
|
||||
| 归档时间、签署单号 | 原签署任务的归档时间和系统业务单号 |
|
||||
| 已签署附件、签署完成证书 | 复用原私有归档文件,先校验页面权限、公司、合作方类型、合同类型和归档状态,再查看或下载 |
|
||||
|
||||
历史缺失信息显示「未填写」或「待核对」,不修改已经签署的快照。未登记附件时明确提示,不用未签署原文冒充;远程存储文件在打开时检查实际存在性,避免列表逐条请求对象存储拖慢页面。
|
||||
|
||||
## 到期提醒
|
||||
|
||||
当前版本为页面内提醒:快递资料各标签顶部展示提醒,合同管理标签带待关注数量。默认提前30天提醒,包含结束日期当天;从次日起标记「约定期限已届满」。另区分有效期内、未到开始日期、期限待核对。缺失、无效或倒置的日期不计算剩余天数。
|
||||
|
||||
提醒统计覆盖当前公司全部符合条件的归档合同,不只统计当前页或筛选结果。列表将即将到期的合同置前,支持按快递名称、编号、甲乙方主体、系统签署单号搜索,以及期限状态、结束日期区间筛选。
|
||||
|
||||
现有合作合同第2.2条包含自动续期约定,因此「约定期限已届满」不等于合同已终止,必须结合续签和自动续期条款核对。本功能不擅自续期、终止或更改合同日期,不发送短信、邮件或钉钉通知。
|
||||
|
||||
建议后续按实际管理流程补充:续签负责人、续签处理状态、自动续期条款备注,以及需要时的消息提醒渠道。避免把银行卡、证件号码等不必要的敏感资料铺在列表上。
|
||||
@ -1846,6 +1846,15 @@
|
||||
<label for="agreement-cooperation-end-date">协议结束日期 <span class="agreement-required" aria-hidden="true">*</span></label>
|
||||
<input class="agreement-control" id="agreement-cooperation-end-date" name="cooperation_end_date" type="date" value="{{ now()->addYear()->toDateString() }}" required>
|
||||
</div>
|
||||
<div class="agreement-field" data-courier-contract-metadata>
|
||||
<label for="agreement-cooperation-contract-number">合同编号(选填)</label>
|
||||
<input class="agreement-control" id="agreement-cooperation-contract-number" name="cooperation_contract_number" type="text" maxlength="100" placeholder="填写正式合同编号,不填写则合同管理显示未填写">
|
||||
</div>
|
||||
<div class="agreement-field" data-courier-contract-metadata data-courier-contract-legal>
|
||||
<label for="agreement-cooperation-party-b-legal">乙方法定代表人(选填)</label>
|
||||
<input class="agreement-control" id="agreement-cooperation-party-b-legal" name="cooperation_party_b_legal_representative" type="text" maxlength="100" placeholder="以营业执照为准,不是签章经办人">
|
||||
<p class="agreement-help">用于合同管理登记;不会替代 E 签宝实名认证姓名。个人签署不适用。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-supplier-contract-fields hidden>
|
||||
@ -3441,6 +3450,11 @@
|
||||
: '必须与对方 E 签宝企业认证名称一致;这是签署主体,不要求与银行卡持卡人姓名一致。';
|
||||
}
|
||||
activateTemplate(supplierContract ? 'supplier-cooperation' : templateType);
|
||||
root.querySelectorAll('[data-courier-contract-metadata]').forEach(function (field) {
|
||||
var visible = cooperationContract && !supplierContract && (!field.hasAttribute('data-courier-contract-legal') || !otherSignerIsPerson);
|
||||
field.hidden = !visible;
|
||||
field.querySelectorAll('input').forEach(function (input) { input.disabled = !visible; });
|
||||
});
|
||||
root.querySelector('[data-supplier-contract-fields]').hidden = !supplierContract;
|
||||
supplierContractSelect.disabled = !supplierContract;
|
||||
root.querySelectorAll('[data-supplier-field-templates]').forEach(function (group) {
|
||||
@ -3562,6 +3576,9 @@
|
||||
}
|
||||
|
||||
if (templateType === 'cooperation-contract') {
|
||||
var contractNumber = fieldValue('[name="cooperation_contract_number"]', '');
|
||||
setPreviewValue('cooperationContractNumber', contractNumber);
|
||||
root.querySelector('[data-preview-contract-number]').hidden = !contractNumber;
|
||||
setPreviewValue('cooperationPartyAName', fieldValue('[name="cooperation_party_a_name"]', companyName));
|
||||
setPreviewValue('cooperationPartyAPhone', fieldValue('[name="cooperation_party_a_phone"]', '待填写'));
|
||||
setPreviewValue('cooperationPartyBName', fieldValue('[name="cooperation_party_b_name"]', courier === '快递公司' ? '待填写' : courier));
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<div data-courier-information-region>
|
||||
@php
|
||||
$tab = $courierTab ?? 'express';
|
||||
$filters = $courierFilters ?? [];
|
||||
@ -53,6 +54,8 @@
|
||||
$detail = $courierDetail ?? null;
|
||||
$selectedCode = (string) ($detail['code'] ?? '');
|
||||
$selectedRow = $detail['row'] ?? null;
|
||||
$contractCounts = $courierContractCounts ?? [];
|
||||
$contractAttention = ($contractCounts['due'] ?? 0) + ($contractCounts['expired'] ?? 0);
|
||||
$selectUrl = static function (string $code, array $extra = []) use ($filters): string {
|
||||
$page = (int) request('page', 1);
|
||||
|
||||
@ -107,6 +110,7 @@
|
||||
|
||||
.courier-information-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 24px;
|
||||
min-height: 48px;
|
||||
margin-bottom: 16px;
|
||||
@ -344,7 +348,30 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.courier-contract-explanation { margin: 0 0 12px; color: var(--vip-content-muted, #595959); line-height: 1.6; }
|
||||
.courier-contract-table { min-width: 2100px; }
|
||||
.courier-contract-panel { overflow: visible; }
|
||||
.courier-contract-panel .courier-information-table-wrap.finance-table-scroll-owner { max-height: max(12rem, calc(100vh - 38rem)); }
|
||||
.courier-contract-panel .finance-table-field-picker__panel { right: auto; left: 0; max-width: calc(100vw - 64px); }
|
||||
.courier-contract-table td { white-space: nowrap; }
|
||||
.courier-contract-status { font-size: .75rem; color: var(--vip-content-muted, #595959); }
|
||||
.courier-contract-status.is-due, .courier-contract-status.is-unknown { color: var(--vip-warning, #935f07); }
|
||||
.courier-contract-status.is-expired { color: var(--vip-danger, #b80000); }
|
||||
.courier-contract-status.is-active { color: var(--vip-success, #53793e); }
|
||||
.courier-contract-attention { padding: 1px 5px; border-radius: 4px; background: var(--vip-content-accent-soft, #f7f0de); color: var(--vip-warning, #935f07); font-size: .75rem; }
|
||||
.courier-information-notice > a { flex-shrink: 0; white-space: nowrap; }
|
||||
.courier-information-local-feedback[hidden] { display: none; }
|
||||
[data-courier-information][aria-busy="true"] { cursor: progress; }
|
||||
@media (max-width: 560px) {
|
||||
.courier-contract-panel .finance-table-field-picker__panel { grid-template-columns: 1fr; max-height: 50vh; overflow: auto; }
|
||||
.courier-contract-panel .courier-information-footer { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="courier-information-page" data-courier-information>
|
||||
<div class="courier-information-notice is-error courier-information-local-feedback" role="alert" hidden><span data-courier-local-error></span><button type="button" class="courier-information-button" data-courier-local-retry>重试</button></div>
|
||||
@if ($tab !== 'contracts')
|
||||
<div class="courier-information-status" role="status">
|
||||
<div class="courier-information-status__main">
|
||||
<span class="courier-information-status__dot {{ $statusClass }}" aria-hidden="true"></span>
|
||||
@ -365,6 +392,7 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<nav class="courier-information-tabs" aria-label="快递资料分组">
|
||||
@foreach ([
|
||||
@ -372,15 +400,27 @@
|
||||
'networks' => ['icon' => 'storefront', 'label' => '网点'],
|
||||
'accounts' => ['icon' => 'account_balance', 'label' => '打款信息'],
|
||||
'logs' => ['icon' => 'history', 'label' => '操作日志'],
|
||||
'contracts' => ['icon' => 'description', 'label' => '合同管理'],
|
||||
] as $key => $item)
|
||||
<a class="courier-information-tab {{ $tab === $key ? 'is-active' : '' }}" href="{{ $tabUrl($key) }}" @if ($tab === $key) aria-current="page" @endif>
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $item['icon'] }}</span>
|
||||
<span>{{ $item['label'] }}</span>
|
||||
@if ($key === 'contracts' && $contractAttention > 0)<span class="courier-contract-attention" title="即将到期及约定期限已届满">{{ $contractAttention }}</span>@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@if ($tab === 'express')
|
||||
@if (! ($courierContractError ?? null) && ($contractAttention > 0 || ($contractCounts['unknown'] ?? 0) > 0))
|
||||
<div class="courier-information-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">event_available</span>
|
||||
<span>合同到期提醒:{{ $contractCounts['due'] ?? 0 }} 份将在30天内到期(含今天),{{ $contractCounts['expired'] ?? 0 }} 份约定期限已届满,{{ $contractCounts['unknown'] ?? 0 }} 份期限待核对。请及时核对续签或自动续期。</span>
|
||||
<a href="{{ $tabUrl('contracts') }}" data-courier-local-link>查看合同</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($tab === 'contracts')
|
||||
@include('business.pages.courier-payables.partials.courier-contracts')
|
||||
@elseif ($tab === 'express')
|
||||
<form method="GET" class="courier-information-filters" aria-label="快递资料筛选">
|
||||
<input type="hidden" name="courier_tab" value="express">
|
||||
<div class="courier-information-field">
|
||||
@ -567,7 +607,7 @@
|
||||
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页 · 工作平台账户共 {{ number_format($rows->total()) }} 条;财务系统的打款信息(入驻对公/对私、新增/变更)显示在第一页最前面</span><nav class="courier-information-pagination" aria-label="打款信息分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@elseif ($tab === 'logs')
|
||||
<div class="courier-information-table-panel">
|
||||
<div class="courier-information-table-wrap">
|
||||
@include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $courierFinanceEvents ?? collect(), 'platformLogs' => $rows ?? [], 'showCourier' => true, 'emptyText' => '当前筛选条件下没有操作日志。'])
|
||||
@ -585,6 +625,64 @@
|
||||
if (!page || page.dataset.ready === 'true') return;
|
||||
page.dataset.ready = 'true';
|
||||
|
||||
var pendingUrl = null;
|
||||
var requestController = null;
|
||||
async function navigate(url, pushHistory) {
|
||||
pendingUrl = url;
|
||||
if (requestController) requestController.abort();
|
||||
var controller = new AbortController();
|
||||
requestController = controller;
|
||||
var feedback = page.querySelector('.courier-information-local-feedback');
|
||||
feedback.hidden = true;
|
||||
page.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
var response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, signal: controller.signal });
|
||||
if (!response.ok) throw new Error('页面暂时无法加载,请重试。');
|
||||
var parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
var next = parsed.querySelector('[data-courier-information-region]');
|
||||
if (!next) throw new Error('页面无法加载,请核对登录状态后重试。');
|
||||
if (controller.signal.aborted) return;
|
||||
page.closest('[data-courier-information-region]').replaceWith(next);
|
||||
if (pushHistory !== false) window.history.pushState({ courierInformation: true }, '', url);
|
||||
next.querySelectorAll('script').forEach(function (oldScript) {
|
||||
var script = document.createElement('script');
|
||||
script.textContent = oldScript.textContent;
|
||||
oldScript.replaceWith(script);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
feedback.querySelector('[data-courier-local-error]').textContent = error.message;
|
||||
feedback.hidden = false;
|
||||
} finally {
|
||||
if (requestController === controller) page.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
window.__courierInformationNavigate = navigate;
|
||||
if (!window.__courierInformationHistoryReady) {
|
||||
window.__courierInformationHistoryReady = true;
|
||||
window.addEventListener('popstate', function () {
|
||||
if (document.querySelector('[data-courier-information]')) window.__courierInformationNavigate(window.location.href, false);
|
||||
});
|
||||
}
|
||||
page.addEventListener('click', function (event) {
|
||||
var retry = event.target.closest('[data-courier-local-retry]');
|
||||
if (retry && pendingUrl) { navigate(pendingUrl); return; }
|
||||
var link = event.target.closest('.courier-information-tab, .courier-information-page-link, [data-courier-local-link]');
|
||||
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!link.classList.contains('is-disabled')) navigate(link.href);
|
||||
}, true);
|
||||
page.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form.matches('.courier-information-filters') || form.method.toLowerCase() !== 'get') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var url = new URL(form.action || window.location.href);
|
||||
url.search = new URLSearchParams(new FormData(form)).toString();
|
||||
navigate(url.toString());
|
||||
}, true);
|
||||
|
||||
// 点表格行(不是点链接/按钮时)= 点开这家快递的明细。
|
||||
page.addEventListener('click', function (event) {
|
||||
var row = event.target.closest('tr[data-select-url]');
|
||||
@ -663,3 +761,4 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
@php
|
||||
$contractRows = $courierContractRows;
|
||||
$contractFilters = $courierContractFilters;
|
||||
$contractLabels = \App\Business\CourierContractPageData::STATUSES;
|
||||
$fileUrl = static fn (int $id, string $kind, bool $preview = false): string => route('business.courier-contracts.download', ['agreementSignature' => $id, 'kind' => $kind] + ($preview ? ['preview' => 1] : []));
|
||||
@endphp
|
||||
|
||||
<form method="GET" class="courier-information-filters" aria-label="合同管理筛选">
|
||||
<input type="hidden" name="courier_tab" value="contracts">
|
||||
<div class="courier-information-field">
|
||||
<label for="courier-contract-search">快递公司 / 合同编号 / 签署主体</label>
|
||||
<input id="courier-contract-search" name="contract_search" maxlength="255" value="{{ $contractFilters['contract_search'] }}" placeholder="输入关键词,也可查签署单号">
|
||||
</div>
|
||||
<div class="courier-information-field">
|
||||
<label for="courier-contract-status">合同期限状态</label>
|
||||
<select id="courier-contract-status" name="contract_status">
|
||||
<option value="">全部状态</option>
|
||||
@foreach ($contractLabels as $key => $label)<option value="{{ $key }}" @selected($contractFilters['contract_status'] === $key)>{{ $label }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="courier-information-field">
|
||||
<label for="courier-contract-end-from">合同结束日期(起)</label>
|
||||
<input id="courier-contract-end-from" name="contract_end_from" type="date" value="{{ $contractFilters['contract_end_from'] }}">
|
||||
</div>
|
||||
<div class="courier-information-field">
|
||||
<label for="courier-contract-end-to">合同结束日期(止)</label>
|
||||
<input id="courier-contract-end-to" name="contract_end_to" type="date" value="{{ $contractFilters['contract_end_to'] }}">
|
||||
</div>
|
||||
<div class="courier-information-actions">
|
||||
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
|
||||
<a class="courier-information-button" href="{{ $tabUrl('contracts') }}" data-courier-local-link>重置</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="courier-contract-explanation">自动收录“快递公司 · 合作合同 · 已签署并归档”,直接读取签署快照与归档附件,不重复复制。按合同约定结束日期提前{{ \App\Business\CourierContractPageData::REMINDER_DAYS }}天提醒(含到期当天);期限届满不等于合同已终止,请核对合同的自动续期条款。合同编号、法定代表人缺失时请核对原始合同,不以签署单号或经办人代替。</p>
|
||||
|
||||
@if ($courierContractError)
|
||||
<div class="courier-information-notice is-error" role="alert"><span>{{ $courierContractError }}</span><a href="{{ request()->fullUrl() }}" data-courier-local-link>重试</a></div>
|
||||
@endif
|
||||
@if ($courierContractUnreadable > 0)
|
||||
<div class="courier-information-notice" role="status">有 {{ $courierContractUnreadable }} 条归档记录的加密快照无法读取,合作方类型无法核对,未纳入合同列表和提醒。请联系管理员核对原签署记录。</div>
|
||||
@endif
|
||||
|
||||
<div class="courier-information-table-panel courier-contract-panel" data-finance-table-surface>
|
||||
<div class="courier-information-table-wrap">
|
||||
<table class="courier-information-table courier-contract-table" id="courier-contract-table" aria-label="快递合同管理">
|
||||
<thead><tr>
|
||||
<th>快递公司</th><th>期限状态</th><th>合同编号</th><th>合同开始日期</th><th>合同结束日期</th><th>距到期天数</th><th>已签署合作合同附件</th>
|
||||
<th>乙方合同签署主体</th><th>乙方法定代表人</th><th>创建人</th><th>创建时间</th><th>甲方签署主体</th><th>归档时间</th><th>签署单号</th><th>签署完成证书</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@forelse ($contractRows as $contract)
|
||||
<tr data-contract-id="{{ $contract['id'] }}">
|
||||
<td><strong>{{ $contract['courier_name'] }}</strong></td>
|
||||
<td><span class="courier-contract-status is-{{ $contract['status'] }}">{{ $contractLabels[$contract['status']] }}</span></td>
|
||||
<td>{{ $contract['number'] ?: '未填写' }}</td>
|
||||
<td class="is-code">{{ $contract['start_date'] ?: '待核对' }}</td>
|
||||
<td class="is-code">{{ $contract['end_date'] ?: '待核对' }}</td>
|
||||
<td class="is-code">@if ($contract['days'] === null)无法计算@elseif ($contract['days'] < 0)已届满 {{ abs($contract['days']) }} 天@elseif ($contract['days'] === 0)今天到期@else{{ $contract['days'] }} 天@endif</td>
|
||||
<td>
|
||||
@if ($contract['has_signed_file'])
|
||||
<a href="{{ $fileUrl($contract['id'], 'signed', true) }}" target="_blank" rel="noopener">查看已签署合同</a> · <a href="{{ $fileUrl($contract['id'], 'signed') }}">下载</a>
|
||||
@else
|
||||
<span class="courier-contract-status is-unknown">归档附件缺失,请核对</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $contract['party_b'] ?: '未填写' }}</td>
|
||||
<td>{{ $contract['legal_representative'] ?: '未填写' }}</td>
|
||||
<td>{{ $contract['creator'] }}</td>
|
||||
<td class="is-code">{{ $datetime($contract['created_at']) }}</td>
|
||||
<td>{{ $contract['party_a'] ?: '未填写' }}</td>
|
||||
<td class="is-code">{{ $datetime($contract['archived_at']) }}</td>
|
||||
<td class="is-code">{{ $contract['business_no'] }}</td>
|
||||
<td>@if ($contract['has_certificate'])<a href="{{ $fileUrl($contract['id'], 'certificate') }}">下载证书</a>@else未归档证书@endif</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="is-empty"><td colspan="15" class="courier-information-empty">{{ $courierContractError ? '合同数据暂时不可用,请重试。' : '当前条件下没有已归档的快递合作合同。签署完成且归档后会自动显示,待签署、待归档和供应商合同不在此处展示。' }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (! $courierContractError)
|
||||
<div class="courier-information-footer">
|
||||
<span>第 {{ $contractRows->currentPage() }} / {{ max($contractRows->lastPage(), 1) }} 页,本页 {{ $contractRows->count() }} 条,共 {{ number_format($contractRows->total()) }} 条 · 即将到期优先显示</span>
|
||||
<nav class="courier-information-pagination" aria-label="合同管理分页">
|
||||
<a class="courier-information-page-link {{ $contractRows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $contractRows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
|
||||
<a class="courier-information-page-link {{ $contractRows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $contractRows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -3,6 +3,12 @@
|
||||
$payload = is_array($payload ?? null) ? $payload : [];
|
||||
@endphp
|
||||
|
||||
@if ($preview)
|
||||
<p data-preview-contract-number hidden>合同编号:<span class="agreement-preview-value" data-preview-bind="cooperationContractNumber"></span></p>
|
||||
@elseif (filled($payload['cooperation_contract_number'] ?? null))
|
||||
<p>合同编号:{!! $mixedText($payload['cooperation_contract_number']) !!}</p>
|
||||
@endif
|
||||
|
||||
@if ($preview)
|
||||
<div class="agreement-preview-parties">
|
||||
<div><span>甲 方:</span><span class="agreement-preview-value" data-preview-bind="cooperationPartyAName">待填写</span><span class="agreement-preview-party-phone">电 话:<span class="agreement-preview-value" data-preview-bind="cooperationPartyAPhone">待填写</span></span></div>
|
||||
|
||||
@ -197,6 +197,10 @@ Route::prefix('workspace/courier-payables/agreement-signatures')
|
||||
Route::get('quote-signatures/{quoteSignature}/files/{kind}', [CourierAgreementSignatures::class, 'downloadQuote'])->name('quotes.download');
|
||||
});
|
||||
|
||||
Route::get('workspace/courier-payables/courier-information/contracts/{agreementSignature}/files/{kind}', 'Business\CourierContracts@download')
|
||||
->middleware('business.page:courier-payables,courier-information')
|
||||
->name('business.courier-contracts.download');
|
||||
|
||||
Route::get('workspace/product-inventory/normal-products/export', 'Business\Pages@exportNormalProducts')
|
||||
->middleware('business.page:product-inventory,normal-products')
|
||||
->name('business.product-inventory.normal-products.export');
|
||||
|
||||
@ -504,6 +504,8 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
{
|
||||
$this->fakeEsign();
|
||||
$payload = $this->cooperationContractPayload('cooperation-001');
|
||||
$payload['cooperation_contract_number'] = ' KD-2026-009 ';
|
||||
$payload['cooperation_party_b_legal_representative'] = ' 王法人 ';
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
@ -516,6 +518,8 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
$this->assertSame('李经理', $signature->payload['secondary_operator_name']);
|
||||
$this->assertSame('0519-88886666', $signature->payload['cooperation_party_a_phone']);
|
||||
$this->assertSame('2026-12-31', $signature->payload['cooperation_end_date']);
|
||||
$this->assertSame('KD-2026-009', $signature->payload['cooperation_contract_number']);
|
||||
$this->assertSame('王法人', $signature->payload['cooperation_party_b_legal_representative']);
|
||||
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') {
|
||||
@ -535,6 +539,19 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_cooperation_contract_rejects_overlong_registry_metadata_before_sending(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->withExceptionHandling();
|
||||
$payload = $this->cooperationContractPayload('contract-metadata-too-long');
|
||||
$payload['cooperation_contract_number'] = str_repeat('X', 101);
|
||||
$payload['cooperation_party_b_legal_representative'] = str_repeat('法', 101);
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)
|
||||
->assertSessionHasErrors(['cooperation_contract_number', 'cooperation_party_b_legal_representative']);
|
||||
$this->assertSame(0, CourierAgreementSignature::query()->count());
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_cooperation_pdf_preserves_clauses_and_uses_a_supported_font_for_static_numbers(): void
|
||||
{
|
||||
$payload = $this->cooperationContractPayload('cooperation-font');
|
||||
|
||||
201
tests/Feature/Business/CourierContractManagementTest.php
Normal file
201
tests/Feature/Business/CourierContractManagementTest.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierContractManagementTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-15 15:30:00', config('app.timezone')));
|
||||
Storage::fake('uploads');
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
// Contract management must work even when upstream courier mirrors are unavailable.
|
||||
$this->app->instance(CourierInformationMirrorRepository::class, Mockery::mock(CourierInformationMirrorRepository::class));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_only_archived_courier_cooperation_contracts_are_visible_without_duplicate_writes(): void
|
||||
{
|
||||
$contract = $this->signature('快递测试公司');
|
||||
$this->signature('供应商不应出现', ['partner_type' => 'supplier']);
|
||||
$this->signature('未知合作方不应出现', ['partner_type' => 'other']);
|
||||
$this->signature('待归档不应出现', [], ['status' => 'completed']);
|
||||
$this->signature('证明文件不应出现', [], ['document_type' => 'account-proof']);
|
||||
$this->signature('其他租户不应出现', [], ['company_id' => $this->company->id + 10]);
|
||||
$deleted = $this->signature('已删除不应出现');
|
||||
$deleted->delete();
|
||||
$count = DB::table('business_courier_agreement_signatures')->count();
|
||||
|
||||
$this->page()->assertOk()->assertSeeText('快递测试公司')->assertSeeText('KD-2026-001')
|
||||
->assertSeeText('乙方签署企业')->assertSeeText('法人测试姓名')->assertSeeText($this->user->name)
|
||||
->assertSeeText('查看已签署合同')->assertSeeText('合同管理')->assertSeeText('甲方签署主体')
|
||||
->assertDontSeeText('供应商不应出现')->assertDontSeeText('未知合作方不应出现')
|
||||
->assertDontSeeText('待归档不应出现')->assertDontSeeText('证明文件不应出现')
|
||||
->assertDontSeeText('其他租户不应出现')->assertDontSeeText('已删除不应出现')
|
||||
->assertSee(route('business.courier-contracts.download', ['agreementSignature' => $contract->id, 'kind' => 'signed']), false);
|
||||
$this->page()->assertOk();
|
||||
$this->assertSame($count, DB::table('business_courier_agreement_signatures')->count());
|
||||
}
|
||||
|
||||
public function test_reminders_use_contract_dates_and_include_today_and_day_thirty(): void
|
||||
{
|
||||
$this->signature('今天到期快递', ['cooperation_end_date' => '2026-10-15']);
|
||||
$this->signature('三十天边界快递', ['cooperation_end_date' => '2026-11-14']);
|
||||
$this->signature('有效期快递', ['cooperation_end_date' => '2026-11-15']);
|
||||
$this->signature('届满快递', ['cooperation_end_date' => '2026-10-14']);
|
||||
$this->signature('未来开始快递', ['cooperation_start_date' => '2026-10-20', 'cooperation_end_date' => '2026-11-01']);
|
||||
$response = $this->page()->assertOk()->assertSeeText('今天到期')->assertSeeText('已届满 1 天');
|
||||
$counts = $response->viewData('content')['courierContractCounts'];
|
||||
$this->assertSame(['due' => 2, 'expired' => 1, 'active' => 1, 'upcoming' => 1, 'unknown' => 0], $counts);
|
||||
$this->page(['contract_status' => 'due'])->assertSeeText('今天到期快递')->assertSeeText('三十天边界快递')
|
||||
->assertDontSeeText('有效期快递')->assertDontSeeText('届满快递')->assertDontSeeText('未来开始快递');
|
||||
}
|
||||
|
||||
public function test_missing_invalid_and_personal_metadata_is_not_guessed_from_signer_or_deadline(): void
|
||||
{
|
||||
$this->signature('缺日期快递', ['cooperation_start_date' => null, 'cooperation_end_date' => null,
|
||||
'cooperation_contract_number' => null, 'cooperation_party_b_legal_representative' => null],
|
||||
['operator_name' => '经办人不是法人', 'expires_at' => '2026-10-16']);
|
||||
$this->signature('无效日期快递', ['cooperation_end_date' => '2026-02-30']);
|
||||
$this->signature('倒置日期快递', ['cooperation_start_date' => '2027-01-01']);
|
||||
$this->signature('个人签署快递', ['counterparty_signer_type' => 'person']);
|
||||
$legacy = $this->signature('旧快递合同', ['partner_type' => null]);
|
||||
$payload = $legacy->payload;
|
||||
unset($payload['partner_type']);
|
||||
$legacy->update(['payload' => $payload]);
|
||||
$this->page()->assertOk()->assertSeeText('期限待核对')->assertSeeText('无法计算')->assertSeeText('未填写')
|
||||
->assertSeeText('不适用(个人签署)')->assertSeeText('旧快递合同')->assertDontSeeText('经办人不是法人');
|
||||
$this->page(['contract_status' => 'unknown'])->assertSeeText('缺日期快递')->assertSeeText('无效日期快递')
|
||||
->assertSeeText('倒置日期快递')->assertDontSeeText('个人签署快递');
|
||||
}
|
||||
|
||||
public function test_search_date_filter_and_pagination_apply_to_all_archives_not_just_the_current_page(): void
|
||||
{
|
||||
setting()->set('default.list_limit', 2);
|
||||
setting()->save();
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->signature('分页快递-' . $i, ['cooperation_contract_number' => 'PAGE-' . $i, 'cooperation_end_date' => '2026-11-0' . $i]);
|
||||
}
|
||||
$this->page()->assertSeeText('第 1 / 3 页,本页 2 条,共 5 条');
|
||||
$this->page(['page' => 3])->assertSeeText('分页快递-5')->assertDontSeeText('分页快递-1');
|
||||
$this->page(['contract_search' => 'PAGE-5'])->assertSeeText('分页快递-5')->assertDontSeeText('分页快递-1');
|
||||
$this->page(['contract_search' => '乙方签署企业', 'contract_end_from' => '2026-11-04', 'contract_end_to' => '2026-11-03'])
|
||||
->assertSeeText('分页快递-3')->assertSeeText('分页快递-4')->assertDontSeeText('分页快递-5');
|
||||
}
|
||||
|
||||
public function test_unreadable_archive_and_missing_attachment_are_explicit_and_not_exposed(): void
|
||||
{
|
||||
$unreadable = $this->signature('无法解密不应出现');
|
||||
DB::table('business_courier_agreement_signatures')->where('id', $unreadable->id)->update(['payload' => 'invalid-encrypted-payload']);
|
||||
$missing = $this->signature('附件缺失快递');
|
||||
Storage::disk('uploads')->delete($missing->signed_pdf_path);
|
||||
$this->page()->assertOk()->assertSeeText('加密快照无法读取')->assertDontSeeText('无法解密不应出现')
|
||||
->assertSeeText('归档附件缺失,请核对')->assertDontSee($missing->signed_pdf_path, false);
|
||||
$this->withExceptionHandling();
|
||||
$this->download($unreadable)->assertNotFound();
|
||||
$this->download($missing)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_download_rechecks_tenant_archive_type_and_partner_and_never_serves_unsigned_originals(): void
|
||||
{
|
||||
$contract = $this->signature('可下载合同');
|
||||
$this->download($contract)->assertOk()->assertHeader('content-type', 'application/pdf');
|
||||
$this->download($contract, 'certificate')->assertOk();
|
||||
$this->withExceptionHandling();
|
||||
foreach ([
|
||||
$this->signature('其他公司', [], ['company_id' => $this->company->id + 10]),
|
||||
$this->signature('待归档', [], ['status' => 'completed']),
|
||||
$this->signature('供应商', ['partner_type' => 'supplier']),
|
||||
$this->signature('非合同', [], ['document_type' => 'account-proof']),
|
||||
] as $ineligible) {
|
||||
$this->download($ineligible)->assertNotFound();
|
||||
}
|
||||
$this->download($contract, 'source')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_read_failure_is_not_presented_as_an_empty_healthy_archive(): void
|
||||
{
|
||||
Schema::drop('business_courier_agreement_signatures');
|
||||
$this->page()->assertOk()->assertSeeText('协议签署数据表尚未建立')->assertSeeText('合同数据暂时不可用,请重试。');
|
||||
}
|
||||
|
||||
public function test_courier_information_access_is_required_for_archive_downloads(): void
|
||||
{
|
||||
$contract = $this->signature('页面权限测试');
|
||||
$this->user->syncRoles([\App\Models\Auth\Role::where('name', 'user')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => ['user_' . $this->user->id],
|
||||
'subjects' => ['user_' . $this->user->id => ['courier-payables/courier-information']],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
setting()->save();
|
||||
$this->page()->assertOk();
|
||||
$this->download($contract)->assertOk();
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => ['user_' . $this->user->id], 'subjects' => ['user_' . $this->user->id => []],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
setting()->save();
|
||||
$this->withExceptionHandling();
|
||||
$this->download($contract)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_object_storage_is_not_queried_per_row_when_opening_the_contract_list(): void
|
||||
{
|
||||
$this->signature('远程附件列表');
|
||||
$this->loginAs();
|
||||
config(['filesystems.disks.uploads.driver' => 's3']);
|
||||
Storage::shouldReceive('disk')->never();
|
||||
$data = app(\App\Business\CourierContractPageData::class)->page();
|
||||
$this->assertNull($data['courierContractError']);
|
||||
$this->assertTrue($data['courierContractRows']->items()[0]['has_signed_file']);
|
||||
}
|
||||
|
||||
private function page(array $query = [])
|
||||
{
|
||||
return $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables',
|
||||
'page' => 'courier-information']) . '?' . http_build_query(['courier_tab' => 'contracts'] + $query));
|
||||
}
|
||||
|
||||
private function download(CourierAgreementSignature $signature, string $kind = 'signed')
|
||||
{
|
||||
return $this->loginAs()->getJson(route('business.courier-contracts.download', ['agreementSignature' => $signature->id, 'kind' => $kind]), ['X-Requested-With' => 'XMLHttpRequest']);
|
||||
}
|
||||
|
||||
private function signature(string $name, array $payload = [], array $attributes = []): CourierAgreementSignature
|
||||
{
|
||||
$key = 'test-' . bin2hex(random_bytes(8));
|
||||
Storage::disk('uploads')->put($key . '/signed.pdf', '%PDF-1.4 synthetic signed contract');
|
||||
Storage::disk('uploads')->put($key . '/certificate.pdf', '%PDF-1.4 synthetic certificate');
|
||||
|
||||
return CourierAgreementSignature::create($attributes + [
|
||||
'company_id' => $this->company->id, 'business_no' => $key, 'idempotency_key' => $key,
|
||||
'document_type' => 'cooperation-contract', 'template_code' => 'TPL-COOPERATION-CONTRACT-V1',
|
||||
'template_version' => '1', 'source_file_name' => 'contract.pdf', 'status' => 'archived',
|
||||
'courier_name' => $name, 'company_full_name' => '甲方企业', 'organization_name' => '乙方签署企业',
|
||||
'operator_name' => '经办人', 'payload' => $payload + [
|
||||
'partner_type' => 'courier', 'cooperation_contract_number' => 'KD-2026-001',
|
||||
'cooperation_party_a_name' => '甲方企业', 'cooperation_party_b_name' => '乙方签署企业',
|
||||
'cooperation_party_b_legal_representative' => '法人测试姓名',
|
||||
'cooperation_start_date' => '2026-01-01', 'cooperation_end_date' => '2026-11-01',
|
||||
],
|
||||
'source_pdf_path' => $key . '/source.pdf', 'source_pdf_sha256' => '',
|
||||
'signed_pdf_path' => $key . '/signed.pdf', 'certificate_path' => $key . '/certificate.pdf',
|
||||
'archived_at' => now(), 'created_by' => $this->user->id, 'updated_by' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user