diff --git a/app/Business/CourierContractPageData.php b/app/Business/CourierContractPageData.php new file mode 100644 index 00000000..5d041c16 --- /dev/null +++ b/app/Business/CourierContractPageData.php @@ -0,0 +1,163 @@ + '即将到期', + '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); + } +} diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index 8e839026..fdf9d6b4 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -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; diff --git a/app/Http/Controllers/Business/CourierContracts.php b/app/Http/Controllers/Business/CourierContracts.php new file mode 100644 index 00000000..ba75d580 --- /dev/null +++ b/app/Http/Controllers/Business/CourierContracts.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a062dc37..cd2bdab1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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=快递公司代码)后,下方显示这家快递的网点 / 打款信息 / 操作日志。 */ diff --git a/app/Models/Business/CourierAgreementSignature.php b/app/Models/Business/CourierAgreementSignature.php index c6441816..8395f0e8 100644 --- a/app/Models/Business/CourierAgreementSignature.php +++ b/app/Models/Business/CourierAgreementSignature.php @@ -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 { diff --git a/docs/快递合同管理.md b/docs/快递合同管理.md new file mode 100644 index 00000000..0bf73bab --- /dev/null +++ b/docs/快递合同管理.md @@ -0,0 +1,35 @@ +# 快递合同管理 + +入口:快递应付 → 快递资料 → 操作日志后的「合同管理」。标签切换、筛选、分页在当前页面局部更新。 + +## 收录规则 + +直接读取当前公司的协议签署归档记录,不复制合同或附件、不新增业务表。合作方为快递公司、文件类型为合作合同、状态为「已签署并归档」时自动显示,历史记录同样适用。待签署、签署完成但待归档、供应商合同、其他证明、已删除记录不显示。 + +合作方分类上线前的记录按原有快递任务处理;加密快照无法读取时不猜测合作方,提示未纳入列表和提醒。快递资料的工作平台镜像不可用不影响合同管理。 + +## 字段口径 + +| 字段 | 来源与注意事项 | +| --- | --- | +| 快递公司 | 发起签署时选择的快递名称 | +| 合同编号 | 合作合同发起表单中的正式编号;选填,填写后显示在合同预览和签署 PDF 中;不冒用系统签署单号 | +| 开始、结束日期 | 合作合同签署快照里的协议期限;不使用签署流程截止时间 | +| 乙方合同签署主体 | 合同乙方名称快照,可为企业或个人 | +| 乙方法定代表人 | 发起合作合同时选填,以营业执照为准;不取经办人姓名,个人签署标记不适用 | +| 创建人、创建时间 | 签署任务创建账号及时间,不使用合同列表首次打开时间 | +| 甲方签署主体 | 合同甲方名称快照 | +| 归档时间、签署单号 | 原签署任务的归档时间和系统业务单号 | +| 已签署附件、签署完成证书 | 复用原私有归档文件,先校验页面权限、公司、合作方类型、合同类型和归档状态,再查看或下载 | + +历史缺失信息显示「未填写」或「待核对」,不修改已经签署的快照。未登记附件时明确提示,不用未签署原文冒充;远程存储文件在打开时检查实际存在性,避免列表逐条请求对象存储拖慢页面。 + +## 到期提醒 + +当前版本为页面内提醒:快递资料各标签顶部展示提醒,合同管理标签带待关注数量。默认提前30天提醒,包含结束日期当天;从次日起标记「约定期限已届满」。另区分有效期内、未到开始日期、期限待核对。缺失、无效或倒置的日期不计算剩余天数。 + +提醒统计覆盖当前公司全部符合条件的归档合同,不只统计当前页或筛选结果。列表将即将到期的合同置前,支持按快递名称、编号、甲乙方主体、系统签署单号搜索,以及期限状态、结束日期区间筛选。 + +现有合作合同第2.2条包含自动续期约定,因此「约定期限已届满」不等于合同已终止,必须结合续签和自动续期条款核对。本功能不擅自续期、终止或更改合同日期,不发送短信、邮件或钉钉通知。 + +建议后续按实际管理流程补充:续签负责人、续签处理状态、自动续期条款备注,以及需要时的消息提醒渠道。避免把银行卡、证件号码等不必要的敏感资料铺在列表上。 diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index dcde55a2..8830b2d3 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -1846,6 +1846,15 @@ +
用于合同管理登记;不会替代 E 签宝实名认证姓名。个人签署不适用。
+