feat(报价管理): 增加存量报价录入与历史签署资料归档 #346
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierQuoteChangeService;
|
||||
use App\Support\CourierOnboardingService;
|
||||
@ -47,7 +48,7 @@ class CourierQuoteChanges extends Controller
|
||||
$source = $this->service->findSourceQuote($selectedId);
|
||||
|
||||
// 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。
|
||||
if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
|
||||
if (empty($source['courier_expcode']) && $request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
|
||||
return redirect()->route('business.courier-onboardings.requote', $onboarding);
|
||||
}
|
||||
|
||||
@ -66,16 +67,18 @@ class CourierQuoteChanges extends Controller
|
||||
{
|
||||
$this->refreshSignatures();
|
||||
$quoteChange->load(['items', 'events.actor', 'version.signature']);
|
||||
$onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
|
||||
$stockEntry = $quoteChange->isStockQuote() ? CourierStockQuoteEntry::query()->where('source_change_id', $quoteChange->id)->firstOrFail() : null;
|
||||
$onboarding = $stockEntry === null && $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
|
||||
$requoteDiff = $onboarding !== null
|
||||
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
|
||||
: null;
|
||||
|
||||
return view('business.pages.courier-payables.quote-change-show', [
|
||||
'change' => $quoteChange,
|
||||
'stockEntry' => $stockEntry,
|
||||
'onboarding' => $onboarding,
|
||||
'requoteDiff' => $requoteDiff,
|
||||
'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
|
||||
'reviewWarnings' => $stockEntry === null && $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
|
||||
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
|
||||
: [],
|
||||
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
|
||||
@ -89,6 +92,9 @@ class CourierQuoteChanges extends Controller
|
||||
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
|
||||
{
|
||||
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
|
||||
if ($quoteChange->isStockQuote()) {
|
||||
return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id'));
|
||||
}
|
||||
if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) {
|
||||
return redirect()->route('business.courier-onboardings.requote', $onboarding);
|
||||
}
|
||||
@ -101,6 +107,7 @@ class CourierQuoteChanges extends Controller
|
||||
|
||||
public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
abort_if($quoteChange->isStockQuote(), 422, '请使用存量报价草稿入口修改。');
|
||||
$change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.edit', $change)
|
||||
@ -115,13 +122,21 @@ class CourierQuoteChanges extends Controller
|
||||
->with('success', "{$change->number} 已提交审核。");
|
||||
}
|
||||
|
||||
public function approve(CourierQuoteChange $quoteChange): RedirectResponse
|
||||
public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
|
||||
$change = $this->service->approve($quoteChange, user_id());
|
||||
if ($quoteChange->isStockQuote()) {
|
||||
$request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']);
|
||||
}
|
||||
$change = $this->service->approve($quoteChange, user_id(), $request->boolean('stock_signature_verified'));
|
||||
|
||||
$message = "审核完成,已生成新报价版本 {$change->version->version_code}。";
|
||||
|
||||
if ($change->isStockQuote()) {
|
||||
return redirect()->route('business.courier-quote-changes.show', $change)
|
||||
->with('success', $message . ' ' . $change->version->signature_status . '。原报价已保留,未自动发起电子签署或发送提醒。');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->signatureService->prepareVersion($change->version, user_id());
|
||||
$message .= ' 签署 PDF 已生成并固定校验值。';
|
||||
@ -154,6 +169,7 @@ class CourierQuoteChanges extends Controller
|
||||
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
|
||||
abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。');
|
||||
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
|
||||
abort_if($onboarding === null || $quoteChange->version === null, 404);
|
||||
app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id());
|
||||
|
||||
178
app/Http/Controllers/Business/CourierStockQuoteEntries.php
Normal file
178
app/Http/Controllers/Business/CourierStockQuoteEntries.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Support\CourierStockQuoteBatchService;
|
||||
use App\Support\CourierStockQuoteEntryService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]);
|
||||
}
|
||||
|
||||
public function single(): View
|
||||
{
|
||||
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]);
|
||||
}
|
||||
|
||||
public function previewBatch(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'billing_rules' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-stock-quotes.batch', $batch);
|
||||
}
|
||||
|
||||
public function showBatch(string $batch): View
|
||||
{
|
||||
$manifest = $this->batches->load($batch, (int) user_id());
|
||||
$couriers = $manifest['result'] === null ? $this->service->courierOptions() : [];
|
||||
|
||||
return view('business.pages.courier-payables.stock-quote-batch', [
|
||||
'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers,
|
||||
'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveBatch(Request $request, string $batch): RedirectResponse
|
||||
{
|
||||
// 单个 JSON 字段避免百家工作表触及 PHP 表单字段数量限制。
|
||||
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
|
||||
$selection = json_decode($request->input('selection_json'), true);
|
||||
if (! is_array($selection)) {
|
||||
throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
|
||||
}
|
||||
foreach (['quote_type', 'summary'] as $field) {
|
||||
if (is_string($selection[$field] ?? null)) {
|
||||
$selection[$field] = trim($selection[$field]);
|
||||
}
|
||||
}
|
||||
$data = Validator::make($selection, [
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'rule_groups.*' => ['array'],
|
||||
'rule_groups.*.name' => ['required', 'string', 'max:100'],
|
||||
'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'],
|
||||
'entries' => ['required', 'array', 'max:200'],
|
||||
'entries.*' => ['array'],
|
||||
'entries.*.selected' => ['required', 'boolean'],
|
||||
'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'],
|
||||
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
|
||||
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'submit_for_review' => ['required', 'boolean'],
|
||||
'confirmed' => ['accepted_if:submit_for_review,true'],
|
||||
])->validate();
|
||||
$this->batches->save($batch, (int) user_id(), $data);
|
||||
|
||||
return redirect()->route('business.courier-stock-quotes.batch', $batch);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
...$this->metadataRules(),
|
||||
'courier_expcode' => ['required', 'string', 'max:64'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'sheet_name' => ['nullable', 'string', 'max:255'],
|
||||
...$this->fileRules(true),
|
||||
]);
|
||||
$entry = $this->service->create($data, (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $entry->change)
|
||||
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
|
||||
}
|
||||
|
||||
public function edit(CourierStockQuoteEntry $stockQuoteEntry): View
|
||||
{
|
||||
$this->ensureEditable($stockQuoteEntry);
|
||||
|
||||
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => $stockQuoteEntry, 'couriers' => []]);
|
||||
}
|
||||
|
||||
public function update(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
|
||||
{
|
||||
$this->ensureEditable($stockQuoteEntry);
|
||||
$data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255']]);
|
||||
$this->service->update($stockQuoteEntry, $data, (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。');
|
||||
}
|
||||
|
||||
public function batchImage(Request $request, string $batch, int $sheet, int $file): StreamedResponse
|
||||
{
|
||||
$manifest = $this->batches->load($batch, (int) user_id());
|
||||
$attachment = $manifest['sheets'][$sheet]['signed_images'][$file] ?? null;
|
||||
|
||||
return $this->attachmentResponse($attachment, $request->boolean('preview'));
|
||||
}
|
||||
|
||||
public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse
|
||||
{
|
||||
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
|
||||
$attachment = $stockQuoteEntry->files[$file] ?? null;
|
||||
|
||||
return $this->attachmentResponse($attachment, $request->boolean('preview'));
|
||||
}
|
||||
|
||||
private function attachmentResponse(?array $attachment, bool $preview): StreamedResponse
|
||||
{
|
||||
$disk = Storage::disk('uploads');
|
||||
abort_if($attachment === null || ! $disk->exists($attachment['path']), 404, '附件不存在。');
|
||||
$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, '此格式请下载原文件查看。');
|
||||
|
||||
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
|
||||
}
|
||||
|
||||
return $disk->download($attachment['path'], $attachment['name'], $headers);
|
||||
}
|
||||
|
||||
private function metadataRules(): array
|
||||
{
|
||||
return [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
private function fileRules(bool $requiresSource): array
|
||||
{
|
||||
return [
|
||||
'source_quote' => [$requiresSource ? 'required' : 'nullable', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'confirmation_files' => ['nullable', 'array', 'max:10'],
|
||||
'confirmation_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png,xlsx,xls,eml,msg', 'max:20480'],
|
||||
'signed_files' => ['nullable', 'array', 'max:10'],
|
||||
'signed_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png', 'max:20480'],
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureEditable(CourierStockQuoteEntry $entry): void
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,11 @@ class CourierQuoteChange extends Model
|
||||
return $this->hasOne(CourierQuoteVersion::class, 'source_change_id');
|
||||
}
|
||||
|
||||
public function stockEntry(): HasOne
|
||||
{
|
||||
return $this->hasOne(CourierStockQuoteEntry::class, 'source_change_id');
|
||||
}
|
||||
|
||||
/** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */
|
||||
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
|
||||
public function periodLabel(): string
|
||||
@ -65,6 +70,11 @@ class CourierQuoteChange extends Model
|
||||
return is_array($this->quote_payload) && $this->quote_payload !== [];
|
||||
}
|
||||
|
||||
public function isStockQuote(): bool
|
||||
{
|
||||
return $this->change_type === '存量报价' && is_numeric(data_get($this->quote_payload, 'stock_quote_entry_id'));
|
||||
}
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true);
|
||||
|
||||
@ -12,7 +12,7 @@ class CourierQuoteVersion extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
||||
'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
|
||||
];
|
||||
|
||||
|
||||
38
app/Models/Business/CourierStockQuoteEntry.php
Normal file
38
app/Models/Business/CourierStockQuoteEntry.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CourierStockQuoteEntry extends Model
|
||||
{
|
||||
protected $table = 'business_courier_stock_quote_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'courier_expcode', 'courier_name', 'quote_type', 'source_change_id',
|
||||
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array'];
|
||||
|
||||
public function change(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');
|
||||
}
|
||||
|
||||
public function sourceQuote(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'STOCK-' . $this->id,
|
||||
'courier_expcode' => $this->courier_expcode,
|
||||
'billing_rules' => data_get($this->change?->quote_payload, 'billing_rules'),
|
||||
'company_name' => $this->courier_name,
|
||||
'sheet_name' => $this->sheet_name,
|
||||
'quote_type' => $this->quote_type,
|
||||
'source_range' => $this->source_range,
|
||||
'values' => json_decode($this->values_json, true, 512, JSON_THROW_ON_ERROR),
|
||||
'formulas' => json_decode($this->formulas_json, true, 512, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ namespace App\Support;
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierQuoteChangeEvent;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -17,10 +18,13 @@ final class CourierQuoteChangeService
|
||||
|
||||
public function create(array $data, int $userId): CourierQuoteChange
|
||||
{
|
||||
if (str_starts_with($data['source_quote_id'], 'STOCK-') && ($data['change_type'] ?? '') !== '存量报价') {
|
||||
throw ValidationException::withMessages(['source_quote_id' => '存量报价请先完成首版审核,再从正式报价版本发起变价。']);
|
||||
}
|
||||
$source = $this->findSourceQuote($data['source_quote_id']);
|
||||
|
||||
return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange {
|
||||
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
|
||||
$retrospective = ($data['change_type'] ?? '') !== '存量报价' && $this->isRetrospectiveDate($data['effective_date']);
|
||||
$change = CourierQuoteChange::create([
|
||||
'company_id' => company_id(),
|
||||
'number' => $this->nextChangeNumber(),
|
||||
@ -38,9 +42,13 @@ final class CourierQuoteChangeService
|
||||
'recalculation_note' => null,
|
||||
'summary' => $data['summary'],
|
||||
'created_by' => $userId,
|
||||
'quote_payload' => $data['quote_payload'] ?? null,
|
||||
'proposed_values_json' => $data['proposed_values_json'] ?? null,
|
||||
]);
|
||||
|
||||
$this->replaceItems($change, $data['items'], $source);
|
||||
if (! $change->isStockQuote()) {
|
||||
$this->replaceItems($change, $data['items'], $source);
|
||||
}
|
||||
$this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change));
|
||||
|
||||
return $change->load('items', 'events');
|
||||
@ -54,7 +62,7 @@ final class CourierQuoteChangeService
|
||||
|
||||
return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange {
|
||||
$from = $change->status;
|
||||
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
|
||||
$retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($data['effective_date']);
|
||||
$change->update([
|
||||
'change_type' => $data['change_type'],
|
||||
'effective_date' => $data['effective_date'],
|
||||
@ -65,8 +73,11 @@ final class CourierQuoteChangeService
|
||||
'recalculation_note' => null,
|
||||
'summary' => $data['summary'],
|
||||
'status' => CourierQuoteChange::STATUS_DRAFT,
|
||||
'quote_payload' => $change->isStockQuote() ? [...$change->quote_payload, 'billing_rules' => $data['billing_rules']] : $change->quote_payload,
|
||||
]);
|
||||
$this->replaceItems($change, $data['items'], $source);
|
||||
if (! $change->isStockQuote()) {
|
||||
$this->replaceItems($change, $data['items'], $source);
|
||||
}
|
||||
$this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change));
|
||||
|
||||
return $change->fresh(['items', 'events']);
|
||||
@ -77,13 +88,13 @@ final class CourierQuoteChangeService
|
||||
{
|
||||
$this->ensureEditableBy($change, $userId);
|
||||
|
||||
if ($change->items()->count() === 0) {
|
||||
if (! ($change->isStockQuote() && filled($change->proposed_values_json)) && $change->items()->count() === 0) {
|
||||
throw ValidationException::withMessages(['items' => '至少需要一条发生变化的报价明细。']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($change, $userId): CourierQuoteChange {
|
||||
$from = $change->status;
|
||||
$retrospective = $this->isRetrospectiveDate($change->effective_date);
|
||||
$retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date);
|
||||
$change->update([
|
||||
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
|
||||
'is_retrospective' => $retrospective,
|
||||
@ -114,7 +125,7 @@ final class CourierQuoteChangeService
|
||||
});
|
||||
}
|
||||
|
||||
public function approve(CourierQuoteChange $change, int $userId): CourierQuoteChange
|
||||
public function approve(CourierQuoteChange $change, int $userId, bool $historicalDocumentsConfirmed = false): CourierQuoteChange
|
||||
{
|
||||
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
||||
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
|
||||
@ -127,14 +138,20 @@ final class CourierQuoteChangeService
|
||||
|
||||
$source = $this->findSourceQuote($change->source_quote_id);
|
||||
|
||||
return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange {
|
||||
return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange {
|
||||
$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' => '尚未上传历史签署资料,请补充资料或保留待确认/待补签状态。']);
|
||||
}
|
||||
$historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed;
|
||||
$values = $source['values'];
|
||||
$formulas = $source['formulas'];
|
||||
|
||||
// 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。
|
||||
if ($change->hasQuotePayload() && filled($change->proposed_values_json)) {
|
||||
$values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR);
|
||||
$formulas = [];
|
||||
$formulas = $change->isStockQuote() ? $source['formulas'] : [];
|
||||
}
|
||||
|
||||
foreach ($change->hasQuotePayload() ? [] : $change->items as $item) {
|
||||
@ -156,6 +173,8 @@ final class CourierQuoteChangeService
|
||||
'sheet_name' => $source['sheet_name'],
|
||||
'company_name' => $source['company_name'],
|
||||
'carrier_name' => $source['company_name'],
|
||||
'courier_expcode' => $source['courier_expcode'] ?? null,
|
||||
'billing_rules' => $change->isStockQuote() ? data_get($change->quote_payload, 'billing_rules') : ($source['billing_rules'] ?? null),
|
||||
'quote_type' => $source['quote_type'],
|
||||
'effective_date' => $change->effective_date,
|
||||
'end_date' => $change->end_date,
|
||||
@ -163,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' => '未签署',
|
||||
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '待确认/待补签') : '未签署',
|
||||
'created_by' => $change->created_by,
|
||||
'approved_by' => $userId,
|
||||
]);
|
||||
@ -171,7 +190,7 @@ final class CourierQuoteChangeService
|
||||
'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id),
|
||||
]);
|
||||
|
||||
$retrospective = $this->isRetrospectiveDate($change->effective_date);
|
||||
$retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date);
|
||||
$recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null;
|
||||
$change->update([
|
||||
'status' => CourierQuoteChange::STATUS_APPROVED,
|
||||
@ -193,6 +212,7 @@ final class CourierQuoteChangeService
|
||||
$this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [
|
||||
...$this->changeDetails($change),
|
||||
'version_code' => $version->version_code,
|
||||
'historical_documents_confirmed' => $historicalSigned,
|
||||
]);
|
||||
|
||||
return $change->fresh(['items', 'events', 'version']);
|
||||
@ -201,6 +221,12 @@ final class CourierQuoteChangeService
|
||||
|
||||
public function findSourceQuote(string $id): array
|
||||
{
|
||||
if (preg_match('/^STOCK-([1-9][0-9]*)$/', $id, $match)) {
|
||||
$entry = CourierStockQuoteEntry::query()->where('company_id', company_id())->find($match[1]);
|
||||
if ($entry !== null) {
|
||||
return $entry->sourceQuote();
|
||||
}
|
||||
}
|
||||
$quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id);
|
||||
|
||||
if (! is_array($quote)) {
|
||||
@ -289,6 +315,12 @@ final class CourierQuoteChangeService
|
||||
private function changeDetails(CourierQuoteChange $change): array
|
||||
{
|
||||
return [
|
||||
...($change->isStockQuote() ? [
|
||||
'stock_quote_entry_id' => data_get($change->quote_payload, 'stock_quote_entry_id'),
|
||||
'billing_rules' => data_get($change->quote_payload, 'billing_rules'),
|
||||
'files' => collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)
|
||||
->map(static fn (array $file): array => \Illuminate\Support\Arr::only($file, ['kind', 'name', 'sha256']))->all(),
|
||||
] : []),
|
||||
'change_type' => $change->change_type,
|
||||
'effective_date' => $change->effective_date->toDateString(),
|
||||
'end_date' => $change->end_date?->toDateString(),
|
||||
|
||||
@ -13,6 +13,7 @@ final class CourierQuoteConfirmation
|
||||
$nextVersion = CourierQuoteVersion::query()
|
||||
->where('carrier_name', $version->carrier_name)
|
||||
->where('quote_type', $version->quote_type)
|
||||
->where('courier_expcode', $version->courier_expcode)
|
||||
->where('status', 'approved')
|
||||
->where('effective_date', '>', $version->effective_date)
|
||||
->whereNull('end_date')
|
||||
@ -21,7 +22,10 @@ final class CourierQuoteConfirmation
|
||||
// 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。
|
||||
$restore = $version->isTemporary()
|
||||
? \App\Support\CourierRequoteService::resolveAt(
|
||||
CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(),
|
||||
CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)
|
||||
->where('courier_expcode', $version->courier_expcode)
|
||||
->when(filled($version->courier_expcode), fn ($query) => $query->where('quote_type', $version->quote_type))
|
||||
->where('status', 'approved')->where('id', '!=', $version->id)->get(),
|
||||
$version->end_date->copy()->addDay(),
|
||||
false,
|
||||
)
|
||||
|
||||
@ -24,6 +24,7 @@ final class CourierQuotePdf
|
||||
'partyA' => $data['party_a'],
|
||||
'expiryDate' => $data['expiry_date'],
|
||||
'temporaryNote' => $data['temporary_note'] ?? null,
|
||||
'billingRules' => $version->billing_rules,
|
||||
'faceFee' => $data['face_fee'],
|
||||
'bubbleCoefficient' => $data['bubble_coefficient'],
|
||||
'signatureDate' => $signatureDate,
|
||||
|
||||
@ -69,6 +69,7 @@ final class CourierRequoteService
|
||||
return CourierQuoteVersion::query()
|
||||
->with('signature')
|
||||
->where('company_name', $onboarding->courier_name)
|
||||
->whereNull('courier_expcode')
|
||||
->where('status', 'approved')
|
||||
->latest('id')
|
||||
->first();
|
||||
@ -80,6 +81,7 @@ final class CourierRequoteService
|
||||
return CourierQuoteVersion::query()
|
||||
->with('signature')
|
||||
->where('company_name', $onboarding->courier_name)
|
||||
->whereNull('courier_expcode')
|
||||
->where('status', 'approved')
|
||||
->latest('id')
|
||||
->get();
|
||||
@ -92,6 +94,7 @@ final class CourierRequoteService
|
||||
->where('carrier_name', $onboarding->courier_name)
|
||||
->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT])
|
||||
->whereNotNull('quote_payload')
|
||||
->where('change_type', '!=', '存量报价')
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
@ -352,6 +355,7 @@ final class CourierRequoteService
|
||||
$versions = CourierQuoteVersion::query()
|
||||
->with('signature')
|
||||
->where('company_name', $courierName)
|
||||
->whereNull('courier_expcode')
|
||||
->where('status', CourierQuoteVersion::STATUS_APPROVED)
|
||||
->get();
|
||||
|
||||
@ -378,7 +382,7 @@ final class CourierRequoteService
|
||||
|
||||
public static function isSignedVersion(CourierQuoteVersion $version): bool
|
||||
{
|
||||
return $version->signature_status === '已签署并归档'
|
||||
return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|
||||
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
|
||||
}
|
||||
|
||||
|
||||
167
app/Support/CourierStockQuoteBatchService.php
Normal file
167
app/Support/CourierStockQuoteBatchService.php
Normal file
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CourierStockQuoteBatchService
|
||||
{
|
||||
public function __construct(private readonly CourierStockQuoteEntryService $entries, private readonly CourierQuoteChangeService $changes)
|
||||
{
|
||||
}
|
||||
|
||||
public function ruleTemplates(): array
|
||||
{
|
||||
return CourierQuoteVersion::query()->whereNotNull('billing_rules')->latest('id')->limit(100)->get()
|
||||
->filter(fn ($version) => filled($version->billing_rules))
|
||||
->unique('billing_rules')->map(fn ($version) => [
|
||||
'name' => $version->carrier_name . ' · ' . $version->quote_type,
|
||||
'rules' => $version->billing_rules,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
public function preview(UploadedFile $file, array $defaults, int $userId): string
|
||||
{
|
||||
$batch = (string) Str::uuid();
|
||||
$disk = Storage::disk('uploads');
|
||||
$savedPaths = [];
|
||||
try {
|
||||
$originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private');
|
||||
if ($originalPath === false) {
|
||||
throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']);
|
||||
}
|
||||
$savedPaths[] = $originalPath;
|
||||
$sheets = $this->entries->readBatchWorkbook($file, $this->directory($batch, $userId) . '/images', $savedPaths);
|
||||
$manifest = [
|
||||
'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(),
|
||||
'defaults' => $defaults, 'sheets' => $sheets, 'result' => null,
|
||||
'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())],
|
||||
];
|
||||
$savedPaths[] = $this->directory($batch, $userId) . '/manifest.json';
|
||||
$this->write($batch, $userId, $manifest);
|
||||
} catch (\Throwable $exception) {
|
||||
$disk->delete($savedPaths);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
public function load(string $batch, int $userId): array
|
||||
{
|
||||
abort_unless(Str::isUuid($batch), 404);
|
||||
$disk = Storage::disk('uploads');
|
||||
$path = $this->directory($batch, $userId) . '/manifest.json';
|
||||
abort_unless($disk->exists($path), 404, '导入预览不存在,请重新上传报价。');
|
||||
$manifest = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless($manifest['company_id'] === (int) company_id() && $manifest['user_id'] === $userId, 404);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
public function rows(array $manifest, array $couriers): array
|
||||
{
|
||||
$existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
|
||||
->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
|
||||
|
||||
return array_map(function (array $sheet) use ($manifest, $couriers, $existing): array {
|
||||
$key = mb_strtolower(trim($sheet['sheet_name']));
|
||||
$matches = array_values(array_filter($couriers, static fn ($courier) => in_array($key, [
|
||||
mb_strtolower($courier['code']), mb_strtolower(trim($courier['name'])),
|
||||
mb_strtolower($courier['name'] . ' · ' . $courier['code']),
|
||||
], true)));
|
||||
$code = count($matches) === 1 ? $matches[0]['code'] : '';
|
||||
$duplicate = $code !== '' && isset($existing[$code . '|' . $manifest['defaults']['quote_type']]);
|
||||
|
||||
return [...$sheet, 'courier_expcode' => $code, 'duplicate' => $duplicate,
|
||||
'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称重复,请选编号' : '待匹配,请选编号'))),
|
||||
];
|
||||
}, $manifest['sheets']);
|
||||
}
|
||||
|
||||
public function save(string $batch, int $userId, array $data): array
|
||||
{
|
||||
$manifest = $this->load($batch, $userId);
|
||||
if ($manifest['result'] !== null) {
|
||||
return $manifest['result'];
|
||||
}
|
||||
$couriers = collect($this->entries->courierOptions())->keyBy('code');
|
||||
$selected = array_filter($data['entries'], static fn ($row) => (bool) ($row['selected'] ?? false));
|
||||
if ($selected === []) {
|
||||
throw ValidationException::withMessages(['entries' => '请至少选择一家需要录入的快递。']);
|
||||
}
|
||||
$errors = $prepared = $identities = [];
|
||||
$existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
|
||||
->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
|
||||
foreach ($selected as $index => $row) {
|
||||
$sheet = $manifest['sheets'][$index] ?? null;
|
||||
$courier = $couriers->get(trim($row['courier_expcode'] ?? ''));
|
||||
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
|
||||
$metadata = [
|
||||
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
|
||||
'billing_rules' => trim($rules), 'summary' => $data['summary'],
|
||||
];
|
||||
$validation = Validator::make($metadata, [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
|
||||
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
|
||||
$reason ??= $courier === null ? '请选择已有快递资料中的唯一编号。' : null;
|
||||
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
|
||||
foreach ($sheet['signed_images'] ?? [] as $image) {
|
||||
if (! Storage::disk('uploads')->exists($image['path'])) {
|
||||
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
|
||||
}
|
||||
}
|
||||
$identity = ($courier['code'] ?? '') . '|' . $metadata['quote_type'];
|
||||
$reason ??= isset($existing[$identity]) ? '此快递编号的同类报价已录入,请取消选择;后续调价使用变价。' : null;
|
||||
$reason ??= isset($identities[$identity]) ? '与本批其他工作表匹配了同一快递编号,请检查或取消选择。' : null;
|
||||
if ($reason !== null) {
|
||||
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
|
||||
} else {
|
||||
$prepared[] = compact('metadata', 'courier', 'sheet');
|
||||
}
|
||||
$identities[$identity] = true;
|
||||
}
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。');
|
||||
// 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。
|
||||
return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array {
|
||||
$created = [];
|
||||
foreach ($prepared as $item) {
|
||||
$entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file'], ...($item['sheet']['signed_images'] ?? [])]);
|
||||
if ($data['submit_for_review'] ?? false) {
|
||||
$this->changes->submit($entry->change, $userId);
|
||||
}
|
||||
$created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode];
|
||||
}
|
||||
|
||||
$result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
|
||||
$manifest['result'] = $result;
|
||||
$this->write($batch, $userId, $manifest);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
private function directory(string $batch, int $userId): string
|
||||
{
|
||||
return 'courier-stock-quote-batches/' . company_id() . '/' . $userId . '/' . $batch;
|
||||
}
|
||||
|
||||
private function write(string $batch, int $userId, array $manifest): void
|
||||
{
|
||||
if (! Storage::disk('uploads')->put($this->directory($batch, $userId) . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'private')) {
|
||||
throw ValidationException::withMessages(['source_quote' => '导入预览保存失败,请重试。']);
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/Support/CourierStockQuoteEntryService.php
Normal file
253
app/Support/CourierStockQuoteEntryService.php
Normal file
@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierOnboarding;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
final class CourierStockQuoteEntryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourierInformationMirrorRepository $mirror,
|
||||
private readonly CourierQuoteChangeService $changes,
|
||||
) {
|
||||
}
|
||||
|
||||
/** 编号是唯一身份;不按品牌或名称合并不同网点、结算主体。 */
|
||||
public function courierOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($this->mirror->overviewSource()['express'] ?? [] as $express) {
|
||||
$code = trim((string) $express->expcode);
|
||||
if ($code !== '' && filled($express->expname)) {
|
||||
$options[$code] = ['code' => $code, 'name' => (string) $express->expname];
|
||||
}
|
||||
}
|
||||
foreach (CourierOnboarding::query()->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
|
||||
->whereNotNull('workplatform_expcode')->get() as $courier) {
|
||||
$code = trim((string) $courier->workplatform_expcode);
|
||||
if ($code !== '' && filled($courier->courier_name)) {
|
||||
$options[$code] ??= ['code' => $code, 'name' => $courier->courier_name];
|
||||
}
|
||||
}
|
||||
uasort($options, static fn (array $a, array $b): int => [$a['name'], $a['code']] <=> [$b['name'], $b['code']]);
|
||||
|
||||
return array_values($options);
|
||||
}
|
||||
|
||||
/** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */
|
||||
public function readWorkbook(UploadedFile $file, ?string $sheetName): array
|
||||
{
|
||||
$book = null;
|
||||
try {
|
||||
$reader = IOFactory::createReaderForFile($file->getRealPath());
|
||||
$sheets = $reader->listWorksheetNames($file->getRealPath());
|
||||
if (blank($sheetName) && count($sheets) !== 1) {
|
||||
throw ValidationException::withMessages(['sheet_name' => '文件有多个工作表,请填写本次录入的准确工作表名称:' . implode('、', $sheets)]);
|
||||
}
|
||||
$sheetName = filled($sheetName) ? $sheetName : $sheets[0];
|
||||
if (! in_array($sheetName, $sheets, true)) {
|
||||
throw ValidationException::withMessages(['sheet_name' => '工作表不存在,可选:' . implode('、', $sheets)]);
|
||||
}
|
||||
foreach ($reader->listWorksheetInfo($file->getRealPath()) as $info) {
|
||||
if ($info['worksheetName'] === $sheetName && ($info['totalRows'] > 2000 || $info['totalColumns'] > 100 || $info['totalRows'] * $info['totalColumns'] > 50000)) {
|
||||
throw ValidationException::withMessages(['source_quote' => '单份报价最多 2000 行、100 列、50000 个单元格,请按快递及报价类型拆分。']);
|
||||
}
|
||||
}
|
||||
$reader->setReadDataOnly(false);
|
||||
$reader->setLoadSheetsOnly([$sheetName]);
|
||||
$book = $reader->load($file->getRealPath());
|
||||
$sheet = $book->getSheetByName($sheetName);
|
||||
return $this->readSheet($sheet);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']);
|
||||
} finally {
|
||||
$book?->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次加载整本文件;各工作表保持独立,不计算公式。 */
|
||||
public function readBatchWorkbook(UploadedFile $file, string $imageDirectory, array &$savedPaths): array
|
||||
{
|
||||
$book = null;
|
||||
try {
|
||||
$reader = IOFactory::createReaderForFile($file->getRealPath());
|
||||
$info = $reader->listWorksheetInfo($file->getRealPath());
|
||||
if (count($info) > 200) {
|
||||
throw ValidationException::withMessages(['source_quote' => '一次最多导入 200 张工作表,请分批上传。']);
|
||||
}
|
||||
$cells = 0;
|
||||
foreach ($info as $sheet) {
|
||||
$cells += $sheet['totalRows'] * $sheet['totalColumns'];
|
||||
if ($sheet['totalRows'] > 2000 || $sheet['totalColumns'] > 100 || $sheet['totalRows'] * $sheet['totalColumns'] > 50000) {
|
||||
throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet['worksheetName'] . '」超过 2000 行、100 列或 50000 格,请缩小范围后上传。']);
|
||||
}
|
||||
}
|
||||
if ($cells > 500000) {
|
||||
throw ValidationException::withMessages(['source_quote' => '整本报价最多 500000 个单元格,请分批上传。']);
|
||||
}
|
||||
$reader->setReadDataOnly(false);
|
||||
$reader->setAllowExternalImages(false);
|
||||
$book = $reader->load($file->getRealPath());
|
||||
$sheets = [];
|
||||
foreach ($book->getWorksheetIterator() as $index => $sheet) {
|
||||
try {
|
||||
$matrix = [...$this->readSheet($sheet), 'error' => null];
|
||||
} catch (ValidationException $exception) {
|
||||
$matrix = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。'];
|
||||
}
|
||||
$sheets[] = [...$matrix, ...app(CourierStockQuoteImageArchive::class)->store($sheet, $file->getRealPath(), $imageDirectory . '/' . $index, $savedPaths)];
|
||||
}
|
||||
|
||||
return $sheets;
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']);
|
||||
} finally {
|
||||
$book?->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
private function readSheet(Worksheet $sheet): array
|
||||
{
|
||||
$lastRow = $sheet->getHighestDataRow();
|
||||
$lastColumn = $sheet->getHighestDataColumn();
|
||||
$columns = Coordinate::columnIndexFromString($lastColumn);
|
||||
$values = $formulas = [];
|
||||
$hasContent = false;
|
||||
for ($row = 1; $row <= $lastRow; $row++) {
|
||||
$valueRow = $formulaRow = [];
|
||||
for ($column = 1; $column <= $columns; $column++) {
|
||||
$cell = $sheet->getCell([$column, $row]);
|
||||
$formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null;
|
||||
$value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue();
|
||||
$valueRow[] = is_object($value) ? (string) $value : $value;
|
||||
$formulaRow[] = $formula;
|
||||
$hasContent = $hasContent || ($value !== null && $value !== '');
|
||||
}
|
||||
$values[] = $valueRow;
|
||||
$formulas[] = $formulaRow;
|
||||
}
|
||||
if (! $hasContent) {
|
||||
throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']);
|
||||
}
|
||||
|
||||
return ['sheet_name' => $sheet->getTitle(), 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas];
|
||||
}
|
||||
|
||||
public function create(array $data, int $userId): CourierStockQuoteEntry
|
||||
{
|
||||
$data['quote_type'] = trim($data['quote_type']);
|
||||
$courier = collect($this->courierOptions())->firstWhere('code', $data['courier_expcode']);
|
||||
if ($courier === null) {
|
||||
throw ValidationException::withMessages(['courier_expcode' => '请选择已有快递资料中的唯一编号;没有编号时请先完善快递资料。']);
|
||||
}
|
||||
if (CourierStockQuoteEntry::withTrashed()->where('courier_expcode', $courier['code'])->where('quote_type', $data['quote_type'])->exists()) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
|
||||
|
||||
return $this->createPrepared($data, $userId, $courier, $matrix);
|
||||
}
|
||||
|
||||
/** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */
|
||||
public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry
|
||||
{
|
||||
$savedPaths = [];
|
||||
try {
|
||||
return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry {
|
||||
$entry = CourierStockQuoteEntry::create([
|
||||
'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'],
|
||||
'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'],
|
||||
'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'files' => [], 'created_by' => $userId,
|
||||
]);
|
||||
$files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)];
|
||||
$entry->update(['files' => $files]);
|
||||
$change = $this->changes->create([
|
||||
'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价',
|
||||
'effective_date' => $data['effective_date'], 'summary' => $data['summary'], 'items' => [],
|
||||
'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']],
|
||||
'proposed_values_json' => $entry->values_json,
|
||||
], $userId);
|
||||
$entry->update(['source_change_id' => $change->id]);
|
||||
|
||||
return $entry->load('change');
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
if ($savedPaths !== []) {
|
||||
Storage::disk('uploads')->delete($savedPaths);
|
||||
}
|
||||
if ($exception instanceof UniqueConstraintViolationException) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function update(CourierStockQuoteEntry $entry, array $data, int $userId): void
|
||||
{
|
||||
$matrix = isset($data['source_quote']) ? $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null) : null;
|
||||
$savedPaths = [];
|
||||
try {
|
||||
DB::transaction(function () use ($entry, $data, $userId, $matrix, &$savedPaths): void {
|
||||
$files = $entry->files;
|
||||
if ($matrix !== null) {
|
||||
foreach ($files as &$file) {
|
||||
if ($file['kind'] === 'source_quote') {
|
||||
$file['kind'] = 'previous_source_quote';
|
||||
}
|
||||
}
|
||||
unset($file);
|
||||
$entry->fill([
|
||||
'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'],
|
||||
'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
$entry->fill(['files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)]])->save();
|
||||
$entry->change->update(['source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]);
|
||||
$this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('uploads')->delete($savedPaths);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function storeFiles(CourierStockQuoteEntry $entry, array $data, array &$savedPaths): array
|
||||
{
|
||||
$files = [];
|
||||
foreach (['source_quote' => isset($data['source_quote']) ? [$data['source_quote']] : [], 'confirmation' => $data['confirmation_files'] ?? [], 'signed' => $data['signed_files'] ?? []] as $kind => $uploads) {
|
||||
foreach ($uploads as $upload) {
|
||||
$path = Storage::disk('uploads')->putFile('courier-stock-quotes/' . company_id() . "/{$entry->id}/{$kind}", $upload, 'private');
|
||||
if ($path === false) {
|
||||
throw ValidationException::withMessages(['source_quote' => '附件保存失败,请重试;本次录入未保存。']);
|
||||
}
|
||||
$savedPaths[] = $path;
|
||||
$files[] = ['kind' => $kind, 'path' => $path, 'name' => $upload->getClientOriginalName(), 'sha256' => hash_file('sha256', $upload->getRealPath())];
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function duplicateError(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['courier_expcode' => '该快递编号的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
|
||||
}
|
||||
}
|
||||
59
app/Support/CourierStockQuoteImageArchive.php
Normal file
59
app/Support/CourierStockQuoteImageArchive.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
final class CourierStockQuoteImageArchive
|
||||
{
|
||||
/** 原样复制工作表内嵌图片,不使用屏幕截图、裁剪结果或重新编码的图片冒充原件。 */
|
||||
public function store(Worksheet $sheet, string $workbookPath, string $directory, array &$savedPaths): array
|
||||
{
|
||||
$files = $warnings = [];
|
||||
$prefix = 'zip://' . realpath($workbookPath) . '#';
|
||||
foreach ($sheet->getDrawingCollection() as $index => $drawing) {
|
||||
if (! $drawing instanceof Drawing || ! str_starts_with($drawing->getPath(), $prefix)) {
|
||||
$warnings[] = '图片 ' . ($index + 1) . ' 无法无损提取,请另附原图;整本 Excel 已保留。';
|
||||
continue;
|
||||
}
|
||||
$stream = @fopen($drawing->getPath(), 'rb');
|
||||
if ($stream === false) {
|
||||
$this->failed($sheet);
|
||||
}
|
||||
try {
|
||||
$bytes = stream_get_contents($stream, 20 * 1024 * 1024 + 1);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
if ($bytes === false || $bytes === '' || strlen($bytes) > 20 * 1024 * 1024) {
|
||||
$this->failed($sheet);
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
|
||||
$extension = strtolower(pathinfo($drawing->getPath(), PATHINFO_EXTENSION));
|
||||
// 不支持浏览器预览的原始格式仍按原字节留存,只提供下载。
|
||||
$extension = preg_match('/^[a-z0-9]{1,8}$/', $extension) ? $extension : 'bin';
|
||||
$name = $sheet->getTitle() . '-原件图片-' . ($index + 1) . '.' . $extension;
|
||||
$path = $directory . '/' . ($index + 1) . '.' . $extension;
|
||||
$savedPaths[] = $path;
|
||||
if (! Storage::disk('uploads')->put($path, $bytes, 'private')) {
|
||||
$this->failed($sheet);
|
||||
}
|
||||
$files[] = [
|
||||
'kind' => 'signed', 'path' => $path, 'name' => $name, 'sha256' => hash('sha256', $bytes),
|
||||
'mime_type' => $mime, 'size' => strlen($bytes), 'sheet_name' => $sheet->getTitle(),
|
||||
'anchor' => $drawing->getCoordinates(), 'image_index' => $index + 1,
|
||||
'source' => 'workbook_embedded', 'archived_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return ['signed_images' => $files, 'image_warnings' => array_values(array_unique($warnings))];
|
||||
}
|
||||
|
||||
private function failed(Worksheet $sheet): never
|
||||
{
|
||||
throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet->getTitle() . '」原件图片提取或存档失败(每张最多 20 MB),本次上传未完成,请重试。']);
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,7 @@ final class QuoteManagementSnapshot
|
||||
? app(CourierRequoteService::class)->onboardedCourierIds()
|
||||
: [];
|
||||
|
||||
$versions = CourierQuoteVersion::query()->with('signature')->orderBy('id')->get();
|
||||
$versions = CourierQuoteVersion::query()->with(['signature', 'change.stockEntry'])->orderBy('id')->get();
|
||||
$superseded = $this->supersededVersions($versions);
|
||||
$changeTypes = \App\Models\Business\CourierQuoteChange::query()
|
||||
->whereIn('id', $versions->pluck('source_change_id')->filter()->unique()->all())
|
||||
@ -99,7 +99,7 @@ final class QuoteManagementSnapshot
|
||||
$quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType);
|
||||
}
|
||||
$onboardingId = $onboarded[$version->company_name] ?? null;
|
||||
$quote['requote_url'] = $onboardingId ? route('business.courier-onboardings.requote', $onboardingId) : null;
|
||||
$quote['requote_url'] = $onboardingId && blank($version->courier_expcode) ? route('business.courier-onboardings.requote', $onboardingId) : null;
|
||||
|
||||
return $quote;
|
||||
})
|
||||
@ -152,6 +152,8 @@ final class QuoteManagementSnapshot
|
||||
'sheet_index' => null,
|
||||
'sheet_name' => $version->sheet_name,
|
||||
'company_name' => $version->company_name,
|
||||
'courier_expcode' => $version->courier_expcode,
|
||||
'billing_rules' => $version->billing_rules,
|
||||
'carrier' => $version->carrier_name,
|
||||
'quote_type' => $version->quote_type,
|
||||
'effective_date' => $version->effective_date?->toDateString(),
|
||||
@ -179,6 +181,13 @@ final class QuoteManagementSnapshot
|
||||
*/
|
||||
private function signedFiles(CourierQuoteVersion $version): array
|
||||
{
|
||||
if ($version->signature_status === '历史签署资料已归档' && ($entry = $version->change?->stockEntry)) {
|
||||
foreach ($entry->files as $index => $file) {
|
||||
if ($file['kind'] === 'signed') {
|
||||
return ['signed' => route('business.courier-stock-quotes.download', [$entry, $index])];
|
||||
}
|
||||
}
|
||||
}
|
||||
$signature = $version->signature;
|
||||
if ($signature === null || $signature->status !== CourierQuoteSignature::STATUS_ARCHIVED || blank($signature->signed_pdf_path)) {
|
||||
return [];
|
||||
@ -228,7 +237,7 @@ final class QuoteManagementSnapshot
|
||||
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
|
||||
private function isSigned(CourierQuoteVersion $version): bool
|
||||
{
|
||||
return $version->signature_status === '已签署并归档'
|
||||
return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|
||||
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
|
||||
}
|
||||
|
||||
@ -244,7 +253,9 @@ final class QuoteManagementSnapshot
|
||||
{
|
||||
$result = [];
|
||||
$today = today();
|
||||
foreach ($versions->groupBy('company_name') as $group) {
|
||||
foreach ($versions->groupBy(static fn (CourierQuoteVersion $version): string => filled($version->courier_expcode)
|
||||
? 'code:' . $version->courier_expcode . ':' . $version->quote_type
|
||||
: '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)
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('business_courier_stock_quote_entries', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('courier_expcode', 64);
|
||||
$table->string('courier_name');
|
||||
$table->string('quote_type', 64);
|
||||
$table->unsignedInteger('source_change_id')->nullable();
|
||||
$table->string('sheet_name');
|
||||
$table->string('source_range', 32);
|
||||
$table->longText('values_json');
|
||||
$table->longText('formulas_json');
|
||||
$table->longText('files');
|
||||
$table->unsignedInteger('created_by');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->unique(['company_id', 'courier_expcode', 'quote_type'], 'courier_stock_quote_identity_unique');
|
||||
});
|
||||
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
$table->string('courier_expcode', 64)->nullable()->index();
|
||||
$table->text('billing_rules')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
$table->dropColumn(['courier_expcode', 'billing_rules']);
|
||||
});
|
||||
Schema::dropIfExists('business_courier_stock_quote_entries');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
@php($stockSource = $stockEntry->sourceQuote())
|
||||
<section class="quote-review__panel" aria-labelledby="stock-source-title">
|
||||
<header class="quote-review__header"><div class="quote-review__title" id="stock-source-title"><span class="material-icons-outlined" aria-hidden="true">inventory_2</span><span>存量报价原文与核对</span></div></header>
|
||||
<div class="quote-review__body">
|
||||
<p>快递编号:<strong>{{ $stockEntry->courier_expcode }}</strong> · 工作表:{{ $stockEntry->sheet_name }} · 范围:{{ $stockEntry->source_range }}</p>
|
||||
<p style="white-space:pre-wrap"><strong>计费规则</strong><br>{{ data_get($change->quote_payload, 'billing_rules') }}</p>
|
||||
<p class="quote-review__hint">请逐项核对报价金额、重量段、计费方式和执行日期。公式保留原表达式及文件中的缓存值;原文件可下载复核。</p>
|
||||
<div class="quote-review__files">
|
||||
@foreach($stockEntry->files as $index => $file)
|
||||
<a class="quote-review__button" href="{{ route('business.courier-stock-quotes.download', [$stockEntry, $index]) }}" title="SHA256:{{ $file['sha256'] }}">{{ ['source_quote' => '原报价', 'previous_source_quote' => '历次原报价', 'confirmation' => '报价依据', 'signed' => '历史签署资料'][$file['kind']] }} · {{ $file['name'] }}</a>
|
||||
@if(in_array($file['mime_type'] ?? '', ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true))
|
||||
<a class="quote-review__button" href="{{ route('business.courier-stock-quotes.download', [$stockEntry, $index, 'preview' => 1]) }}" target="_blank" rel="noopener">查看原图 · {{ $file['sheet_name'] ?? $file['name'] }} · {{ $file['anchor'] ?? '' }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if(collect($stockEntry->files)->contains('source', 'workbook_embedded'))
|
||||
<p class="quote-review__hint">工作表内嵌图片已按原始字节独立留存,未裁剪、未压缩。提取成功不代表已核对一致;后续调价仍保留本版原件。</p>
|
||||
@endif
|
||||
@if($canReview && $change->status === 'pending_review')
|
||||
<div class="quote-review__form">
|
||||
<label><input type="checkbox" form="quote-approval-form" name="stock_rules_confirmed" value="1" required> 我已逐项核对原报价、计费规则和实际执行日期</label>
|
||||
@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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="quote-review__table-wrap" style="max-height:60vh;overflow:auto" tabindex="0" aria-label="原报价预览">
|
||||
<table data-table-key="stock-quote-original">
|
||||
<thead style="position:sticky;top:0;background:var(--qr-gold-soft,#F7F0DE)"><tr><th>行号</th>@for($column = 1; $column <= count($stockSource['values'][0] ?? []); $column++)<th>{{ \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($column) }}</th>@endfor</tr></thead>
|
||||
<tbody>
|
||||
@foreach($stockSource['values'] as $rowIndex => $row)
|
||||
<tr><th scope="row">{{ $rowIndex + 1 }}</th>@foreach($row as $columnIndex => $value)<td @class(['quote-review__number' => is_numeric($value)]) title="{{ $stockSource['formulas'][$rowIndex][$columnIndex] ?? '' }}" style="white-space:pre-wrap">{{ $value ?? '' }}@if(filled($stockSource['formulas'][$rowIndex][$columnIndex] ?? null))<small class="quote-review__hint" style="display:block">{{ $stockSource['formulas'][$rowIndex][$columnIndex] }}</small>@endif</td>@endforeach</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@ -59,13 +59,43 @@
|
||||
@media(max-width:800px){.quote-review{margin:16px}.quote-review__toolbar{align-items:stretch;flex-direction:column}.quote-review__meta,.quote-review__signature-meta,.quote-review__form-grid{grid-template-columns:1fr}.quote-review__event{grid-template-columns:1fr}.quote-review__review-form,.quote-review__form-actions{align-items:stretch;flex-direction:column}}
|
||||
</style>
|
||||
<main class="quote-review">
|
||||
<div class="quote-review__toolbar"><a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a><div class="quote-review__actions">@if($change->isEditable() && $change->created_by===user_id())<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a><form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>@endif @if($canReview && $change->status==='pending_review')<form method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>@endif</div></div>
|
||||
<div class="quote-review__toolbar">
|
||||
<a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a>
|
||||
<div class="quote-review__actions">
|
||||
@if($change->isEditable() && $change->created_by===user_id())
|
||||
<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a>
|
||||
<form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>
|
||||
@endif
|
||||
@if($canReview && $change->status==='pending_review')
|
||||
<form id="quote-approval-form" method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
|
||||
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
|
||||
@if($reviewWarnings)<div class="quote-review__alert" role="alert" style="border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">report</span><div><strong>审核提醒(请重点核对)</strong>@foreach($reviewWarnings as $warning)<br>· {{ $warning }}@endforeach</div></div>@endif
|
||||
@if($change->isRetrospective())<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><div><strong>追溯变价 · {{ $change->recalculationStatusLabel() }}</strong><br>自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}</div></div>@endif
|
||||
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>执行期间</dt><dd>{{ $change->periodLabel() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $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></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
|
||||
@if($requoteDiff)
|
||||
<section class="quote-review__panel" aria-labelledby="review-base-title">
|
||||
<header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header>
|
||||
<div class="quote-review__body">
|
||||
<dl class="quote-review__meta">
|
||||
<div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div>
|
||||
<div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div>
|
||||
<div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div>
|
||||
<div><dt>执行期间</dt><dd>{{ $change->periodLabel() }}</dd></div>
|
||||
<div><dt>生效方式</dt><dd>{{ $stockEntry ? '存量报价补录' : ($change->isRetrospective() ? '追溯变价' : '正常生效') }}</dd></div>
|
||||
<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>
|
||||
</dl>
|
||||
<div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div>
|
||||
</div>
|
||||
</section>
|
||||
@if($stockEntry)
|
||||
@include('business.pages.courier-payables.partials.stock-quote-review')
|
||||
@elseif($requoteDiff)
|
||||
@php($fmt = static fn ($v) => $v === null ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'))
|
||||
<section class="quote-review__panel" aria-labelledby="review-diff-title">
|
||||
<header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧报价对比 · {{ $change->change_type }}</span></div></header>
|
||||
@ -121,7 +151,9 @@
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@if(!$quoteSignature)
|
||||
@if(!$quoteSignature && $change->version->signature_status === '历史签署资料已归档')
|
||||
<p>历史签署资料已核验归档,可从上方附件下载。尚无本系统电子签署记录。</p>
|
||||
@elseif(!$quoteSignature)
|
||||
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
|
||||
@if($canManageSignature)<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}">@csrf<div class="quote-review__form-grid"><div class="quote-review__field"><label for="prepare_signature_date">签署日期</label><input class="quote-review__control" id="prepare_signature_date" name="signature_date" type="date" value="{{ old('signature_date',now()->toDateString()) }}" required></div></div><div class="quote-review__form-actions"><span class="quote-review__hint">日期将直接写入待签原文。</span><button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></div></form>@endif
|
||||
@else
|
||||
|
||||
@ -762,6 +762,9 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="quote-toolbar__actions">
|
||||
<a class="quote-button" href="{{ route('business.courier-stock-quotes.create') }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>录入存量报价</span>
|
||||
</a>
|
||||
<button class="quote-icon-button" type="button" title="重置筛选" aria-label="重置筛选" data-quote-reset>
|
||||
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
|
||||
</button>
|
||||
@ -986,7 +989,7 @@
|
||||
var row = create('tr');
|
||||
var nameCell = appendCell(row, '');
|
||||
nameCell.appendChild(create('span', 'quote-table__name', quote.id));
|
||||
appendCell(row, quote.company_name);
|
||||
appendCell(row, quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : ''));
|
||||
appendCell(row, quote.quote_type);
|
||||
appendCell(row, text(quote.source_range, '-'));
|
||||
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
|
||||
@ -994,9 +997,9 @@
|
||||
var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary');
|
||||
if (quote.signed_files && quote.signed_files.signed) {
|
||||
signCell.appendChild(document.createElement('br'));
|
||||
var signedLink = create('a', 'quote-action', '回签单');
|
||||
var signedLink = create('a', 'quote-action', quote.signature_status === '历史签署资料已归档' ? '历史签署资料' : '回签单');
|
||||
signedLink.href = quote.signed_files.signed;
|
||||
signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
|
||||
signedLink.title = '下载 ' + quote.id + ' 签署资料' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
|
||||
signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id);
|
||||
signCell.appendChild(signedLink);
|
||||
}
|
||||
@ -1046,7 +1049,7 @@
|
||||
root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本';
|
||||
var exportLink = root.querySelector('[data-quote-export]');
|
||||
if (exportLink) {
|
||||
var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; })
|
||||
var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.export; })
|
||||
.map(function (quote) { return quote.id; });
|
||||
exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : '');
|
||||
exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true');
|
||||
@ -1068,7 +1071,7 @@
|
||||
var keyword = fields.keyword.value.trim().toLocaleLowerCase('zh-CN');
|
||||
|
||||
filteredQuotes = quotes.filter(function (quote) {
|
||||
var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier)
|
||||
var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier + ' ' + (quote.courier_expcode || ''))
|
||||
.toLocaleLowerCase('zh-CN')
|
||||
.includes(keyword);
|
||||
|
||||
@ -1106,7 +1109,7 @@
|
||||
lastFocusedElement = trigger;
|
||||
setDetail('title', quote.sheet_name);
|
||||
setDetail('id', quote.id);
|
||||
setDetail('carrier', quote.company_name);
|
||||
setDetail('carrier', quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : ''));
|
||||
setDetail('type', quote.quote_type);
|
||||
setDetail('range', text(quote.source_range, '-'));
|
||||
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">批量录入存量报价</x-slot>
|
||||
<x-slot name="content">
|
||||
<style>
|
||||
.sq-batch { --sb-gold:var(--vip-content-accent-dark,#856632); --sb-border:var(--vip-border,#D8D2C7); --sb-muted:var(--vip-content-muted,#595959); margin:24px 40px; min-width:0; color:var(--vip-content-ink,#171717); font:13px/1.5 "JetBrains Mono","Microsoft YaHei",monospace; }
|
||||
.sq-batch * { box-sizing:border-box; }
|
||||
.sq-batch [hidden] { display:none!important; }
|
||||
.sq-batch a { color:var(--sb-gold); }
|
||||
.sb-toolbar,.sb-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
|
||||
.sb-actions { justify-content:flex-end; margin-top:16px; }
|
||||
.sb-panel { margin-top:16px; background:var(--vip-surface,#fff); border:1px solid var(--sb-border); border-radius:12px; }
|
||||
.sb-header { display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--sb-border); margin:0; padding:12px 16px; font-size:14px; font-weight:600; }
|
||||
.sb-header .material-icons-outlined { color:var(--sb-gold); font-size:19px; }
|
||||
.sb-body { padding:16px; display:grid; gap:16px; }
|
||||
.sb-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
|
||||
.sb-field { display:grid; align-content:start; gap:6px; min-width:0; }
|
||||
.sb-field label { font-size:12px; font-weight:500; }
|
||||
.sb-control { width:100%; min-height:34px; padding:6px 10px; border:1px solid var(--sb-border); border-radius:8px; background:#fff; color:inherit; font:inherit; }
|
||||
.sb-control:focus { outline:none; border-color:var(--sb-gold); box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.sb-hint { margin:0; color:var(--sb-muted); font-size:12px; }
|
||||
.sb-notice { padding:12px 16px; margin-top:16px; border:1px solid var(--sb-border); border-radius:8px; background:var(--vip-content-accent-soft,#F7F0DE); }
|
||||
.sb-error { color:var(--vip-danger,#B80000); }
|
||||
.sb-errors { border:1px solid var(--vip-danger,#B80000); padding:12px 16px; margin-top:16px; border-radius:8px; }
|
||||
.sb-button { display:inline-flex; justify-content:center; align-items:center; gap:6px; min-height:34px; padding:0 14px; border:1px solid var(--sb-gold); border-radius:8px; background:var(--sb-gold); color:#fff!important; cursor:pointer; font:inherit; }
|
||||
.sb-button--secondary { background:#fff; border-color:var(--sb-border); color:inherit!important; }
|
||||
.sb-button:disabled { opacity:.65; cursor:wait; }
|
||||
.sb-steps { display:flex; gap:24px; flex-wrap:wrap; margin:16px 0 0; padding:0; list-style:none; color:var(--sb-muted); font-size:12px; }
|
||||
.sb-steps [aria-current="step"] { color:var(--sb-gold); font-weight:600; }
|
||||
.sb-upload { display:grid; gap:12px; border:1px dashed var(--sb-border); padding:24px; border-radius:8px; background:var(--vip-content-bg,#F8F6F1); }
|
||||
.sb-upload strong { font-size:14px; }
|
||||
.sb-table-wrap { max-height:62vh; overflow:auto; overscroll-behavior:contain; border-top:1px solid var(--sb-border); }
|
||||
.sb-table { width:100%; border-collapse:separate; border-spacing:0; font-size:12.5px; }
|
||||
.sb-table th,.sb-table td { padding:10px 12px; border-bottom:1px solid var(--sb-border); text-align:left; vertical-align:top; }
|
||||
.sb-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); white-space:nowrap; font-size:12px; }
|
||||
.sb-table tbody tr:hover { background:#fbf9f4; }
|
||||
.sb-table .sb-control { min-width:145px; }
|
||||
.sb-table input[type=checkbox] { accent-color:var(--sb-gold); }
|
||||
.sb-match { min-width:210px; }
|
||||
.sb-sheet { min-width:150px; overflow-wrap:anywhere; }
|
||||
.sb-pill { color:var(--sb-gold); font-weight:600; }
|
||||
.sb-original { width:100%; }
|
||||
.sb-original summary { color:var(--sb-gold); cursor:pointer; white-space:nowrap; }
|
||||
.sb-original table { margin-top:8px; border-collapse:collapse; max-width:400px; }
|
||||
.sb-original td { padding:4px 8px; max-width:160px; overflow-wrap:anywhere; font-size:12px; }
|
||||
.sb-rule { border-bottom:1px solid var(--sb-border); padding-bottom:16px; }
|
||||
.sb-rule:last-child { border-bottom:0; padding-bottom:0; }
|
||||
.sb-rule-title { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||
.sb-rule-title input { max-width:240px; }
|
||||
.sb-search { max-width:260px; }
|
||||
@media(max-width:800px) { .sq-batch { margin:16px; } .sb-grid { grid-template-columns:1fr; } .sb-steps { gap:12px; } .sb-upload { padding:16px; } .sb-table-wrap { max-height:65vh; } }
|
||||
</style>
|
||||
@php
|
||||
$result = $manifest['result'] ?? null;
|
||||
$previous = json_decode(old('selection_json', '{}'), true) ?: [];
|
||||
$defaults = $manifest['defaults'] ?? [];
|
||||
$groups = $previous['rule_groups'] ?? [['name' => '共用计费规则', 'rules' => $defaults['billing_rules'] ?? '']];
|
||||
@endphp
|
||||
<main class="sq-batch" data-stock-quote-batch>
|
||||
<div class="sb-toolbar">
|
||||
<a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">返回报价管理</a>
|
||||
<a href="{{ route('business.courier-stock-quotes.single') }}">只补录一家</a>
|
||||
</div>
|
||||
<ol class="sb-steps" aria-label="批量录入步骤">
|
||||
<li @if(!$batch) aria-current="step" @endif>1 上传整本报价</li>
|
||||
<li @if($batch && !$result) aria-current="step" @endif>2 匹配快递 · 套用规则</li>
|
||||
<li @if($result) aria-current="step" @endif>3 确认批量录入</li>
|
||||
</ol>
|
||||
@if($errors->any())
|
||||
<div class="sb-errors sb-error" role="alert">
|
||||
<strong>尚未录入,请修正以下问题</strong>
|
||||
@foreach($errors->all() as $error)<div>{{ $error }}</div>@endforeach
|
||||
@if($batch)<p class="sb-hint">已保留本次上传的 Excel 和选择,无需重新上传。</p>@endif
|
||||
</div>
|
||||
@endif
|
||||
@if(!$batch)
|
||||
<p class="sb-notice">一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。</p>
|
||||
<form action="{{ route('business.courier-stock-quotes.batch-preview') }}" method="POST" enctype="multipart/form-data" data-batch-upload>
|
||||
@csrf
|
||||
<section class="sb-panel">
|
||||
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">upload_file</span>上传整本存量报价</h2>
|
||||
<div class="sb-body">
|
||||
<div class="sb-upload">
|
||||
<strong>选择包含全部快递报价的 Excel</strong>
|
||||
<input id="source_quote" name="source_quote" type="file" accept=".xlsx,.xls" required aria-label="整本报价 Excel">
|
||||
<p class="sb-hint">支持 xlsx / xls,最大 20 MB、200 张工作表;保留原文件、原公式和报价金额。</p>
|
||||
<p class="sb-hint">xlsx 工作表中的内嵌图片会按原始文件独立留存,无需逐家另传。多张图片全部保留,存档不代表已核对一致;外链图片不会自动下载。</p>
|
||||
<p class="sb-hint">工作表名称与快递编号或唯一名称一致时自动匹配;说明页可在下一步取消选择。</p>
|
||||
</div>
|
||||
<div class="sb-grid">
|
||||
<div class="sb-field"><label for="quote_type">本批报价类型</label><input id="quote_type" name="quote_type" class="sb-control" value="{{ old('quote_type', '标准快递报价') }}" maxlength="64" required></div>
|
||||
<div class="sb-field"><label for="effective_date">共用实际执行日期(可稍后填写)</label><input id="effective_date" name="effective_date" type="date" class="sb-control" value="{{ old('effective_date') }}"><p class="sb-hint">日期不一致的快递可逐家调整,不以录入日期代替。</p></div>
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label for="billing_rules">共用计费规则(可稍后填写)</label>
|
||||
@if($templates)
|
||||
<select class="sb-control" data-existing-rule aria-label="套用已有计费规则"><option value="">从已有报价套用规则</option>@foreach($templates as $template)<option value="{{ $template['rules'] }}">{{ $template['name'] }}</option>@endforeach</select>
|
||||
@endif
|
||||
<textarea id="billing_rules" name="billing_rules" class="sb-control" rows="3" maxlength="4000" placeholder="填写多数快递共用的计费口径,例如取重、进位、计泡及面单费规则;各家单价沿用原工作表">{{ old('billing_rules') }}</textarea>
|
||||
<p class="sb-hint">无需重复填写 100 多遍。下一步可新增规则分组,只调整有差异的快递。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="sb-actions"><span class="sb-hint">先预览匹配结果,不会直接生成正式报价。</span><button class="sb-button" type="submit">识别全部工作表并预览</button></div>
|
||||
</form>
|
||||
@elseif($result)
|
||||
<div class="sb-notice" role="status">已录入 <strong>{{ count($result['created']) }}</strong> 家报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各家报价独立管理。</div>
|
||||
<p class="sb-hint" style="margin-top:12px">{{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;缺图时再补齐,核对一致后才能按历史签署资料归档生效。</p>
|
||||
<section class="sb-panel">
|
||||
<h2 class="sb-header">本批录入结果</h2>
|
||||
<div class="sb-table-wrap"><table class="sb-table"><thead><tr><th>原工作表</th><th>快递及编号</th><th>查看</th></tr></thead><tbody>
|
||||
@foreach($result['created'] as $created)<tr><td>{{ $created['sheet_name'] }}</td><td>{{ $created['courier_name'] }} · {{ $created['courier_expcode'] }}</td><td><a href="{{ route('business.courier-quote-changes.show', $created['change_id']) }}">查看报价</a></td></tr>@endforeach
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<div class="sb-actions"><a class="sb-button sb-button--secondary" href="{{ route('business.courier-quote-changes.index') }}">打开报价历史</a><a class="sb-button" href="{{ route('business.courier-stock-quotes.create') }}">继续导入下一批</a></div>
|
||||
@else
|
||||
<p class="sb-notice">{{ $manifest['source_file']['name'] }} · 识别到 <strong>{{ count($rows) }}</strong> 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。</p>
|
||||
<form action="{{ route('business.courier-stock-quotes.batch-save', $batch) }}" method="POST" data-batch-review>
|
||||
@csrf
|
||||
<input type="hidden" name="selection_json" data-selection-json>
|
||||
<section class="sb-panel">
|
||||
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">rule</span>共用设置与计费规则</h2>
|
||||
<div class="sb-body">
|
||||
<div class="sb-grid">
|
||||
<div class="sb-field"><label for="quote_type">本批报价类型</label><input id="quote_type" class="sb-control" data-common-type value="{{ $previous['quote_type'] ?? $defaults['quote_type'] }}" maxlength="64" required></div>
|
||||
<div class="sb-field"><label for="effective_date">共用实际执行日期</label><input id="effective_date" class="sb-control" type="date" data-common-date value="{{ $previous['effective_date'] ?? $defaults['effective_date'] ?? '' }}"><p class="sb-hint">只填一次;下表单独填写的日期优先。</p></div>
|
||||
</div>
|
||||
<div data-rule-groups>
|
||||
@foreach($groups as $index => $group)
|
||||
<div class="sb-rule" data-rule-group>
|
||||
<div class="sb-rule-title"><label for="rule-name-{{ $index }}">规则分组 {{ $index + 1 }}</label><input id="rule-name-{{ $index }}" class="sb-control" data-rule-name value="{{ $group['name'] }}" maxlength="100" required></div>
|
||||
<textarea class="sb-control" rows="3" data-rule-text maxlength="4000" aria-label="规则分组 {{ $index + 1 }} 的计费规则" placeholder="填写这组共用规则,各家价格仍保留在各自工作表中">{{ $group['rules'] ?? '' }}</textarea>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div><button class="sb-button sb-button--secondary" type="button" data-add-rule>+ 新增差异规则分组</button><span class="sb-hint"> 大多数沿用第一组,有差异的只切换分组。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sb-panel">
|
||||
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>逐表匹配与集中核对</h2>
|
||||
<div class="sb-body">
|
||||
<div class="sb-toolbar">
|
||||
<div class="sb-toolbar"><input class="sb-control sb-search" type="search" data-batch-search aria-label="搜索工作表或快递" placeholder="搜索工作表、快递名称或编号"><label><input type="checkbox" data-only-unmatched> 只看待处理</label></div>
|
||||
<div class="sb-toolbar"><button class="sb-button sb-button--secondary" type="button" data-select-matched>只选已匹配</button><button class="sb-button sb-button--secondary" type="button" data-select-visible>全选当前可见</button></div>
|
||||
</div>
|
||||
<p class="sb-hint" data-batch-count aria-live="polite"></p>
|
||||
<p class="sb-hint">沿用已有快递资料,不重复核验网点或签署主体。说明页、重复报价可取消勾选。各表图片已分别留存,需核对是否为完整签署原件以及金额、计费条件和执行日期是否一致;缺图的可先存草稿,补齐后再核验。</p>
|
||||
</div>
|
||||
<datalist id="batch-couriers">@foreach($couriers as $courier)<option value="{{ $courier['code'] }}">{{ $courier['name'] }} · {{ $courier['code'] }}</option>@endforeach</datalist>
|
||||
<div class="sb-table-wrap" tabindex="0" aria-label="工作表匹配结果">
|
||||
<table class="sb-table" data-table-key="stock-quote-batch-matching">
|
||||
<thead><tr><th>录入</th><th>原工作表</th><th>关联快递编号</th><th>计费规则</th><th>执行日期</th><th>签署原件图片</th><th>报价原文</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach($rows as $index => $row)
|
||||
@php($choice = $previous['entries'][$index] ?? [])
|
||||
<tr data-batch-row="{{ $index }}" data-sheet-name="{{ $row['sheet_name'] }}" data-invalid="{{ $row['error'] ? '1' : '0' }}" data-duplicate="{{ $row['duplicate'] ? '1' : '0' }}" data-initial-code="{{ $row['courier_expcode'] }}" data-initial-status="{{ $row['match_status'] }}">
|
||||
<td><input type="checkbox" data-row-selected aria-label="录入{{ $row['sheet_name'] }}" @checked($choice['selected'] ?? (!$row['error'] && !$row['duplicate'])) @disabled($row['error'])></td>
|
||||
<td class="sb-sheet"><strong>{{ $row['sheet_name'] }}</strong><div class="sb-hint">{{ $row['source_range'] ?? '空表' }}</div>@error('entries.' . $index)<div class="sb-error">{{ $message }}</div>@enderror</td>
|
||||
<td class="sb-match"><input class="sb-control" list="batch-couriers" data-row-courier value="{{ $choice['courier_expcode'] ?? $row['courier_expcode'] }}" maxlength="64" placeholder="选择或输入快递编号" aria-label="{{ $row['sheet_name'] }}的快递编号" @disabled($row['error'])><div class="sb-hint" data-courier-name></div><div class="sb-hint" data-row-status>{{ $row['match_status'] }}</div></td>
|
||||
<td><select class="sb-control" data-row-rule aria-label="{{ $row['sheet_name'] }}的计费规则">@foreach($groups as $groupIndex => $group)<option value="{{ $groupIndex }}" @selected(($choice['rule_group'] ?? 0) == $groupIndex)>{{ $group['name'] }}</option>@endforeach</select></td>
|
||||
<td><input class="sb-control" type="date" data-row-date value="{{ $choice['effective_date'] ?? '' }}" aria-label="{{ $row['sheet_name'] }}的实际执行日期"><span class="sb-hint" data-row-date-hint>留空沿用共用日期</span></td>
|
||||
<td>
|
||||
@if(count($row['signed_images'] ?? []))
|
||||
<details class="sb-original"><summary>已留存 {{ count($row['signed_images']) }} 张 · 待核对</summary>
|
||||
@foreach($row['signed_images'] as $imageIndex => $image)
|
||||
<p class="sb-hint">图片 {{ $imageIndex + 1 }} · 原位置 {{ $image['anchor'] }}
|
||||
@if(in_array($image['mime_type'] ?? '', ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true))
|
||||
<a href="{{ route('business.courier-stock-quotes.batch-image', [$batch, $index, $imageIndex, 'preview' => 1]) }}" target="_blank" rel="noopener">查看</a>
|
||||
@endif
|
||||
<a href="{{ route('business.courier-stock-quotes.batch-image', [$batch, $index, $imageIndex]) }}">下载原图</a>
|
||||
</p>
|
||||
@endforeach
|
||||
</details>
|
||||
@else
|
||||
<span class="sb-hint">未提取到图片,待补原件</span>
|
||||
@endif
|
||||
@foreach($row['image_warnings'] ?? [] as $warning)<p class="sb-error">{{ $warning }}</p>@endforeach
|
||||
</td>
|
||||
<td>@if(!$row['error'])<details class="sb-original"><summary>原文</summary><table><tbody>@foreach(array_slice($row['values'], 0, 8) as $valueRow)<tr>@foreach(array_slice($valueRow, 0, 10) as $value)<td>{{ $value }}</td>@endforeach</tr>@endforeach</tbody></table></details>@else<span class="sb-error">{{ $row['error'] }}</span>@endif</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sb-panel"><div class="sb-body">
|
||||
<div class="sb-field"><label for="batch-summary">本批录入说明</label><input id="batch-summary" class="sb-control" data-batch-summary value="{{ $previous['summary'] ?? '补录历史合作报价,以原报价为准' }}" maxlength="2000" minlength="2" required></div>
|
||||
<label><input type="checkbox" data-review-confirmed @checked($previous['confirmed'] ?? false)> 我已核对本次所选快递、原报价、计费规则和真实执行日期</label>
|
||||
<p class="sb-hint">可先保存草稿,也可集中提交审核。审核和历史签署资料核验仍按各家独立处理。</p>
|
||||
</div></section>
|
||||
<div class="sb-actions"><a href="{{ route('business.courier-stock-quotes.create') }}">重新选择 Excel</a><button class="sb-button sb-button--secondary" type="submit" data-save-mode="draft">批量保存草稿</button><button class="sb-button" type="submit" data-save-mode="review">保存并提交审核</button></div>
|
||||
</form>
|
||||
@endif
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector('[data-stock-quote-batch]');
|
||||
if (!root) return;
|
||||
root.querySelector('[data-existing-rule]')?.addEventListener('change', event => {
|
||||
if (event.target.value) root.querySelector('#billing_rules').value = event.target.value;
|
||||
});
|
||||
root.querySelector('[data-batch-upload]')?.addEventListener('submit', event => {
|
||||
const button = event.target.querySelector('button[type=submit]'); button.disabled = true; button.textContent = '正在识别全部工作表…';
|
||||
});
|
||||
const form = root.querySelector('[data-batch-review]');
|
||||
if (!form) return;
|
||||
const couriers = new Map(@json($couriers).map(courier => [courier.code, courier.name]));
|
||||
const rows = [...form.querySelectorAll('[data-batch-row]')];
|
||||
const groups = form.querySelector('[data-rule-groups]');
|
||||
const commonDate = form.querySelector('[data-common-date]');
|
||||
const isExisting = row => row.dataset.duplicate === '1' && row.querySelector('[data-row-courier]').value.trim() === row.dataset.initialCode && form.querySelector('[data-common-type]').value.trim() === @json($defaults['quote_type'] ?? '');
|
||||
const update = () => {
|
||||
const search = form.querySelector('[data-batch-search]').value.trim().toLowerCase();
|
||||
const onlyUnmatched = form.querySelector('[data-only-unmatched]').checked;
|
||||
let selected = 0, pending = 0;
|
||||
const identities = new Map();
|
||||
rows.forEach(row => {
|
||||
const code = row.querySelector('[data-row-courier]').value.trim();
|
||||
if (row.querySelector('[data-row-selected]').checked && code) identities.set(code, (identities.get(code) || 0) + 1);
|
||||
});
|
||||
rows.forEach(row => {
|
||||
const code = row.querySelector('[data-row-courier]').value.trim();
|
||||
const checked = row.querySelector('[data-row-selected]').checked;
|
||||
const known = couriers.has(code);
|
||||
const duplicate = isExisting(row);
|
||||
const invalid = row.dataset.invalid === '1';
|
||||
const repeated = checked && (identities.get(code) || 0) > 1;
|
||||
row.querySelector('[data-courier-name]').textContent = couriers.get(code) || '';
|
||||
const status = row.querySelector('[data-row-status]');
|
||||
status.textContent = invalid ? '空表,已跳过' : duplicate ? '已有报价,请核对或跳过' : repeated ? '与本批其他表编号重复' : known ? '已匹配' : !code ? row.dataset.initialStatus : '编号不存在,请从列表选择';
|
||||
status.classList.toggle('sb-error', !invalid && (!known || repeated));
|
||||
row.querySelector('[data-row-date-hint]').textContent = row.querySelector('[data-row-date]').value ? '单独执行日期' : commonDate.value ? '沿用 ' + commonDate.value : '请填写共用或单独日期';
|
||||
const needsAttention = !known || repeated || duplicate || invalid;
|
||||
row.hidden = !(row.dataset.sheetName + ' ' + code + ' ' + (couriers.get(code) || '')).toLowerCase().includes(search) || (onlyUnmatched && !needsAttention);
|
||||
if (checked) { selected++; if (needsAttention) pending++; }
|
||||
});
|
||||
form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 家 · 其中 ${pending} 家需处理匹配 · 跳过 ${rows.length - selected} 张工作表`;
|
||||
};
|
||||
const updateGroups = () => {
|
||||
const names = [...groups.querySelectorAll('[data-rule-name]')].map((input, index) => input.value || `规则分组 ${index + 1}`);
|
||||
rows.forEach(row => {
|
||||
const select = row.querySelector('[data-row-rule]'), selected = select.value;
|
||||
select.replaceChildren(...names.map((name, index) => new Option(name, index)));
|
||||
select.value = selected || '0';
|
||||
});
|
||||
};
|
||||
form.querySelector('[data-add-rule]').addEventListener('click', event => {
|
||||
const count = groups.querySelectorAll('[data-rule-group]').length;
|
||||
if (count >= 10) return;
|
||||
const next = groups.firstElementChild.cloneNode(true);
|
||||
const name = next.querySelector('[data-rule-name]'); name.value = `差异规则 ${count}`; name.id = `rule-name-${count}`;
|
||||
const label = next.querySelector('label'); label.htmlFor = name.id; label.textContent = `规则分组 ${count + 1}`;
|
||||
next.querySelector('[data-rule-text]').setAttribute('aria-label', `规则分组 ${count + 1} 的计费规则`);
|
||||
groups.appendChild(next); updateGroups(); name.focus();
|
||||
if (count + 1 >= 10) event.target.disabled = true;
|
||||
});
|
||||
groups.addEventListener('input', updateGroups);
|
||||
form.addEventListener('input', update); form.addEventListener('change', update);
|
||||
form.querySelector('[data-select-matched]').addEventListener('click', () => {
|
||||
rows.forEach(row => row.querySelector('[data-row-selected]').checked = couriers.has(row.querySelector('[data-row-courier]').value.trim()) && !isExisting(row) && row.dataset.invalid !== '1'); update();
|
||||
});
|
||||
form.querySelector('[data-select-visible]').addEventListener('click', () => {
|
||||
const visible = rows.filter(row => !row.hidden && row.dataset.invalid !== '1');
|
||||
const select = !visible.every(row => row.querySelector('[data-row-selected]').checked);
|
||||
visible.forEach(row => row.querySelector('[data-row-selected]').checked = select); update();
|
||||
});
|
||||
form.addEventListener('submit', event => {
|
||||
const payload = {
|
||||
quote_type: form.querySelector('[data-common-type]').value,
|
||||
effective_date: commonDate.value,
|
||||
summary: form.querySelector('[data-batch-summary]').value,
|
||||
confirmed: form.querySelector('[data-review-confirmed]').checked,
|
||||
submit_for_review: event.submitter?.dataset.saveMode === 'review',
|
||||
rule_groups: [...groups.querySelectorAll('[data-rule-group]')].map(group => ({ name: group.querySelector('[data-rule-name]').value, rules: group.querySelector('[data-rule-text]').value })),
|
||||
entries: rows.map(row => ({ selected: row.querySelector('[data-row-selected]').checked, courier_expcode: row.querySelector('[data-row-courier]').value.trim(), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').value }))
|
||||
};
|
||||
form.querySelector('[data-selection-json]').value = JSON.stringify(payload);
|
||||
form.querySelectorAll('button[type=submit]').forEach(button => button.disabled = true);
|
||||
if (event.submitter) event.submitter.textContent = '正在批量保存…';
|
||||
});
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -0,0 +1,101 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">{{ $entry ? '编辑存量报价' : '录入存量报价' }}</x-slot>
|
||||
<x-slot name="content">
|
||||
<style>
|
||||
.stock-quote { --sq-gold:var(--vip-content-accent-dark,#856632); --sq-border:var(--vip-border,#D8D2C7); --sq-muted:var(--vip-content-muted,#595959); margin:24px 40px; color:var(--vip-content-ink,#171717); font:13px/1.5 "JetBrains Mono","Microsoft YaHei",monospace; }
|
||||
.stock-quote * { box-sizing:border-box; }
|
||||
.stock-quote a { color:var(--sq-gold); }
|
||||
.stock-quote__panel { margin-top:16px; border:1px solid var(--sq-border); border-radius:12px; background:#fff; }
|
||||
.stock-quote__header { display:flex; align-items:center; gap:8px; margin:0; border-bottom:1px solid var(--sq-border); padding:14px 16px; font-size:14px; font-weight:600; }
|
||||
.stock-quote__header .material-icons-outlined { color:var(--sq-gold); font-size:19px; }
|
||||
.stock-quote__body { display:grid; gap:16px; padding:16px; }
|
||||
.stock-quote__grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
|
||||
.stock-quote__field { display:grid; align-content:start; gap:6px; min-width:0; }
|
||||
.stock-quote__field label { font-size:12px; font-weight:500; }
|
||||
.stock-quote__control { width:100%; min-height:34px; border:1px solid var(--sq-border); border-radius:8px; background:#fff; padding:6px 10px; color:inherit; font:inherit; }
|
||||
.stock-quote__control:focus { border-color:var(--sq-gold); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.stock-quote__hint { margin:0; color:var(--sq-muted); font-size:12px; }
|
||||
.stock-quote__notice { margin:16px 0 0; border:1px solid var(--sq-border); border-radius:8px; background:var(--vip-content-accent-soft,#F7F0DE); padding:12px 16px; }
|
||||
.stock-quote__errors { margin-top:16px; border:1px solid var(--vip-danger,#B80000); border-radius:8px; padding:12px 16px; color:var(--vip-danger,#B80000); }
|
||||
.stock-quote__actions { display:flex; justify-content:flex-end; align-items:center; gap:12px; margin-top:16px; }
|
||||
.stock-quote__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:34px; border:1px solid var(--sq-gold); border-radius:8px; background:var(--sq-gold); padding:0 14px; color:#fff; font:inherit; cursor:pointer; }
|
||||
.stock-quote__button:disabled { cursor:wait; opacity:.7; }
|
||||
@media(max-width:800px) { .stock-quote { margin:16px; } .stock-quote__grid { grid-template-columns:1fr; } .stock-quote__actions { flex-wrap:wrap; } }
|
||||
</style>
|
||||
<main class="stock-quote">
|
||||
<a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">返回报价管理</a>
|
||||
<p class="stock-quote__notice">选择已有快递资料,保存原报价并核对后提交审核。后续调价沿用报价版本、审核和签署流程。</p>
|
||||
@if($errors->any())
|
||||
<div class="stock-quote__errors" role="alert">
|
||||
@foreach($errors->all() as $error)<div>{{ $error }}</div>@endforeach
|
||||
<p class="stock-quote__hint">提交失败时请重新选择附件,其他已填内容会保留。</p>
|
||||
</div>
|
||||
@endif
|
||||
<form method="POST" enctype="multipart/form-data" action="{{ $entry ? route('business.courier-stock-quotes.update', $entry) : route('business.courier-stock-quotes.store') }}" data-stock-quote-form>
|
||||
@csrf
|
||||
@if($entry) @method('PATCH') @endif
|
||||
<section class="stock-quote__panel">
|
||||
<h2 class="stock-quote__header"><span class="material-icons-outlined" aria-hidden="true">local_shipping</span>第一步:关联已有快递</h2>
|
||||
<div class="stock-quote__body">
|
||||
@if($entry)
|
||||
<p>{{ $entry->courier_name }} · 快递编号 {{ $entry->courier_expcode }} · {{ $entry->quote_type }}</p>
|
||||
<p class="stock-quote__hint">此草稿已关联该快递编号及报价类型。</p>
|
||||
@else
|
||||
<div class="stock-quote__grid">
|
||||
<div class="stock-quote__field">
|
||||
<label for="courier_expcode">已有快递资料(名称 / 唯一编号)</label>
|
||||
<select class="stock-quote__control" id="courier_expcode" name="courier_expcode" required>
|
||||
<option value="">请选择快递及网点</option>
|
||||
@foreach($couriers as $courier)<option value="{{ $courier['code'] }}" @selected(old('courier_expcode') === $courier['code'])>{{ $courier['name'] }} · {{ $courier['code'] }}</option>@endforeach
|
||||
</select>
|
||||
<p class="stock-quote__hint">同品牌的不同网点、结算主体请按各自编号分别录入。</p>
|
||||
@if(!$couriers)<p role="status">尚无可选择的快递编号,请先在快递资料中完善资料,或确认资料同步状态后重试。</p>@endif
|
||||
</div>
|
||||
<div class="stock-quote__field">
|
||||
<label for="quote_type">报价类型</label>
|
||||
<input class="stock-quote__control" id="quote_type" name="quote_type" list="stock-quote-types" value="{{ old('quote_type') }}" maxlength="64" required placeholder="例如:标准快递报价、超长件报价">
|
||||
<datalist id="stock-quote-types"><option value="标准快递报价"><option value="超长件报价"><option value="大件快递报价"></datalist>
|
||||
<p class="stock-quote__hint">同一快递编号、同一类型只录入一次;后续调整请从对应报价发起变价。</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
<section class="stock-quote__panel">
|
||||
<h2 class="stock-quote__header"><span class="material-icons-outlined" aria-hidden="true">upload_file</span>第二步:保存原报价与依据</h2>
|
||||
<div class="stock-quote__body">
|
||||
@if($entry)
|
||||
<p class="stock-quote__hint">已保存原报价及附件。草稿阶段可重新上传报价或补充附件,历次原文件均会保留。</p>
|
||||
<div>@foreach($entry->files as $index => $file)<div><a href="{{ route('business.courier-stock-quotes.download', [$entry, $index]) }}">{{ $file['name'] }}</a></div>@endforeach</div>
|
||||
@endif
|
||||
<div class="stock-quote__grid">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stock-quote__panel">
|
||||
<h2 class="stock-quote__header"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>第三步:核对计费规则与执行日期</h2>
|
||||
<div class="stock-quote__body">
|
||||
<div class="stock-quote__field"><label for="effective_date">实际开始执行日期</label><input class="stock-quote__control" id="effective_date" name="effective_date" type="date" value="{{ old('effective_date', $entry?->change?->effective_date?->toDateString()) }}" required><p class="stock-quote__hint">请按原报价填写真实执行日期,历史报价不以录入日期代替。</p></div>
|
||||
<div class="stock-quote__field"><label for="billing_rules">计费规则</label><textarea class="stock-quote__control" id="billing_rules" name="billing_rules" rows="4" minlength="2" maxlength="4000" required placeholder="请核对并记录重量段、首续重、计泡、面单、区域及附加费等原报价规则">{{ old('billing_rules', data_get($entry?->change?->quote_payload, 'billing_rules')) }}</textarea><p class="stock-quote__hint">本次将报价接入版本管理;运费自动核算仍以已实现且经核验的计算规则为准。</p></div>
|
||||
<div class="stock-quote__field"><label for="summary">录入依据与说明</label><textarea class="stock-quote__control" id="summary" name="summary" rows="3" minlength="2" maxlength="2000" required placeholder="例如:补录历史合作报价,价格以原确认单为准">{{ old('summary', $entry?->change?->summary) }}</textarea></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="stock-quote__actions"><span class="stock-quote__hint">下一步预览报价逐项核对,再提交审核。</span><button class="stock-quote__button" type="submit"><span class="material-icons-outlined" aria-hidden="true">preview</span>{{ $entry ? '保存修改并预览' : '保存草稿并预览' }}</button></div>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-stock-quote-form]');
|
||||
form?.addEventListener('submit', () => {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
button.textContent = '正在保存…';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -117,6 +117,7 @@
|
||||
@endif
|
||||
|
||||
<h2>完整报价规则</h2>
|
||||
@if(! empty($billingRules))<p class="summary">{!! $mixedText($billingRules) !!}</p>@endif
|
||||
<table class="matrix">
|
||||
<tbody>
|
||||
@forelse($values as $rowIndex => $row)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Business\AccountMappingActions;
|
||||
use App\Http\Controllers\Business\CourierProfiles;
|
||||
use App\Http\Controllers\Business\CourierQuoteChanges;
|
||||
use App\Http\Controllers\Business\CourierStockQuoteEntries;
|
||||
use App\Http\Controllers\Business\CourierQuoteSignatures;
|
||||
use App\Http\Controllers\Business\CourierAgreementSignatures;
|
||||
use App\Http\Controllers\Business\CourierOnboardings;
|
||||
@ -123,6 +124,22 @@ Route::prefix('workspace-data/supplier-payables')
|
||||
* @see \modules\OfflinePayments\Routes\admin.php for module example
|
||||
*/
|
||||
|
||||
Route::prefix('workspace/courier-payables/quote-management/stock-entries')
|
||||
->name('business.courier-stock-quotes.')
|
||||
->middleware('business.page:courier-payables,quote-management')
|
||||
->group(function () {
|
||||
Route::get('/', [CourierStockQuoteEntries::class, 'create'])->name('create');
|
||||
Route::get('single', [CourierStockQuoteEntries::class, 'single'])->name('single');
|
||||
Route::post('batch-preview', [CourierStockQuoteEntries::class, 'previewBatch'])->name('batch-preview');
|
||||
Route::get('batches/{batch}', [CourierStockQuoteEntries::class, 'showBatch'])->name('batch');
|
||||
Route::get('batches/{batch}/images/{sheet}/{file}', [CourierStockQuoteEntries::class, 'batchImage'])->whereNumber(['sheet', 'file'])->name('batch-image');
|
||||
Route::post('batches/{batch}', [CourierStockQuoteEntries::class, 'saveBatch'])->name('batch-save');
|
||||
Route::post('/', [CourierStockQuoteEntries::class, 'store'])->name('store');
|
||||
Route::get('{stockQuoteEntry}/edit', [CourierStockQuoteEntries::class, 'edit'])->name('edit');
|
||||
Route::patch('{stockQuoteEntry}', [CourierStockQuoteEntries::class, 'update'])->name('update');
|
||||
Route::get('{stockQuoteEntry}/files/{file}', [CourierStockQuoteEntries::class, 'download'])->whereNumber('file')->name('download');
|
||||
});
|
||||
|
||||
Route::prefix('workspace/courier-payables/quote-management/changes')
|
||||
->name('business.courier-quote-changes.')
|
||||
->middleware('business.page:courier-payables,quote-management')
|
||||
|
||||
369
tests/Feature/Business/CourierStockQuoteBatchTest.php
Normal file
369
tests/Feature/Business/CourierStockQuoteBatchTest.php
Normal file
@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Support\CourierStockQuoteBatchService;
|
||||
use App\Support\CourierQuoteChangeService;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierStockQuoteBatchTest extends FeatureTestCase
|
||||
{
|
||||
private array $temporaryFiles = [];
|
||||
|
||||
private array $sourceImages = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('uploads');
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->mirror([
|
||||
['code' => 'EXP-001', 'name' => '同名网点'], ['code' => 'EXP-002', 'name' => '同名网点'],
|
||||
['code' => 'EXP-003', 'name' => '第三网点'],
|
||||
]);
|
||||
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-batch-quotes.json'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->temporaryFiles as $path) {
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_preview_keeps_workbook_without_creating_quotes_and_never_guesses_ambiguous_names(): void
|
||||
{
|
||||
$this->loginAs()->get(route('business.courier-stock-quotes.create'))->assertOk()
|
||||
->assertSeeText('共用计费规则')->assertSeeText('只补录一家');
|
||||
$batch = $this->preview(['EXP-001', '同名网点', '第三网点', '说明']);
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()
|
||||
->assertSeeText('名称重复,请选编号')->assertSeeText('保存并提交审核');
|
||||
$service = app(CourierStockQuoteBatchService::class);
|
||||
$manifest = $service->load($batch, (int) $this->user->id);
|
||||
$rows = $service->rows($manifest, app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions());
|
||||
$this->assertSame(['EXP-001', '', 'EXP-003', ''], array_column($rows, 'courier_expcode'));
|
||||
$this->assertSame('=B2/2', $manifest['sheets'][0]['formulas'][1][2]);
|
||||
Storage::disk('uploads')->assertExists($manifest['source_file']['path']);
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$this->assertDatabaseCount('business_courier_quote_changes', 0);
|
||||
}
|
||||
|
||||
public function test_bulk_save_reuses_rule_groups_overrides_dates_and_preserves_separate_prices(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', 'EXP-002', '说明']);
|
||||
$selection = $this->selection(['EXP-001', 'EXP-002', '']);
|
||||
$selection['entries'][2]['selected'] = false;
|
||||
$selection['rule_groups'][] = ['name' => '单独规则', 'rules' => '超长件按实际重量计费'];
|
||||
$selection['entries'][1]['rule_group'] = 1;
|
||||
$selection['entries'][1]['effective_date'] = '2025-11-01';
|
||||
$url = route('business.courier-stock-quotes.batch-save', $batch);
|
||||
$this->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertRedirect();
|
||||
$entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get();
|
||||
$this->assertCount(2, $entries);
|
||||
$this->assertSame('按原表重量段计费,面单另计', $entries[0]->change->quote_payload['billing_rules']);
|
||||
$this->assertSame('超长件按实际重量计费', $entries[1]->change->quote_payload['billing_rules']);
|
||||
$this->assertSame('2025-11-01', $entries[1]->change->effective_date->toDateString());
|
||||
$this->assertEquals(5, $entries[0]->sourceQuote()['values'][1][1]);
|
||||
$this->assertEquals(6, $entries[1]->sourceQuote()['values'][1][1]);
|
||||
$this->assertSame($entries[0]->files[0]['path'], $entries[1]->files[0]['path']);
|
||||
$this->assertSame(CourierQuoteChange::STATUS_DRAFT, $entries[0]->change->status);
|
||||
$this->assertDatabaseCount('business_courier_quote_versions', 0);
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已录入');
|
||||
// 同一预览重发只返回原结果,不生成重复记录。
|
||||
$this->post($url, ['selection_json' => json_encode($selection)])->assertRedirect();
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
|
||||
}
|
||||
|
||||
public function test_bulk_submit_requires_confirmation_and_does_not_approve_or_fake_signatures(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', 'EXP-002']);
|
||||
$selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => false];
|
||||
$url = route('business.courier-stock-quotes.batch-save', $batch);
|
||||
$this->withExceptionHandling()->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasErrors('confirmed');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$selection['confirmed'] = true;
|
||||
$this->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all());
|
||||
$this->assertDatabaseCount('business_courier_quote_versions', 0);
|
||||
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
|
||||
}
|
||||
|
||||
public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', 'EXP-002']);
|
||||
$selection = $this->selection(['EXP-001', 'EXP-001']);
|
||||
$this->withExceptionHandling()->loginAs()->from(route('business.courier-stock-quotes.batch', $batch))
|
||||
->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
|
||||
->assertSessionHasErrors('entries.1')->assertSessionHasInput('selection_json');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$selection['entries'][1]['courier_expcode'] = 'EXP-002';
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
|
||||
$another = $this->preview(['EXP-001']);
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $another), ['selection_json' => json_encode($this->selection(['EXP-001']))])
|
||||
->assertSessionHasErrors('entries.0');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
|
||||
$this->get(route('business.courier-stock-quotes.batch', $another))->assertOk()->assertSeeText('已录入,默认跳过');
|
||||
}
|
||||
|
||||
public function test_real_date_and_known_courier_are_required_but_notes_can_be_skipped(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', '说明']);
|
||||
$selection = $this->selection(['EXP-001', 'UNKNOWN']);
|
||||
$selection['effective_date'] = '';
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
|
||||
->assertSessionHasErrors(['entries.0', 'entries.1']);
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$selection['entries'][0]['effective_date'] = '2024-06-01';
|
||||
$selection['entries'][1]['selected'] = false;
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 1);
|
||||
}
|
||||
|
||||
public function test_preview_is_scoped_to_company_and_uploader_and_rejects_fake_sheet_indices(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001']);
|
||||
$manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
|
||||
$manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json';
|
||||
$selection = $this->selection(['EXP-001']);
|
||||
$selection['entries'] = [50 => $selection['entries'][0]];
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
|
||||
->assertSessionHasErrors('entries.50');
|
||||
$manifest['user_id'] = (int) $this->user->id + 1;
|
||||
Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
$manifest['user_id'] = (int) $this->user->id;
|
||||
$manifest['company_id'] = (int) company_id() + 1;
|
||||
Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_one_hundred_twenty_sheets_preview_and_save_as_one_batch(): void
|
||||
{
|
||||
$couriers = $names = [];
|
||||
for ($index = 1; $index <= 120; $index++) {
|
||||
$names[] = $code = sprintf('BULK-%03d', $index);
|
||||
$couriers[] = ['code' => $code, 'name' => '批量网点' . $index];
|
||||
}
|
||||
$this->mirror($couriers);
|
||||
$batch = $this->preview($names, array_fill(0, 120, 1));
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120');
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect();
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 120);
|
||||
$this->assertDatabaseCount('business_courier_quote_changes', 120);
|
||||
$this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
|
||||
$this->assertCount(120, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[1]['path'])->unique());
|
||||
}
|
||||
|
||||
public function test_empty_sheet_can_be_skipped_and_failed_result_storage_rolls_back_the_entire_batch(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', '空表']);
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('空工作表,请取消选择');
|
||||
$selection = $this->selection(['EXP-001', 'EXP-002']);
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
|
||||
->assertSessionHasErrors('entries.1');
|
||||
$selection['entries'][1]['selected'] = false;
|
||||
$disk = Storage::disk('uploads');
|
||||
$failingDisk = \Mockery::mock($disk);
|
||||
$failingDisk->shouldReceive('put')->once()->andReturn(false);
|
||||
Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk);
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
|
||||
->assertSessionHasErrors('source_quote');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$this->assertDatabaseCount('business_courier_quote_changes', 0);
|
||||
}
|
||||
|
||||
public function test_embedded_images_are_archived_byte_for_byte_per_sheet_and_never_auto_verified(): void
|
||||
{
|
||||
$file = $this->workbook(['EXP-001', 'EXP-002'], [2, 1]);
|
||||
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $file, 'quote_type' => '标准快递报价',
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$batch = basename($response->headers->get('Location'));
|
||||
$manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
|
||||
$this->assertCount(2, $manifest['sheets'][0]['signed_images']);
|
||||
$this->assertCount(1, $manifest['sheets'][1]['signed_images']);
|
||||
foreach ($manifest['sheets'] as $sheetIndex => $sheet) {
|
||||
foreach ($sheet['signed_images'] as $imageIndex => $image) {
|
||||
$this->assertSame($this->sourceImages[$sheetIndex][$imageIndex], Storage::disk('uploads')->get($image['path']));
|
||||
$this->assertSame(hash('sha256', $this->sourceImages[$sheetIndex][$imageIndex]), $image['sha256']);
|
||||
$this->assertSame($sheet['sheet_name'], $image['sheet_name']);
|
||||
$this->assertSame('G' . (2 + $imageIndex * 20), $image['anchor']);
|
||||
$this->assertSame('image/png', $image['mime_type']);
|
||||
$url = route('business.courier-stock-quotes.batch-image', [$batch, $sheetIndex, $imageIndex]);
|
||||
$download = $this->get($url)->assertOk()->assertDownload()->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]);
|
||||
$this->assertStringContainsString(rawurlencode($image['name']), $download->headers->get('Content-Disposition'));
|
||||
$this->get($url . '?preview=1')->assertOk()->assertHeader('Content-Type', 'image/png')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]);
|
||||
}
|
||||
}
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已留存 2 张 · 待核对');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => true];
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
|
||||
$entries = CourierStockQuoteEntry::orderBy('id')->get();
|
||||
$this->assertSame([3, 2], $entries->map(fn ($entry) => count($entry->files))->all());
|
||||
$this->assertSame($manifest['sheets'][0]['signed_images'][0]['path'], $entries[0]->files[1]['path']);
|
||||
$this->assertSame($manifest['sheets'][1]['signed_images'][0]['path'], $entries[1]->files[1]['path']);
|
||||
$this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all());
|
||||
$this->assertDatabaseCount('business_courier_quote_versions', 0);
|
||||
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
|
||||
$this->get(route('business.courier-quote-changes.show', $entries[0]->change))->assertOk()->assertSeeText('查看原图');
|
||||
$this->get(route('business.courier-stock-quotes.download', [$entries[0], 1, 'preview' => 1]))->assertOk()
|
||||
->assertStreamedContent($this->sourceImages[0][0]);
|
||||
}
|
||||
|
||||
public function test_image_storage_failure_does_not_publish_a_preview_or_create_quotes(): void
|
||||
{
|
||||
$file = $this->workbook(['EXP-001'], [1]);
|
||||
$disk = Storage::disk('uploads');
|
||||
$failingDisk = \Mockery::mock($disk);
|
||||
$failingDisk->shouldReceive('put')->withArgs(fn ($path) => str_contains($path, '/images/'))->once()->andReturn(false);
|
||||
Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk);
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $file, 'quote_type' => '标准快递报价',
|
||||
])->assertSessionHasErrors('source_quote');
|
||||
$this->assertSame([], $disk->allFiles());
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
}
|
||||
|
||||
public function test_missing_archived_image_blocks_the_entire_save_and_image_routes_are_private(): void
|
||||
{
|
||||
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $this->workbook(['EXP-001', 'EXP-002'], [1, 1]), 'quote_type' => '标准快递报价',
|
||||
])->assertRedirect();
|
||||
$batch = basename($response->headers->get('Location'));
|
||||
$manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
|
||||
Storage::disk('uploads')->delete($manifest['sheets'][1]['signed_images'][0]['path']);
|
||||
$this->withExceptionHandling()->post(route('business.courier-stock-quotes.batch-save', $batch), [
|
||||
'selection_json' => json_encode($this->selection(['EXP-001', 'EXP-002'])),
|
||||
])->assertSessionHasErrors('entries.1');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$url = route('business.courier-stock-quotes.batch-image', [$batch, 0, 0, 'preview' => 1]);
|
||||
$this->get(route('business.courier-stock-quotes.batch-image', [$batch, 0, 99]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
$manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json';
|
||||
$manifest['user_id']++;
|
||||
Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
|
||||
$this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
$manifest['user_id']--;
|
||||
$manifest['company_id']++;
|
||||
Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
|
||||
$this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_followup_price_change_keeps_all_original_images_and_unverified_images_do_not_activate_quote(): void
|
||||
{
|
||||
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $this->workbook(['EXP-001'], [2]), 'quote_type' => '标准快递报价',
|
||||
])->assertRedirect();
|
||||
$batch = basename($response->headers->get('Location'));
|
||||
$this->post(route('business.courier-stock-quotes.batch-save', $batch), [
|
||||
'selection_json' => json_encode([...$this->selection(['EXP-001']), 'submit_for_review' => true, 'confirmed' => true]),
|
||||
])->assertSessionHasNoErrors();
|
||||
$entry = CourierStockQuoteEntry::firstOrFail();
|
||||
$reviewer = user_model_class()::create([
|
||||
'name' => '原件核验员', 'email' => 'image-reviewer@example.com', 'password' => 'local-test-password',
|
||||
'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test',
|
||||
]);
|
||||
$reviewer->companies()->attach($this->company->id);
|
||||
$changes = app(CourierQuoteChangeService::class);
|
||||
$approved = $changes->approve($entry->change, (int) $reviewer->id);
|
||||
$this->assertSame('待确认/待补签', $approved->version->signature_status);
|
||||
$change = $changes->create([
|
||||
'source_quote_id' => $approved->version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
|
||||
'summary' => '基于存量版本调价,原件继续保留',
|
||||
'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]],
|
||||
], (int) $this->user->id);
|
||||
$changes->submit($change, (int) $this->user->id);
|
||||
$changes->approve($change->fresh(), (int) $reviewer->id);
|
||||
$this->assertCount(3, $entry->fresh()->files);
|
||||
foreach ([1, 2] as $index) {
|
||||
$this->get(route('business.courier-stock-quotes.download', [$entry, $index]))->assertOk()
|
||||
->assertStreamedContent($this->sourceImages[0][$index - 1]);
|
||||
}
|
||||
\DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
|
||||
$this->withExceptionHandling()->get(route('business.courier-stock-quotes.download', [$entry, 1, 'preview' => 1]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_legacy_xls_images_are_not_reencoded_and_falsely_labeled_originals(): void
|
||||
{
|
||||
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $this->workbook(['EXP-001'], [1], 'Xls'), 'quote_type' => '标准快递报价',
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$batch = basename($response->headers->get('Location'));
|
||||
$manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
|
||||
$this->assertSame([], $manifest['sheets'][0]['signed_images']);
|
||||
$this->assertNotEmpty($manifest['sheets'][0]['image_warnings']);
|
||||
Storage::disk('uploads')->assertExists($manifest['source_file']['path']);
|
||||
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('无法无损提取');
|
||||
}
|
||||
|
||||
private function preview(array $names, array $imageCounts = []): string
|
||||
{
|
||||
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
|
||||
'source_quote' => $this->workbook($names, $imageCounts), 'quote_type' => '标准快递报价',
|
||||
'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计',
|
||||
])->assertRedirect();
|
||||
|
||||
return basename($response->headers->get('Location'));
|
||||
}
|
||||
|
||||
private function selection(array $codes): array
|
||||
{
|
||||
return ['quote_type' => '标准快递报价', 'effective_date' => '2025-12-01', 'summary' => '整本报价历史补录',
|
||||
'rule_groups' => [['name' => '共用规则', 'rules' => '按原表重量段计费,面单另计']],
|
||||
'submit_for_review' => false, 'confirmed' => false,
|
||||
'entries' => array_map(fn ($code) => ['selected' => true, 'courier_expcode' => $code, 'rule_group' => 0, 'effective_date' => ''], $codes),
|
||||
];
|
||||
}
|
||||
|
||||
private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx'): UploadedFile
|
||||
{
|
||||
$book = new Spreadsheet();
|
||||
foreach ($names as $index => $name) {
|
||||
$sheet = ($index === 0 ? $book->getActiveSheet() : $book->createSheet())->setTitle($name);
|
||||
if ($name === '空表') {
|
||||
continue;
|
||||
}
|
||||
$sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]);
|
||||
$sheet->setCellValue('C2', '=B2/2');
|
||||
for ($imageIndex = 0; $imageIndex < ($imageCounts[$index] ?? 0); $imageIndex++) {
|
||||
$image = imagecreatetruecolor(24 + $index, 32 + $imageIndex);
|
||||
imagefill($image, 0, 0, imagecolorallocate($image, (80 + $index * 20) % 256, 50, (30 + $imageIndex * 20) % 256));
|
||||
$imagePath = tempnam(sys_get_temp_dir(), 'quote-image-');
|
||||
rename($imagePath, $imagePath . '.png');
|
||||
$imagePath .= '.png';
|
||||
$this->temporaryFiles[] = $imagePath;
|
||||
imagepng($image, $imagePath);
|
||||
imagedestroy($image);
|
||||
$this->sourceImages[$index][$imageIndex] = file_get_contents($imagePath);
|
||||
(new Drawing())->setPath($imagePath)->setCoordinates('G' . (2 + $imageIndex * 20))->setWorksheet($sheet);
|
||||
}
|
||||
}
|
||||
$path = tempnam(sys_get_temp_dir(), 'batch-quote-');
|
||||
$this->temporaryFiles[] = $path;
|
||||
IOFactory::createWriter($book, $format)->save($path);
|
||||
$book->disconnectWorksheets();
|
||||
|
||||
return new UploadedFile($path, '整本存量报价.' . strtolower($format), null, null, true);
|
||||
}
|
||||
|
||||
private function mirror(array $couriers): void
|
||||
{
|
||||
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
|
||||
$mirror->shouldReceive('overviewSource')->andReturn(['express' => array_map(fn ($courier) => (object) ['expcode' => $courier['code'], 'expname' => $courier['name']], $couriers)]);
|
||||
app()->instance(CourierInformationMirrorRepository::class, $mirror);
|
||||
}
|
||||
}
|
||||
239
tests/Feature/Business/CourierStockQuoteEntryTest.php
Normal file
239
tests/Feature/Business/CourierStockQuoteEntryTest.php
Normal file
@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Auth\Role;
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Support\CourierQuoteChangeService;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierStockQuoteEntryTest extends FeatureTestCase
|
||||
{
|
||||
private array $temporaryFiles = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('uploads');
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
|
||||
$mirror->shouldReceive('overviewSource')->andReturn(['express' => [
|
||||
(object) ['expcode' => 'EXP-001', 'expname' => '同名快递网点'],
|
||||
(object) ['expcode' => 'EXP-002', 'expname' => '同名快递网点'],
|
||||
]]);
|
||||
app()->instance(CourierInformationMirrorRepository::class, $mirror);
|
||||
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-stock-quote-fixture.json'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->temporaryFiles as $path) {
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_entry_uses_existing_courier_and_retains_original_file_formula_and_real_date(): void
|
||||
{
|
||||
$this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()
|
||||
->assertSeeText('EXP-001')->assertSeeText('EXP-002')->assertSeeText('实际开始执行日期');
|
||||
$data = $this->payload();
|
||||
$originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
|
||||
$this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
|
||||
$entry = CourierStockQuoteEntry::firstOrFail();
|
||||
$this->assertSame('EXP-001', $entry->courier_expcode);
|
||||
$this->assertSame($originalHash, $entry->files[0]['sha256']);
|
||||
$this->assertSame('原始报价.xlsx', $entry->files[0]['name']);
|
||||
$this->assertSame('=B2/2', $entry->sourceQuote()['formulas'][1][2]);
|
||||
$this->assertSame('2025-12-01', $entry->change->effective_date->toDateString());
|
||||
$this->assertFalse($entry->change->isRetrospective());
|
||||
Storage::disk('uploads')->assertExists($entry->files[0]['path']);
|
||||
$this->assertDatabaseCount('business_courier_quote_versions', 0);
|
||||
$this->assertDatabaseCount('business_courier_onboardings', 0);
|
||||
$this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
|
||||
->assertSeeText('存量报价原文与核对')->assertSeeText('=B2/2')->assertSeeText('首重1kg');
|
||||
}
|
||||
|
||||
public function test_review_requires_confirmation_and_never_claims_unsigned_quote_is_signed(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change))->assertRedirect();
|
||||
$reviewer = $this->reviewer();
|
||||
$this->withExceptionHandling()->loginAs($reviewer, $this->company)
|
||||
->post(route('business.courier-quote-changes.approve', $entry->change))->assertSessionHasErrors('stock_rules_confirmed');
|
||||
$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('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']);
|
||||
}
|
||||
|
||||
public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void
|
||||
{
|
||||
$entry = $this->createEntry(['signed_files' => [UploadedFile::fake()->create('历史合同.pdf', 5, 'application/pdf')]]);
|
||||
$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', 'stock_signature_verified' => '1',
|
||||
])->assertRedirect();
|
||||
$version = CourierQuoteVersion::firstOrFail();
|
||||
$this->assertSame('历史签署资料已归档', $version->signature_status);
|
||||
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
|
||||
$snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
|
||||
$this->assertSame('生效中', $snapshot['status']);
|
||||
$this->assertArrayHasKey('signed', $snapshot['signed_files']);
|
||||
$this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
|
||||
->assertSeeText('尚无本系统电子签署记录')->assertDontSeeText('生成签署 PDF');
|
||||
}
|
||||
|
||||
public function test_claiming_verified_history_without_documents_is_rejected(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
|
||||
$this->withExceptionHandling()->loginAs($this->reviewer(), $this->company)
|
||||
->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1', 'stock_signature_verified' => '1'])
|
||||
->assertSessionHasErrors('stock_signature_verified');
|
||||
$this->assertDatabaseCount('business_courier_quote_versions', 0);
|
||||
}
|
||||
|
||||
public function test_same_code_and_type_cannot_be_imported_twice_but_same_names_with_different_codes_can(): void
|
||||
{
|
||||
$this->createEntry();
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), $this->payload())
|
||||
->assertSessionHasErrors('courier_expcode');
|
||||
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'EXP-002'])->assertRedirect();
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'UNREGISTERED'])
|
||||
->assertSessionHasErrors('courier_expcode');
|
||||
}
|
||||
|
||||
public function test_multi_sheet_upload_requires_exact_sheet_and_invalid_workbooks_leave_no_record(): void
|
||||
{
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true)])
|
||||
->assertSessionHasErrors('sheet_name');
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true), 'sheet_name' => '报价'])
|
||||
->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_draft_replacement_retains_previous_original_and_updates_preview_and_audit(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
$originalPath = $entry->files[0]['path'];
|
||||
$this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [
|
||||
'effective_date' => '2025-11-01', 'billing_rules' => '修正后首重1kg', 'summary' => '更正原报价附件',
|
||||
'source_quote' => $this->workbook(false, 8),
|
||||
])->assertRedirect();
|
||||
$entry->refresh();
|
||||
$this->assertCount(2, $entry->files);
|
||||
$this->assertSame('previous_source_quote', $entry->files[0]['kind']);
|
||||
Storage::disk('uploads')->assertExists($originalPath);
|
||||
$this->assertEquals(8, $entry->sourceQuote()['values'][1][1]);
|
||||
$this->assertEquals(8, json_decode($entry->change->proposed_values_json, true)[1][1]);
|
||||
$details = json_decode($entry->change->events()->where('event_type', 'saved')->firstOrFail()->details_json, true);
|
||||
$this->assertSame('修正后首重1kg', $details['billing_rules']);
|
||||
$this->assertCount(2, $details['files']);
|
||||
}
|
||||
|
||||
public function test_followup_price_change_reuses_version_and_keeps_exact_courier_identity(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
|
||||
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $this->reviewer()->id);
|
||||
$version = CourierQuoteVersion::firstOrFail();
|
||||
$this->loginAs()->get(route('business.courier-quote-changes.start', ['source_quote_id' => $version->version_code]))
|
||||
->assertOk()->assertSeeText('编辑新价格');
|
||||
$change = app(CourierQuoteChangeService::class)->create([
|
||||
'source_quote_id' => $version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
|
||||
'summary' => '存量快递后续变价', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]],
|
||||
], (int) $this->user->id);
|
||||
app(CourierQuoteChangeService::class)->submit($change, (int) $this->user->id);
|
||||
$approved = app(CourierQuoteChangeService::class)->approve($change->fresh(), (int) $this->reviewer()->id);
|
||||
$this->assertSame('EXP-001', $approved->version->courier_expcode);
|
||||
$this->assertSame('首重1kg,续重按原报价', $approved->version->billing_rules);
|
||||
$this->assertEquals(6, json_decode($approved->version->values_json, true)[1][1]);
|
||||
$this->assertEquals(5, json_decode($version->fresh()->values_json, true)[1][1]);
|
||||
}
|
||||
|
||||
public function test_other_company_cannot_download_or_edit_stock_quote(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
\DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
|
||||
$this->withExceptionHandling()->loginAs()
|
||||
->get(route('business.courier-stock-quotes.download', [$entry, 0]))->assertNotFound();
|
||||
$this->loginAs()->get(route('business.courier-stock-quotes.edit', $entry))->assertNotFound();
|
||||
$this->assertNull(CourierStockQuoteEntry::find($entry->id));
|
||||
}
|
||||
|
||||
public function test_same_name_couriers_keep_separate_effective_versions(): void
|
||||
{
|
||||
$reviewer = $this->reviewer();
|
||||
foreach (['EXP-001', 'EXP-002'] as $code) {
|
||||
$entry = $this->createEntry(['courier_expcode' => $code, 'signed_files' => [UploadedFile::fake()->create('回签.pdf', 1, 'application/pdf')]]);
|
||||
app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
|
||||
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $reviewer->id, true);
|
||||
}
|
||||
$quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
|
||||
$this->assertCount(2, $quotes);
|
||||
$this->assertSame(['生效中', '生效中'], array_column($quotes, 'status'));
|
||||
$this->assertSame(['EXP-001', 'EXP-002'], array_column($quotes, 'courier_expcode'));
|
||||
}
|
||||
|
||||
private function createEntry(array $overrides = []): CourierStockQuoteEntry
|
||||
{
|
||||
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), ...$overrides])->assertRedirect();
|
||||
|
||||
return CourierStockQuoteEntry::latest('id')->firstOrFail();
|
||||
}
|
||||
|
||||
private function payload(): array
|
||||
{
|
||||
return ['courier_expcode' => 'EXP-001', 'quote_type' => '标准快递报价', 'source_quote' => $this->workbook(),
|
||||
'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '补录历史合作报价'];
|
||||
}
|
||||
|
||||
private function workbook(bool $multipleSheets = false, int $price = 5): UploadedFile
|
||||
{
|
||||
$book = new Spreadsheet();
|
||||
$sheet = $book->getActiveSheet()->setTitle('报价');
|
||||
$sheet->fromArray([['省份', '首重', '续重'], ['江苏省', $price, null]]);
|
||||
$sheet->setCellValue('C2', '=B2/2');
|
||||
if ($multipleSheets) {
|
||||
$book->createSheet()->setTitle('其他网点')->setCellValue('A1', '其他报价');
|
||||
}
|
||||
$path = tempnam(sys_get_temp_dir(), 'stock-quote-');
|
||||
$this->temporaryFiles[] = $path;
|
||||
IOFactory::createWriter($book, 'Xlsx')->save($path);
|
||||
$book->disconnectWorksheets();
|
||||
|
||||
return new UploadedFile($path, '原始报价.xlsx', null, null, true);
|
||||
}
|
||||
|
||||
private function reviewer()
|
||||
{
|
||||
$user = user_model_class()::create([
|
||||
'name' => '报价审核员', 'email' => 'stock-reviewer-' . uniqid() . '@example.com',
|
||||
'password' => 'local-test-password', 'locale' => 'zh-CN', 'enabled' => 1,
|
||||
'landing_page' => 'dashboard', 'created_from' => 'core::test',
|
||||
]);
|
||||
$user->companies()->attach($this->company->id);
|
||||
$user->attachRole(Role::where('name', 'admin')->firstOrFail());
|
||||
$user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user