fix: 存量报价审核生效并支持签署原件预览

This commit is contained in:
limeng 2026-10-17 12:48:24 +08:00
parent a0a6e9285a
commit 125e9ea8aa
11 changed files with 90 additions and 16 deletions

View File

@ -166,7 +166,7 @@ class CourierStockQuoteEntries extends Controller
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
if ($preview) {
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
}

View File

@ -61,6 +61,20 @@ class CourierQuoteVersion extends Model
return $this->status === self::STATUS_VOIDED;
}
/** 存量报价审核后按执行日期生效,不以补签或历史签署资料为前置条件。 */
public function isApprovedStockQuote(): bool
{
return $this->status === self::STATUS_APPROVED
&& $this->change?->status === CourierQuoteChange::STATUS_APPROVED
&& $this->change->isStockQuote();
}
public function signatureLabel(): string
{
return $this->isApprovedStockQuote() && !in_array($this->signature_status, ['历史签署资料已归档', '已签署并归档'], true)
? '存量报价,审核生效(无需签章)' : (string) $this->signature_status;
}
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');

View File

@ -142,7 +142,7 @@ final class CourierQuoteChangeService
$hasHistoricalFiles = $change->isStockQuote()
&& collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)->contains('kind', 'signed');
if ($change->isStockQuote() && $historicalDocumentsConfirmed && ! $hasHistoricalFiles) {
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或保留待确认/待补签状态。']);
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或取消历史资料核验;不影响存量报价审核生效。']);
}
$historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed;
$values = $source['values'];
@ -182,7 +182,7 @@ final class CourierQuoteChangeService
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '待确认/待补签') : '未签署',
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '存量报价,审核生效(无需签章)') : '未签署',
'created_by' => $change->created_by,
'approved_by' => $userId,
]);

View File

