fix: 存量报价审核生效并支持签署原件预览
This commit is contained in:
parent
a0a6e9285a
commit
125e9ea8aa
@ -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]);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' => '基于存量版本调价,原件继续保留',
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user