协议签署:待处理与已归档视图、归档原因及文件下载 (#291)
This commit is contained in:
parent
22133450a6
commit
4eb09a1fcc
@ -283,11 +283,26 @@ class CourierAgreementSignatures extends Controller
|
||||
->with('success', "{$signature->documentLabel()}状态已同步:{$signature->statusLabel()}。");
|
||||
}
|
||||
|
||||
public function archive(CourierAgreementSignature $agreementSignature): RedirectResponse
|
||||
public function archive(Request $request, CourierAgreementSignature $agreementSignature): RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($agreementSignature->status === CourierAgreementSignature::STATUS_COMPLETED && $agreementSignature->remote_status === '2', 422, '只有签署完成的协议可以人工归档。');
|
||||
$signature = $this->service->archive($agreementSignature, null, user_id());
|
||||
$data = $request->validate([
|
||||
'archive_reason_code' => ['required', 'in:completed,confirmed,backfill,other'],
|
||||
'archive_note' => ['nullable', 'string', 'max:200'],
|
||||
]);
|
||||
$reasonLabel = match ($data['archive_reason_code']) {
|
||||
'completed' => '签署完成',
|
||||
'confirmed' => '财务已确认',
|
||||
'backfill' => '历史补录',
|
||||
'other' => '其他',
|
||||
};
|
||||
$note = trim((string) ($data['archive_note'] ?? ''));
|
||||
if ($data['archive_reason_code'] === 'other' && $note === '') {
|
||||
throw ValidationException::withMessages(['archive_note' => '选择“其他”时请填写归档说明。']);
|
||||
}
|
||||
$reason = $note === '' ? $reasonLabel : $reasonLabel . ':' . $note;
|
||||
$signature = $this->service->archive($agreementSignature, null, user_id(), $reason);
|
||||
|
||||
return redirect()
|
||||
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
|
||||
|
||||
@ -2206,6 +2206,13 @@ class Pages extends Controller
|
||||
$query = CourierAgreementSignature::query();
|
||||
$search = $this->agreementFilterSearch();
|
||||
$status = $this->agreementFilterStatus();
|
||||
$scope = $this->agreementFilterScope();
|
||||
|
||||
if ($scope === 'archived') {
|
||||
$query->where('status', CourierAgreementSignature::STATUS_ARCHIVED);
|
||||
} elseif ($scope === 'active') {
|
||||
$query->where('status', '<>', CourierAgreementSignature::STATUS_ARCHIVED);
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$like = '%' . addcslashes($search, '%_\\') . '%';
|
||||
@ -2269,6 +2276,14 @@ class Pages extends Controller
|
||||
return in_array($type, $allowed, true) ? $type : null;
|
||||
}
|
||||
|
||||
protected function agreementFilterScope(): string
|
||||
{
|
||||
$default = request('agreement_status') === CourierAgreementSignature::STATUS_ARCHIVED ? 'archived' : 'active';
|
||||
$scope = trim((string) request('agreement_scope', $default));
|
||||
|
||||
return in_array($scope, ['active', 'archived', 'all'], true) ? $scope : 'active';
|
||||
}
|
||||
|
||||
protected function agreementSubjects(?string $page): Collection
|
||||
{
|
||||
if ($page !== 'agreement-signing') {
|
||||
@ -2302,6 +2317,13 @@ class Pages extends Controller
|
||||
$query = CourierQuoteSignature::query()->with('version');
|
||||
$search = $this->agreementFilterSearch();
|
||||
$status = $this->agreementFilterStatus();
|
||||
$scope = $this->agreementFilterScope();
|
||||
|
||||
if ($scope === 'archived') {
|
||||
$query->where('status', CourierQuoteSignature::STATUS_ARCHIVED);
|
||||
} elseif ($scope === 'active') {
|
||||
$query->where('status', '<>', CourierQuoteSignature::STATUS_ARCHIVED);
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$like = '%' . addcslashes($search, '%_\\') . '%';
|
||||
|
||||
@ -158,4 +158,13 @@ class CourierAgreementSignature extends Model
|
||||
|
||||
return in_array($mode, ['auto', 'manual'], true) ? $mode : 'auto';
|
||||
}
|
||||
|
||||
public function archiveReason(): string
|
||||
{
|
||||
try {
|
||||
return trim((string) data_get($this->payload, 'archive_reason', ''));
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,7 +211,7 @@ final class CourierAgreementSignatureService
|
||||
});
|
||||
}
|
||||
|
||||
public function archive(CourierAgreementSignature $signature, ?EsignClient $client = null, ?int $userId = null): CourierAgreementSignature
|
||||
public function archive(CourierAgreementSignature $signature, ?EsignClient $client = null, ?int $userId = null, ?string $archiveReason = null): CourierAgreementSignature
|
||||
{
|
||||
if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) {
|
||||
return $signature;
|
||||
@ -246,6 +246,17 @@ final class CourierAgreementSignatureService
|
||||
'last_error' => null,
|
||||
'updated_by' => $userId ?: (int) $signature->updated_by,
|
||||
];
|
||||
$archiveReason ??= '签署完成后自动归档';
|
||||
if (filled($archiveReason)) {
|
||||
try {
|
||||
$payload = $signature->payload ?? [];
|
||||
$payload['archive_reason'] = mb_substr(trim($archiveReason), 0, 255);
|
||||
$archive['payload'] = $payload;
|
||||
} catch (Throwable) {
|
||||
// Preserve unreadable historical payloads; retiring an APP_KEY
|
||||
// must not prevent downloading and archiving a completed flow.
|
||||
}
|
||||
}
|
||||
if ($certificateBytes !== null) {
|
||||
$archive['certificate_path'] = $certificatePath;
|
||||
$archive['certificate_sha256'] = hash('sha256', $certificateBytes);
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
@php($agreementSearch = trim((string) request('agreement_search', '')))
|
||||
@php($agreementStatus = trim((string) request('agreement_status', '')))
|
||||
@php($agreementType = trim((string) request('agreement_type', '')))
|
||||
@php($agreementScope = trim((string) request('agreement_scope', $agreementStatus === 'archived' ? 'archived' : 'active')))
|
||||
@php($agreementScope = in_array($agreementScope, ['active', 'archived', 'all'], true) ? $agreementScope : 'active')
|
||||
@php($agreementFiltersActive = $agreementSearch !== '' || $agreementStatus !== '' || $agreementType !== '')
|
||||
|
||||
<section class="agreement-workspace" aria-label="协议签署" data-agreement-signing>
|
||||
@ -198,6 +200,77 @@
|
||||
border-bottom: 1px solid var(--agreement-border);
|
||||
}
|
||||
|
||||
.agreement-scope-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--agreement-border);
|
||||
}
|
||||
|
||||
.agreement-scope-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 42px;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--agreement-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agreement-scope-link:hover,
|
||||
.agreement-scope-link[aria-current="page"] {
|
||||
color: var(--agreement-accent);
|
||||
}
|
||||
|
||||
.agreement-scope-link[aria-current="page"] {
|
||||
border-bottom-color: var(--agreement-gold);
|
||||
}
|
||||
|
||||
.agreement-scope-link:focus-visible {
|
||||
outline: 2px solid var(--agreement-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.agreement-scope-link .material-icons-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.agreement-archive-meta {
|
||||
display: block;
|
||||
max-width: 24rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--agreement-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agreement-archive-meta strong {
|
||||
margin-right: 0.4rem;
|
||||
color: var(--agreement-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agreement-button--compact {
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.agreement-task-error:not(:empty) {
|
||||
margin: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.agreement-archive-dialog .agreement-modal__body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.agreement-field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
@ -1231,8 +1304,19 @@
|
||||
</div>
|
||||
|
||||
<section class="agreement-panel" id="agreement-panel-tasks" role="tabpanel" aria-labelledby="agreement-tab-tasks" data-agreement-panel="tasks">
|
||||
@php($scopeQuery = request()->except(['agreement_scope', 'agreement_status', 'agreement_task_page', 'agreement_quote_page']))
|
||||
<nav class="agreement-scope-bar" aria-label="任务归档视图">
|
||||
@foreach (['active' => ['inbox', '待处理'], 'all' => ['view_list', '全部任务'], 'archived' => ['inventory_2', '已归档']] as $scope => $scopeLabel)
|
||||
<a class="agreement-scope-link" data-task-scope="{{ $scope }}" href="{{ url()->current() . '?' . http_build_query(array_merge($scopeQuery, ['agreement_view' => 'tasks', 'agreement_scope' => $scope])) }}" @if ($agreementScope === $scope) aria-current="page" @endif>
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $scopeLabel[0] }}</span>
|
||||
{{ $scopeLabel[1] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
<p class="agreement-form-error agreement-task-error" role="alert" data-task-load-error></p>
|
||||
<form class="agreement-filter-bar" method="GET" action="{{ url()->current() }}" data-task-filter-form>
|
||||
<input type="hidden" name="agreement_view" value="tasks">
|
||||
<input type="hidden" name="agreement_scope" value="{{ $agreementScope }}">
|
||||
<div class="agreement-field">
|
||||
<label for="agreement-task-search">搜索任务</label>
|
||||
<div class="agreement-search">
|
||||
@ -1244,16 +1328,20 @@
|
||||
<label for="agreement-task-status">签署状态</label>
|
||||
<select class="agreement-control" id="agreement-task-status" name="agreement_status" data-task-filter>
|
||||
<option value="">全部状态</option>
|
||||
@if ($agreementScope !== 'archived')
|
||||
<option value="prepared" @selected($agreementStatus === 'prepared')>待推送</option>
|
||||
<option value="sending" @selected($agreementStatus === 'sending')>推送中</option>
|
||||
<option value="signing" @selected($agreementStatus === 'signing')>待签署</option>
|
||||
<option value="completed" @selected($agreementStatus === 'completed')>签署完成,待归档</option>
|
||||
<option value="archived" @selected($agreementStatus === 'archived')>已签署并归档</option>
|
||||
<option value="revoked" @selected($agreementStatus === 'revoked')>已撤销</option>
|
||||
<option value="rejected" @selected($agreementStatus === 'rejected')>已拒签</option>
|
||||
<option value="expired" @selected($agreementStatus === 'expired')>已过期</option>
|
||||
<option value="failed" @selected($agreementStatus === 'failed')>处理失败</option>
|
||||
<option value="unknown" @selected($agreementStatus === 'unknown')>状态未知</option>
|
||||
@endif
|
||||
@if ($agreementScope !== 'active')
|
||||
<option value="archived" @selected($agreementStatus === 'archived')>已签署并归档</option>
|
||||
@endif
|
||||
</select>
|
||||
</div>
|
||||
<div class="agreement-field">
|
||||
@ -1302,6 +1390,10 @@
|
||||
<td>报价确认单</td>
|
||||
<td class="agreement-status-cell">
|
||||
<span class="agreement-status agreement-status--{{ in_array($quoteSignature->status, ['archived', 'completed'], true) ? 'success' : (in_array($quoteSignature->status, ['failed', 'rejected', 'expired', 'revoked'], true) ? 'danger' : 'warning') }}">{{ $quoteSignature->statusLabel() }}</span>
|
||||
@if ($quoteSignature->status === 'archived')
|
||||
<span class="agreement-archive-meta"><strong>归档时间</strong>{{ optional($quoteSignature->archived_at)->format('Y-m-d H:i') ?: '时间未知' }}</span>
|
||||
<span class="agreement-archive-meta"><strong>归档说明</strong>签署完成后自动归档</span>
|
||||
@endif
|
||||
@if ($quoteError)
|
||||
<span class="agreement-error-summary" title="{{ $quoteError }}">{{ $quoteError }}</span>
|
||||
@endif
|
||||
@ -1336,6 +1428,10 @@
|
||||
<td>{{ $task->documentLabel() }}</td>
|
||||
<td class="agreement-status-cell">
|
||||
<span class="agreement-status agreement-status--{{ in_array($task->status, ['archived', 'completed'], true) ? 'success' : ($taskFailed || in_array($task->status, ['rejected', 'expired', 'revoked'], true) ? 'danger' : 'warning') }}">{{ $task->statusLabel() }}</span>
|
||||
@if ($task->status === 'archived')
|
||||
<span class="agreement-archive-meta"><strong>归档时间</strong>{{ optional($task->archived_at)->format('Y-m-d H:i') ?: '时间未知' }}</span>
|
||||
<span class="agreement-archive-meta"><strong>归档说明</strong>{{ $task->archiveReason() ?: '未记录说明' }}</span>
|
||||
@endif
|
||||
@if ($taskError)
|
||||
<span class="agreement-error-summary" title="{{ $taskError }}">{{ $taskError }}</span>
|
||||
@endif
|
||||
@ -1349,10 +1445,12 @@
|
||||
<button class="agreement-icon-button" type="submit" title="同步E签宝状态"><span class="material-icons-outlined" aria-hidden="true">sync</span></button>
|
||||
</form>
|
||||
@endif
|
||||
@if ($task->status === 'completed' && $task->archiveMode() === 'manual')
|
||||
<form method="POST" action="{{ route('business.courier-agreement-signatures.archive', $task) }}">
|
||||
@if ($task->status === 'completed' && $task->remote_status === '2' && $task->archiveMode() === 'manual')
|
||||
<form method="POST" action="{{ route('business.courier-agreement-signatures.archive', $task) }}" data-archive-form data-task-number="{{ $task->business_no }}">
|
||||
@csrf
|
||||
<button class="agreement-icon-button" type="submit" title="人工确认并归档"><span class="material-icons-outlined" aria-hidden="true">archive</span></button>
|
||||
<input type="hidden" name="archive_reason_code" data-archive-reason-code>
|
||||
<input type="hidden" name="archive_note" data-archive-note>
|
||||
<button class="agreement-button agreement-button--compact" type="button" data-archive-open title="人工确认并归档"><span class="material-icons-outlined" aria-hidden="true">archive</span>归档</button>
|
||||
</form>
|
||||
@endif
|
||||
@if ($task->source_pdf_path)
|
||||
@ -1361,6 +1459,9 @@
|
||||
@if ($task->signed_pdf_path)
|
||||
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'signed']) }}" title="下载已签署协议"><span class="material-icons-outlined" aria-hidden="true">download</span></a>
|
||||
@endif
|
||||
@if ($task->certificate_path)
|
||||
<a class="agreement-icon-button" href="{{ route('business.courier-agreement-signatures.download', [$task, 'certificate']) }}" title="下载签署完成证书"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span></a>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@ -1375,8 +1476,8 @@
|
||||
<strong>没有匹配的签署任务</strong>
|
||||
<p>请调整筛选条件后重新查询。</p>
|
||||
@else
|
||||
<strong>还没有协议签署任务</strong>
|
||||
<p>完成E签宝接入后,发起、签署、拒签和归档记录会在这里统一追踪。</p>
|
||||
<strong>{{ $agreementScope === 'archived' ? '还没有已归档任务' : ($agreementScope === 'active' ? '暂无待处理任务' : '还没有协议签署任务') }}</strong>
|
||||
<p>{{ $agreementScope === 'archived' ? '完成归档后可在这里检索和下载签署文件。' : '可新建签署任务,或在“全部任务”和“已归档”中查找历史文件。' }}</p>
|
||||
@endif
|
||||
<button class="agreement-button agreement-button--primary" type="button" data-start-agreement>
|
||||
<span class="material-icons-outlined" aria-hidden="true">add</span>
|
||||
@ -1974,6 +2075,37 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="agreement-modal" role="presentation" aria-hidden="true" data-archive-modal hidden>
|
||||
<div class="agreement-modal__backdrop" data-archive-modal-close></div>
|
||||
<section class="agreement-modal__dialog agreement-archive-dialog" role="dialog" aria-modal="true" aria-labelledby="agreement-archive-modal-title">
|
||||
<header class="agreement-modal__header">
|
||||
<h2 class="agreement-modal__title" id="agreement-archive-modal-title"><span class="material-icons-outlined" aria-hidden="true">inventory_2</span>确认归档签署任务</h2>
|
||||
<button class="agreement-modal-close" type="button" aria-label="关闭归档确认" data-archive-modal-close><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
</header>
|
||||
<form class="agreement-modal__body" id="agreement-archive-confirm-form" data-archive-confirm-form>
|
||||
<p class="agreement-modal__hint"><span class="material-icons-outlined" aria-hidden="true">info</span><span><strong data-archive-task-number></strong> 归档后将移入“已归档”,原文件、签署文件和完成证书仍可下载。</span></p>
|
||||
<fieldset class="agreement-archive-fieldset">
|
||||
<legend class="agreement-section-heading">归档原因 <span class="agreement-required" aria-hidden="true">*</span></legend>
|
||||
<div class="agreement-archive-options">
|
||||
<label class="agreement-archive-choice"><input type="radio" name="archive_reason_code" value="completed" checked required><span><strong>签署完成</strong><span>签署文件已核对</span></span></label>
|
||||
<label class="agreement-archive-choice"><input type="radio" name="archive_reason_code" value="confirmed"><span><strong>财务已确认</strong><span>财务复核无误</span></span></label>
|
||||
<label class="agreement-archive-choice"><input type="radio" name="archive_reason_code" value="backfill"><span><strong>历史补录</strong><span>补录以前的签署文件</span></span></label>
|
||||
<label class="agreement-archive-choice"><input type="radio" name="archive_reason_code" value="other"><span><strong>其他</strong><span>请填写具体说明</span></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="agreement-field">
|
||||
<label for="agreement-archive-note">归档说明</label>
|
||||
<textarea class="agreement-control" id="agreement-archive-note" name="archive_note" rows="3" maxlength="200" placeholder="选择“其他”时必填,其他原因可选填" aria-describedby="agreement-archive-error"></textarea>
|
||||
</div>
|
||||
<p class="agreement-form-error" id="agreement-archive-error" role="alert" data-archive-error></p>
|
||||
</form>
|
||||
<footer class="agreement-modal__footer">
|
||||
<button class="agreement-button" type="button" data-archive-modal-close>取消</button>
|
||||
<button class="agreement-button agreement-button--primary" type="submit" form="agreement-archive-confirm-form" data-archive-confirm><span class="material-icons-outlined" aria-hidden="true">archive</span>确认归档</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="sr-only" role="status" aria-live="polite" data-agreement-announcer></p>
|
||||
|
||||
<script>
|
||||
@ -1993,6 +2125,11 @@
|
||||
var form = root.querySelector('[data-agreement-form]');
|
||||
var announcer = root.querySelector('[data-agreement-announcer]');
|
||||
var subjectModal = root.querySelector('[data-subject-modal]');
|
||||
var archiveModal = root.querySelector('[data-archive-modal]');
|
||||
var archiveConfirmForm = root.querySelector('[data-archive-confirm-form]');
|
||||
var archiveTargetForm = null;
|
||||
var lastArchiveTrigger = null;
|
||||
var taskLoadRequest = 0;
|
||||
var subjectList = root.querySelector('[data-subject-list]');
|
||||
var subjectForm = root.querySelector('[data-subject-form]');
|
||||
var subjectSelect = root.querySelector('[data-signing-subject-select]');
|
||||
@ -2051,6 +2188,142 @@
|
||||
}
|
||||
}
|
||||
|
||||
function closeArchiveModal() {
|
||||
archiveModal.hidden = true;
|
||||
archiveModal.setAttribute('aria-hidden', 'true');
|
||||
archiveTargetForm = null;
|
||||
if (lastArchiveTrigger && lastArchiveTrigger.isConnected) {
|
||||
lastArchiveTrigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function openArchiveModal(button) {
|
||||
archiveTargetForm = button.closest('[data-archive-form]');
|
||||
lastArchiveTrigger = button;
|
||||
archiveConfirmForm.reset();
|
||||
archiveConfirmForm.querySelector('[data-archive-error]').textContent = '';
|
||||
archiveConfirmForm.querySelector('textarea').removeAttribute('aria-invalid');
|
||||
archiveConfirmForm.querySelector('[data-archive-task-number]').textContent = archiveTargetForm.dataset.taskNumber;
|
||||
archiveModal.hidden = false;
|
||||
archiveModal.setAttribute('aria-hidden', 'false');
|
||||
archiveConfirmForm.querySelector('input:checked').focus();
|
||||
}
|
||||
|
||||
async function loadTasks(url, options) {
|
||||
var requestId = ++taskLoadRequest;
|
||||
var panel = root.querySelector('[data-agreement-panel="tasks"]');
|
||||
var focused = panel.contains(document.activeElement) ? document.activeElement : null;
|
||||
var focusedId = focused ? focused.id : '';
|
||||
var focusedScope = focused ? focused.dataset.taskScope : '';
|
||||
panel.setAttribute('aria-busy', 'true');
|
||||
panel.querySelector('[data-task-load-error]').textContent = '';
|
||||
try {
|
||||
var response = await fetch(url.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('任务列表加载失败');
|
||||
}
|
||||
var doc = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
var nextPanel = doc.querySelector('[data-agreement-panel="tasks"]');
|
||||
if (!nextPanel) {
|
||||
throw new Error('任务列表不可用');
|
||||
}
|
||||
if (requestId !== taskLoadRequest) {
|
||||
return;
|
||||
}
|
||||
panel.replaceChildren.apply(panel, Array.prototype.slice.call(nextPanel.childNodes));
|
||||
var nextFocus = focusedId ? document.getElementById(focusedId) : (focusedScope ? panel.querySelector('[data-task-scope="' + focusedScope + '"]') : null);
|
||||
if (nextFocus && document.activeElement === document.body) {
|
||||
nextFocus.focus();
|
||||
}
|
||||
if (!options || options.updateUrl !== false) {
|
||||
window.history.pushState({ agreementView: 'tasks' }, '', url.toString());
|
||||
}
|
||||
announce('签署任务列表已更新');
|
||||
} catch (error) {
|
||||
if (requestId === taskLoadRequest) {
|
||||
panel.querySelector('[data-task-load-error]').textContent = '任务列表加载失败,已保留原列表。请重新查询。';
|
||||
}
|
||||
} finally {
|
||||
if (requestId === taskLoadRequest) {
|
||||
panel.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-start-agreement]')) {
|
||||
showTab('create');
|
||||
refreshPreview();
|
||||
return;
|
||||
}
|
||||
var archiveOpen = event.target.closest('[data-archive-open]');
|
||||
if (archiveOpen) {
|
||||
openArchiveModal(archiveOpen);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-archive-modal-close]')) {
|
||||
closeArchiveModal();
|
||||
return;
|
||||
}
|
||||
var panel = event.target.closest('[data-agreement-panel="tasks"]');
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
var link = event.target.closest('a[href]');
|
||||
if (link && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
||||
var url = new URL(link.href);
|
||||
if (url.origin === window.location.origin && url.pathname === window.location.pathname) {
|
||||
event.preventDefault();
|
||||
url.searchParams.set('agreement_view', 'tasks');
|
||||
loadTasks(url);
|
||||
}
|
||||
}
|
||||
if (event.target.closest('[data-task-reset]')) {
|
||||
var resetUrl = new URL(window.location.href);
|
||||
['agreement_search', 'agreement_status', 'agreement_type', 'agreement_task_page', 'agreement_quote_page', 'agreement_page'].forEach(function (key) {
|
||||
resetUrl.searchParams.delete(key);
|
||||
});
|
||||
resetUrl.searchParams.set('agreement_view', 'tasks');
|
||||
loadTasks(resetUrl);
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener('submit', function (event) {
|
||||
if (!event.target.matches('[data-task-filter-form]')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var url = new URL(event.target.action);
|
||||
new FormData(event.target).forEach(function (value, key) {
|
||||
if (value !== '') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
loadTasks(url);
|
||||
});
|
||||
|
||||
archiveConfirmForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
if (!archiveTargetForm) {
|
||||
return;
|
||||
}
|
||||
var code = archiveConfirmForm.querySelector('input[name="archive_reason_code"]:checked').value;
|
||||
var note = archiveConfirmForm.querySelector('textarea[name="archive_note"]');
|
||||
if (code === 'other' && !note.value.trim()) {
|
||||
archiveConfirmForm.querySelector('[data-archive-error]').textContent = '选择“其他”时请填写归档说明。';
|
||||
note.setAttribute('aria-invalid', 'true');
|
||||
note.focus();
|
||||
return;
|
||||
}
|
||||
archiveTargetForm.querySelector('[data-archive-reason-code]').value = code;
|
||||
archiveTargetForm.querySelector('[data-archive-note]').value = note.value.trim();
|
||||
root.querySelector('[data-archive-confirm]').disabled = true;
|
||||
archiveTargetForm.requestSubmit();
|
||||
});
|
||||
|
||||
function subjectUrl(template, id) {
|
||||
return template.replace('__subject__', encodeURIComponent(String(id)));
|
||||
}
|
||||
@ -2981,13 +3254,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-start-agreement]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
showTab('create');
|
||||
refreshPreview();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-subject-manage-open]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
openSubjectModal();
|
||||
@ -3180,22 +3446,33 @@
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('[data-task-reset]').addEventListener('click', function () {
|
||||
var url = new URL(window.location.href);
|
||||
['agreement_search', 'agreement_status', 'agreement_type', 'agreement_task_page', 'agreement_quote_page', 'agreement_page'].forEach(function (key) {
|
||||
url.searchParams.delete(key);
|
||||
});
|
||||
url.searchParams.set('agreement_view', 'tasks');
|
||||
window.location.assign(url.toString());
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
var url = new URL(window.location.href);
|
||||
var view = url.searchParams.get('agreement_view') || 'tasks';
|
||||
showTab(view, { updateUrl: false });
|
||||
loadTasks(url, { updateUrl: false });
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', function (event) {
|
||||
if (!archiveModal.hidden) {
|
||||
if (event.key === 'Escape') {
|
||||
closeArchiveModal();
|
||||
} else if (event.key === 'Tab') {
|
||||
var controls = Array.prototype.filter.call(archiveModal.querySelectorAll('button, input, textarea'), function (control) {
|
||||
return !control.disabled && (control.type !== 'radio' || control.checked);
|
||||
});
|
||||
var first = controls[0];
|
||||
var last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && subjectModal && !subjectModal.hidden) {
|
||||
closeSubjectModal();
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ class AgreementSigningPageTest extends FeatureTestCase
|
||||
->assertSeeText('发起签章')
|
||||
->assertSeeText('模板管理')
|
||||
->assertSeeText(EsignClient::configured() ? 'E签宝已连接' : 'E签宝未连接')
|
||||
->assertSeeText('还没有协议签署任务')
|
||||
->assertSeeText('暂无待处理任务')
|
||||
->assertSeeText('收款账户变更证明.doc')
|
||||
->assertSeeText('收款账户证明文件.doc')
|
||||
->assertSeeText('公司主体变更证明.docx')
|
||||
@ -225,6 +225,77 @@ class AgreementSigningPageTest extends FeatureTestCase
|
||||
->assertDontSeeText('没有匹配的签署任务');
|
||||
}
|
||||
|
||||
public function testAgreementSigningPageSeparatesActiveAndArchivedTasksForBothDocumentSources(): void
|
||||
{
|
||||
$this->grantWorkspaceAccess();
|
||||
$this->createAgreementTask(['business_no' => 'AGR-ACTIVE', 'status' => CourierAgreementSignature::STATUS_COMPLETED]);
|
||||
$this->createAgreementTask(['business_no' => 'AGR-ARCHIVED', 'status' => CourierAgreementSignature::STATUS_ARCHIVED]);
|
||||
$this->createQuoteSignature('QB-ACTIVE', CourierQuoteSignature::STATUS_SIGNING, '测试快递');
|
||||
$this->createQuoteSignature('QB-ARCHIVED', CourierQuoteSignature::STATUS_ARCHIVED, '测试快递');
|
||||
|
||||
$this->loginAs();
|
||||
$url = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']);
|
||||
$this->get($url)->assertOk()
|
||||
->assertSeeText('AGR-ACTIVE')->assertSeeText('QB-ACTIVE')
|
||||
->assertDontSeeText('AGR-ARCHIVED')->assertDontSee('<td>QB-ARCHIVED</td>', false);
|
||||
$this->get($url . '?agreement_scope=archived')->assertOk()
|
||||
->assertSeeText('AGR-ARCHIVED')->assertSeeText('QB-ARCHIVED')
|
||||
->assertDontSeeText('AGR-ACTIVE')->assertDontSee('<td>QB-ACTIVE</td>', false);
|
||||
$this->get($url . '?agreement_scope=all')->assertOk()
|
||||
->assertSeeText('AGR-ACTIVE')->assertSeeText('QB-ACTIVE')
|
||||
->assertSeeText('AGR-ARCHIVED')->assertSeeText('QB-ARCHIVED');
|
||||
// An explicit scope remains authoritative even if a stale URL has a
|
||||
// conflicting status filter; archived rows never enter the inbox.
|
||||
$this->get($url . '?agreement_scope=active&agreement_status=archived')->assertOk()
|
||||
->assertDontSeeText('AGR-ARCHIVED')->assertDontSee('<td>QB-ARCHIVED</td>', false);
|
||||
$this->get($url . '?agreement_scope=archived&agreement_status=signing')->assertOk()
|
||||
->assertDontSeeText('AGR-ACTIVE')->assertDontSee('<td>QB-ACTIVE</td>', false);
|
||||
}
|
||||
|
||||
public function testArchivedTaskShowsMetadataAndAllAvailableFilesWithoutReadingLegacyPayload(): void
|
||||
{
|
||||
$this->grantWorkspaceAccess();
|
||||
$archived = $this->createAgreementTask([
|
||||
'business_no' => 'AGR-ARCHIVE-META',
|
||||
'status' => CourierAgreementSignature::STATUS_ARCHIVED,
|
||||
'archived_at' => '2026-10-10 12:34:00',
|
||||
'payload' => ['archive_reason' => '财务已确认:核对完成'],
|
||||
'signed_pdf_path' => 'history/signed.pdf',
|
||||
'certificate_path' => 'history/certificate.pdf',
|
||||
]);
|
||||
$legacy = $this->createAgreementTask(['business_no' => 'AGR-ARCHIVE-LEGACY', 'status' => CourierAgreementSignature::STATUS_ARCHIVED]);
|
||||
DB::table('business_courier_agreement_signatures')->where('id', $legacy->id)->update(['payload' => 'unreadable-legacy-payload']);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_scope' => 'archived',
|
||||
]))->assertOk()
|
||||
->assertSeeText('2026-10-10 12:34')->assertSeeText('财务已确认:核对完成')
|
||||
->assertSeeText('AGR-ARCHIVE-LEGACY')->assertSeeText('未记录说明')
|
||||
->assertSee(route('business.courier-agreement-signatures.download', [$archived, 'source']), false)
|
||||
->assertSee(route('business.courier-agreement-signatures.download', [$archived, 'signed']), false)
|
||||
->assertSee(route('business.courier-agreement-signatures.download', [$archived, 'certificate']), false)
|
||||
->assertDontSee('data-archive-open title=', false);
|
||||
}
|
||||
|
||||
public function testArchivedScopeRetainsSearchAndPaginationForMoreThanOnePage(): void
|
||||
{
|
||||
$this->grantWorkspaceAccess();
|
||||
for ($i = 1; $i <= 55; $i++) {
|
||||
$this->createAgreementTask([
|
||||
'business_no' => 'AGR-PAGED-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT),
|
||||
'status' => CourierAgreementSignature::STATUS_ARCHIVED,
|
||||
]);
|
||||
}
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables', 'page' => 'agreement-signing',
|
||||
'agreement_scope' => 'archived', 'agreement_search' => 'AGR-PAGED-', 'agreement_task_page' => 2,
|
||||
]))->assertOk()->assertSeeText('AGR-PAGED-001')->assertDontSeeText('AGR-PAGED-055');
|
||||
$this->assertSame(55, $response->viewData('agreementTasks')->total());
|
||||
$this->assertCount(5, $response->viewData('agreementTasks')->items());
|
||||
$this->assertStringContainsString('agreement_scope=archived', $response->viewData('agreementTasks')->url(1));
|
||||
$this->assertStringContainsString('agreement_search=AGR-PAGED-', $response->viewData('agreementTasks')->url(1));
|
||||
}
|
||||
|
||||
public function testAgreementSigningPageDistinguishesDefinitiveFailureFromUnknownResult(): void
|
||||
{
|
||||
$this->grantWorkspaceAccess();
|
||||
|
||||
@ -1124,6 +1124,100 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase
|
||||
$this->assertSame('file-server-error', $signature->esign_file_id);
|
||||
}
|
||||
|
||||
public function test_manual_archive_requires_a_valid_reason_and_other_requires_a_non_blank_note(): void
|
||||
{
|
||||
Http::fake();
|
||||
$signature = $this->agreementSignature([
|
||||
'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2',
|
||||
'esign_sign_flow_id' => 'flow-manual', 'payload' => ['archive_mode' => 'manual'],
|
||||
]);
|
||||
foreach ([
|
||||
[[], 'archive_reason_code'],
|
||||
[['archive_reason_code' => 'invalid'], 'archive_reason_code'],
|
||||
[['archive_reason_code' => 'other'], 'archive_note'],
|
||||
[['archive_reason_code' => 'other', 'archive_note' => ' '], 'archive_note'],
|
||||
[['archive_reason_code' => 'confirmed', 'archive_note' => str_repeat('字', 201)], 'archive_note'],
|
||||
] as [$data, $field]) {
|
||||
$this->withExceptionHandling()->loginAs()
|
||||
->post(route('business.courier-agreement-signatures.archive', $signature), $data)
|
||||
->assertSessionHasErrors($field);
|
||||
$this->assertSame(CourierAgreementSignature::STATUS_COMPLETED, $signature->fresh()->status);
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_manual_archive_persists_reason_and_keeps_all_files_downloadable(): void
|
||||
{
|
||||
$signature = $this->agreementSignature([
|
||||
'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2',
|
||||
'esign_sign_flow_id' => 'flow-manual',
|
||||
'payload' => ['archive_mode' => 'manual', 'signing_subject' => $this->company->name],
|
||||
]);
|
||||
$this->fakeArchiveDownload();
|
||||
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-original');
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [
|
||||
'archive_reason_code' => 'confirmed', 'archive_note' => ' 已核对签章及账号 ',
|
||||
])->assertRedirect();
|
||||
$signature->refresh();
|
||||
$this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $signature->status);
|
||||
$this->assertSame('财务已确认:已核对签章及账号', $signature->payload['archive_reason']);
|
||||
$this->assertSame($this->company->name, $signature->payload['signing_subject']);
|
||||
$this->assertNotNull($signature->archived_at);
|
||||
$this->assertSame(hash('sha256', '%PDF-signed'), $signature->signed_pdf_sha256);
|
||||
$this->assertSame(hash('sha256', '%PDF-certificate'), $signature->certificate_sha256);
|
||||
foreach (['source', 'signed', 'certificate'] as $kind) {
|
||||
$this->get(route('business.courier-agreement-signatures.download', [$signature, $kind]))->assertOk()->assertDownload();
|
||||
}
|
||||
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']))
|
||||
->assertOk()->assertDontSeeText($signature->business_no);
|
||||
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_scope' => 'archived']))
|
||||
->assertOk()->assertSeeText($signature->business_no)->assertSeeText('财务已确认:已核对签章及账号');
|
||||
}
|
||||
|
||||
public function test_manual_archive_preserves_unreadable_legacy_payload_and_still_archives_files(): void
|
||||
{
|
||||
$signature = $this->agreementSignature([
|
||||
'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2',
|
||||
'esign_sign_flow_id' => 'flow-manual',
|
||||
]);
|
||||
$signature->getConnection()->table($signature->getTable())->where('id', $signature->id)
|
||||
->update(['payload' => 'unreadable-legacy-payload']);
|
||||
$this->fakeArchiveDownload();
|
||||
$this->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [
|
||||
'archive_reason_code' => 'backfill',
|
||||
])->assertRedirect();
|
||||
$signature->refresh();
|
||||
$this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $signature->status);
|
||||
$this->assertSame('unreadable-legacy-payload', $signature->getRawOriginal('payload'));
|
||||
$this->assertSame('', $signature->archiveReason());
|
||||
Storage::disk('uploads')->assertExists($signature->signed_pdf_path);
|
||||
}
|
||||
|
||||
public function test_manual_archive_rejects_an_incomplete_flow_before_downloading(): void
|
||||
{
|
||||
Http::fake();
|
||||
$signature = $this->agreementSignature(['status' => CourierAgreementSignature::STATUS_SIGNING, 'remote_status' => '1']);
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [
|
||||
'archive_reason_code' => 'completed',
|
||||
])->assertStatus(422);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
private function fakeArchiveDownload(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://smlopenapi.esign.test/v3/sign-flow/flow-manual/file-download-url' => Http::response([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'files' => [['downloadUrl' => 'https://download.esign.test/manual-signed']],
|
||||
'certificateDownloadUrl' => 'https://download.esign.test/manual-certificate',
|
||||
],
|
||||
]),
|
||||
'https://download.esign.test/manual-signed' => Http::response('%PDF-signed'),
|
||||
'https://download.esign.test/manual-certificate' => Http::response('%PDF-certificate'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function fakeEsign(): void
|
||||
{
|
||||
Http::fake(function (ClientRequest $request) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user