@ -370,7 +370,7 @@ final class CourierRequoteService
{
return $versions
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
&& self::isSignedVersion($version)
&& ($version->isApprovedStockQuote() || self::isSignedVersion($version))
&& ($includeTemporary || ! $version->isTemporary())
&& $version->coversDate($date))
->sortBy([

View File

@ -165,11 +165,12 @@ final class QuoteManagementSnapshot
'column_count' => $columnCount,
'formula_count' => $this->formulaCount($formulas),
'status' => $this->versionLifecycleStatus($version),
'signature_status' => $version->signature_status,
'signature_status' => $version->signatureLabel(),
'values' => $values,
'formulas' => $formulas,
'source_change_id' => $version->source_change_id,
'signed_files' => $this->signedFiles($version),
'stock_originals' => $this->stockOriginals($version),
'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i')
?? $version->signature?->archived_at?->format('Y-m-d H:i'),
];
@ -205,6 +206,24 @@ final class QuoteManagementSnapshot
return $files;
}
private function stockOriginals(CourierQuoteVersion $version): array
{
if (!$version->change?->isStockQuote() || !($entry = $version->change->stockEntry)) return [];
$files = [];
foreach ($entry->files as $index => $file) {
if (($file['kind'] ?? '') !== 'signed') continue;
// 早期手动上传未记录 MIME;入口按扩展名提供,响应仍按实际 MIME 白名单校验。
$previewable = isset($file['mime_type'])
? in_array($file['mime_type'], ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)
: in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true);
$files[] = [
'name' => $file['name'], 'previewable' => $previewable,
'url' => route('business.courier-stock-quotes.download', [$entry, $index, 'preview' => $previewable ? 1 : 0]),
];
}
return $files;
}
private function decodeMatrix(?string $json): array
{
if ($json === null || trim($json) === '') {
@ -224,7 +243,7 @@ final class QuoteManagementSnapshot
return '已作废';
}
if (! $this->isSigned($version)) {
if (! $this->isExecutable($version)) {
return '待生效(待签章)';
}
@ -235,10 +254,10 @@ final class QuoteManagementSnapshot
return $version->isTemporary() ? '生效中(临时)' : '生效中';
}
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
private function isSigned(CourierQuoteVersion $version): bool
/** 存量报价审核生效;其他报价仍要求回签归档。 */
private function isExecutable(CourierQuoteVersion $version): bool
{
return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
return $version->isApprovedStockQuote() || in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
@ -261,7 +280,7 @@ final class QuoteManagementSnapshot
: 'name:' . $version->company_name)) as $group) {
$current = CourierRequoteService::resolveAt($group, $today);
$base = CourierRequoteService::resolveAt($group, $today, false);
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version)
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isExecutable($version)
&& $version->effective_date !== null && $version->effective_date->lte($today));
foreach ($started as $version) {
if ($current !== null && $version->id === $current->id) {

View File

@ -22,7 +22,7 @@
@if(collect($stockEntry->files)->contains('kind', 'signed'))
<p><label><input type="checkbox" form="quote-approval-form" name="stock_signature_verified" value="1"> 我已核验历史签署资料与本报价一致,按历史签署资料归档</label></p>
@endif
<p class="quote-review__hint">未核验签署依据时,审核后仍为“待确认/待补签”。历史资料归档与本系统电子签署分别记录。</p>
<p class="quote-review__hint">存量报价审核通过后按实际执行日期生效,无需签章。历史签署资料可另行核验归档,不影响报价生效。</p>
</div>
@endif
</div>

View File

@ -88,7 +88,7 @@
<div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div>
<div><dt>{{ $stockEntry ? '录入范围' : '变价明细' }}</dt><dd>{{ $stockEntry ? $stockEntry->source_range : ($requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项') }}</dd></div>
<div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div>
<div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div>
<div><dt>签署状态</dt><dd>{{ $change->version?->signatureLabel() ?? '未进入签署' }}</dd></div>
</dl>
<div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div>
</div>

View File

@ -849,6 +849,7 @@
<div class="quote-modal__title" id="quote-detail-title" data-quote-detail="title"></div>
<div class="quote-modal__subtitle">原工作表内容 · 只读</div>
</div>
<div data-stock-original-links style="display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-left:auto"></div>
<button class="quote-modal__close" type="button" aria-label="关闭报价明细" title="关闭" data-quote-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
@ -1114,6 +1115,16 @@
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
var originals = root.querySelector('[data-stock-original-links]');
originals.replaceChildren();
(quote.stock_originals || []).forEach(function (file, index, files) {
var link = create('a', 'quote-button', (file.previewable ? '预览签署原件' : '下载签署原件') + (files.length > 1 ? ' ' + (index + 1) : ''));
link.href = file.url;
link.title = file.name;
link.target = '_blank';
link.rel = 'noopener noreferrer';
originals.appendChild(link);
});
var signedBox = root.querySelector('[data-quote-signed]');
var files = quote.signed_files || {};
signedBox.hidden = !files.signed;

View File

@ -69,7 +69,7 @@
<div class="stock-quote__field"><label for="source_quote">{{ $entry ? '替换当前报价 Excel(可选)' : '原报价 Excel(必填)' }}</label><input class="stock-quote__control" id="source_quote" name="source_quote" type="file" accept=".xlsx,.xls" @required(!$entry)><p class="stock-quote__hint">支持 xlsx、xls,单文件不超过 20 MB。原始内容和公式会保留。</p></div>
<div class="stock-quote__field"><label for="sheet_name">工作表名称</label><input class="stock-quote__control" id="sheet_name" name="sheet_name" value="{{ old('sheet_name', $entry?->sheet_name) }}" maxlength="255" placeholder="文件只有一张工作表时可留空"><p class="stock-quote__hint">多工作表文件请填写本次报价所在的准确工作表名称。</p></div>
<div class="stock-quote__field"><label for="confirmation_files">报价确认单、合同及其他依据(可选)</label><input class="stock-quote__control" id="confirmation_files" name="confirmation_files[]" type="file" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls,.eml,.msg"><p class="stock-quote__hint">最多 10 个,每个不超过 20 MB。</p></div>
<div class="stock-quote__field"><label for="signed_files">历史签署资料(可选)</label><input class="stock-quote__control" id="signed_files" name="signed_files[]" type="file" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"><p class="stock-quote__hint">上传资料后由审核人核验;缺少有效签署依据时标记为待确认/待补签。</p></div>
<div class="stock-quote__field"><label for="signed_files">历史签署资料(可选)</label><input class="stock-quote__control" id="signed_files" name="signed_files[]" type="file" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"><p class="stock-quote__hint">上传资料后由审核人核验归档;存量报价审核后按执行日期生效,无需签章。</p></div>
</div>
</div>
</section>

View File

@ -364,7 +364,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$reviewer->companies()->attach($this->company->id);
$changes = app(CourierQuoteChangeService::class);
$approved = $changes->approve($entry->change, (int) $reviewer->id);
$this->assertSame('待确认/待补签', $approved->version->signature_status);
$this->assertSame('存量报价,审核生效(无需签章)', $approved->version->signature_status);
$change = $changes->create([
'source_quote_id' => $approved->version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
'summary' => '基于存量版本调价,原件继续保留',

View File

@ -75,12 +75,18 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_quote_versions', 0);
$this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect();
$version = CourierQuoteVersion::firstOrFail();
$this->assertSame('待确认/待补签', $version->signature_status);
$this->assertSame('存量报价,审核生效(无需签章)', $version->signature_status);
$this->assertSame('EXP-001', $version->courier_expcode);
$this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $entry->change->fresh()->recalculation_status);
$this->assertSame('2025-12-01', $version->effective_date->toDateString());
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
$this->assertSame('待生效(待签章)', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
$this->assertSame('生效中', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
$this->assertSame($version->id, \App\Support\CourierRequoteService::resolveAt(collect([$version->fresh()]), now())->id);
// 已上线旧记录无需改库或重新审核,也按存量审核规则生效。
$version->update(['signature_status' => '待确认/待补签']);
$snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
$this->assertSame('生效中', $snapshot['status']);
$this->assertSame('存量报价,审核生效(无需签章)', $snapshot['signature_status']);
}
public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void
@ -110,6 +116,30 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_approved_stock_quote_exposes_all_originals_without_requiring_signature_verification(): void
{
$entry = $this->createEntry(['signed_files' => [
UploadedFile::fake()->createWithContent('signed-original.pdf', "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"),
UploadedFile::fake()->image('签署原件二.png'),
]]);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
$this->loginAs($this->reviewer(), $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect();
$quote = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
$this->assertSame('生效中', $quote['status']);
$this->assertCount(2, $quote['stock_originals']);
$this->assertSame([], $quote['signed_files']);
$this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']))
->assertOk()->assertSee('data-stock-original-links', false)->assertSee('预览签署原件');
foreach ($quote['stock_originals'] as $original) {
$this->assertTrue($original['previewable']);
$response = $this->loginAs()->get($original['url'])->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
$this->assertStringStartsWith('inline;', $response->headers->get('Content-Disposition'));
}
$version = CourierQuoteVersion::firstOrFail();
$version->update(['status' => CourierQuoteVersion::STATUS_VOIDED]);
$this->assertNull(\App\Support\CourierRequoteService::resolveAt(collect([$version->fresh()]), now()));
}
public function test_same_code_and_type_cannot_be_imported_twice_but_same_names_with_different_codes_can(): void
{
$this->createEntry();