diff --git a/app/Http/Controllers/Business/AccessoryReshipDeductions.php b/app/Http/Controllers/Business/AccessoryReshipDeductions.php index a9b8a3b4..95364911 100644 --- a/app/Http/Controllers/Business/AccessoryReshipDeductions.php +++ b/app/Http/Controllers/Business/AccessoryReshipDeductions.php @@ -35,7 +35,7 @@ class AccessoryReshipDeductions extends Controller fwrite($file, "\xEF\xBB\xBF"); fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']); foreach ($data->rows($filters) as $row) { - if ($filters['match_status'] !== 'all' && $row['status'] !== $filters['match_status']) continue; + if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue; $values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']]; fputcsv($file, array_map(static function ($value) { $value = (string) $value; diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index beb94014..a2c58aca 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -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]); } diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 74916822..05bd7336 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -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'); diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index cd9f578b..7f6d64e1 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -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, ]); diff --git a/app/Support/CourierRequoteService.php b/app/Support/CourierRequoteService.php index c2ff6478..5bc9dbbb 100644 --- a/app/Support/CourierRequoteService.php +++ b/app/Support/CourierRequoteService.php @@ -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([ diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 9a1090cb..cbd3353e 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -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) { diff --git a/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php b/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php index b5b30e97..5b7d6fcc 100644 --- a/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php +++ b/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php @@ -44,6 +44,13 @@ class AccessoryReshipRateCalculator $row['deduction_rate_basis_points'] = null; $row['deduction_state'] = 'unmatched'; $row['deduction_note'] = '三个月内未匹配首发快递单号'; + $reshipCourier = trim((string) (($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''))); + if (in_array($reshipCourier, ['外采快递', '线下快递'], true)) { + $row['deduction_cents'] = 0; + $row['deduction_state'] = 'excluded_courier'; + $row['deduction_note'] = '补发快递为' . $reshipCourier . ',不参与扣款'; + return $row; + } if ($row['status'] === 'unmatched') { return $row; } @@ -79,6 +86,10 @@ class AccessoryReshipRateCalculator $row['deduction_cents'] = self::amount($row['freight_cents'], $rate); $row['deduction_state'] = $rate === 0 ? 'zero_rate' : 'ready'; $row['deduction_note'] = $rate === 0 ? '扣款比例0%,不扣款' : '按所选首发快递配置比例核算,仅计一次'; + if ($rate > 0 && $row['deduction_cents'] === 0) { + $row['deduction_state'] = 'zero_amount'; + $row['deduction_note'] = '扣款金额为0,不扣款'; + } } return $row; } diff --git a/modules/EcommerceFinance/View/AccessoryReshipPageData.php b/modules/EcommerceFinance/View/AccessoryReshipPageData.php index 0019b53b..1a6fe40f 100644 --- a/modules/EcommerceFinance/View/AccessoryReshipPageData.php +++ b/modules/EcommerceFinance/View/AccessoryReshipPageData.php @@ -23,8 +23,8 @@ class AccessoryReshipPageData { $month = request()->query('month', now()->format('Y-m')); abort_unless(is_string($month) && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) && (int) substr($month, 0, 4) >= 2000, 422, '请选择有效业务月份。'); - $status = request()->query('match_status', 'matched'); - $filters = ['month' => $month, 'match_status' => in_array($status, ['matched', 'unmatched', 'all'], true) ? $status : 'matched']; + $status = request()->query('match_status', 'ready'); + $filters = ['month' => $month, 'match_status' => in_array($status, ['ready', 'matched', 'unmatched', 'all'], true) ? $status : 'ready']; foreach (['trade_no','express_no','courier','ship_date','barcode','normalized_barcode','freight','deduction_state','original_courier','original_express_no','original_month','deduction_amount'] as $field) { $value = request()->query($field, ''); abort_unless(is_string($value) && mb_strlen($value) <= 200, 422, '筛选条件格式无效。'); @@ -93,7 +93,7 @@ class AccessoryReshipPageData if ($row['deduction_state'] === 'selection_required') $summary['ambiguous']++; if (isset($summary[$row['deduction_state']])) $summary[$row['deduction_state']]++; } - if ($filters['match_status'] !== 'all' && $filters['match_status'] !== $row['status']) continue; + if (!self::inCategory($row, $filters['match_status'])) continue; if ($total >= ($page - 1) * 50 && count($items) < 50) $items[] = $row; $total++; } @@ -113,4 +113,11 @@ class AccessoryReshipPageData 'reshipRows' => new LengthAwarePaginator($items, $total, 50, $page, ['path' => request()->url(), 'query' => request()->except('page')]), ]; } + + public static function inCategory(array $row, string $category): bool + { + return $category === 'all' || ($category === 'ready' + ? $row['deduction_state'] === 'ready' && $row['deduction_cents'] > 0 + : $row['status'] === $category); + } } diff --git a/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php b/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php index 13d7f429..ace6ec5d 100644 --- a/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php +++ b/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php @@ -1,7 +1,7 @@ @php $filters = $reshipFilters ?? []; $summary = $reshipSummary ?? []; - $labels = ['matched' => '已匹配项', 'unmatched' => '未匹配项', 'all' => '全部配件单']; + $labels = ['ready' => '可扣款', 'matched' => '已匹配项', 'unmatched' => '未匹配项', 'all' => '全部配件单']; $startMonth = \Carbon\Carbon::createFromFormat('!Y-m', ($filters['month'] ?? now()->format('Y-m')))->subMonths(2)->format('Y年n月'); @endphp
配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。仅匹配到首发快递单号的记录列为可扣款,扣款金额=补发运费×首发快递配置比例,按分四舍五入。未配置比例暂不核算,0%不扣款。
本页生成扣款核对清单,尚未记入应付账。首发快递名称相同自动任选一条,无需核对;名称不同请选择需要扣款的快递,仅计一次;运费缺失或异常需核实;重复匹配不会新增扣款记录。
配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。补发快递为外采快递或线下快递不参与扣款。可扣款列表仅包含已确定首发归属、已配置比例且扣款金额大于0的记录,扣款金额=补发运费×首发快递配置比例,按分四舍五入。未配置比例暂不核算,0%不扣款。
本页生成扣款核对清单,尚未记入应付账。首发快递名称相同自动任选一条,无需核对;名称不同请选择需要扣款的快递,仅计一次;运费缺失或异常需核实;重复匹配不会新增扣款记录。