feat: 存量报价导入支持多个执行区间

This commit is contained in:
limeng 2026-10-24 16:27:58 +08:00
parent 9a9e01461c
commit 52c0212719
8 changed files with 323 additions and 62 deletions

View File

@ -92,6 +92,10 @@ class CourierStockQuoteEntries extends Controller
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'],
'entries.*.periods.*' => ['array'],
'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'],
'submit_for_review' => ['required', 'boolean'],
'confirmed' => ['accepted_if:submit_for_review,true'],
])->validate();
@ -107,14 +111,19 @@ class CourierStockQuoteEntries extends Controller
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
...$this->metadataRules(),
...$this->metadataRules(true),
'courier_expcode' => ['nullable', 'string', 'max:64'],
'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'],
'quote_type' => ['required', 'string', 'max:64'],
'sheet_name' => ['nullable', 'string', 'max:255'],
...$this->fileRules(true),
]);
$entry = $this->service->create($data, (int) user_id());
$entries = $this->service->createPeriods($data, (int) user_id());
if (count($entries) > 1) {
return redirect()->route('business.courier-quote-changes.index')
->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。');
}
$entry = $entries[0];
return redirect()->route('business.courier-quote-changes.show', $entry->change)
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
@ -177,10 +186,16 @@ class CourierStockQuoteEntries extends Controller
return $disk->download($attachment['path'], $attachment['name'], $headers);
}
private function metadataRules(): array
private function metadataRules(bool $multiplePeriods = false): array
{
return [
'effective_date' => ['required', 'date_format:Y-m-d'],
...($multiplePeriods ? [
'periods' => ['sometimes', 'array', 'min:1', 'max:20'],
'periods.*' => ['array'],
'periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'],
] : []),
'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],

View File

@ -108,33 +108,39 @@ final class CourierStockQuoteBatchService
$courierError = $exception->validator->errors()->first();
}
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
$metadata = [
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
$periods = $row['periods'] ?? [[
'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
'end_date' => ($row['end_date'] ?? null) ?: (($data['end_date'] ?? null) ?: null),
'billing_rules' => trim($rules), 'summary' => $data['summary'],
];
$validation = Validator::make($metadata, [
'effective_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
$reason ??= $courierError;
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
foreach ($sheet['signed_images'] ?? [] as $image) {
if (! Storage::disk('uploads')->exists($image['path'])) {
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
]];
foreach ($periods as $periodIndex => $period) {
$metadata = [
'quote_type' => trim($data['quote_type']), 'effective_date' => $period['effective_date'],
'end_date' => ($period['end_date'] ?? null) ?: null,
'billing_rules' => trim($rules), 'summary' => $data['summary'],
];
$validation = Validator::make($metadata, [
'effective_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
$reason ??= $courierError;
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
foreach ($sheet['signed_images'] ?? [] as $image) {
if (! Storage::disk('uploads')->exists($image['path'])) {
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
}
}
if ($courier !== null) {
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
}
if ($reason !== null) {
$errors['entries.' . $index . (isset($row['periods']) ? '.periods.' . $periodIndex : '')] = '「' . ($sheet['sheet_name'] ?? $index) . '」第 ' . (array_search($periodIndex, array_keys($periods), true) + 1) . ' 段:' . $reason;
} else {
$prepared[] = compact('metadata', 'courier', 'sheet');
}
}
if ($courier !== null) {
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
}
if ($reason !== null) {
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
} else {
$prepared[] = compact('metadata', 'courier', 'sheet');
}
}
if ($errors !== []) {
@ -142,17 +148,18 @@ final class CourierStockQuoteBatchService
}
abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。');
// 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。
return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array {
return DB::transaction(function () use ($prepared, $selected, $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];
$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,
'effective_date' => $entry->effective_date->toDateString(), 'end_date' => $entry->end_date?->toDateString()];
}
$result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
$result = ['created' => $created, 'selected_sheets' => count($selected), 'skipped' => count($manifest['sheets']) - count($selected), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
$manifest['result'] = $result;
$this->write($batch, $userId, $manifest);

View File

@ -224,6 +224,37 @@ final class CourierStockQuoteEntryService
return $this->createPrepared($data, $userId, $courier, $matrix);
}
/** 同一份原报价按不连续期间分别建草稿,复用原件;任一区间失败整组回滚。 */
public function createPeriods(array $data, int $userId): array
{
if (! isset($data['periods'])) {
return [$this->create($data, $userId)];
}
$courier = $this->resolveCourier($data);
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
$savedFiles = [];
try {
return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedFiles): array {
$created = [];
foreach ($data['periods'] as $period) {
$metadata = [...$data, 'effective_date' => $period['effective_date'],
'end_date' => ($period['end_date'] ?? null) ?: null, 'quote_type' => trim($data['quote_type'])];
if ($created !== []) {
unset($metadata['source_quote'], $metadata['confirmation_files'], $metadata['signed_files']);
}
$entry = $this->createPrepared($metadata, $userId, $courier, $matrix, $savedFiles);
$savedFiles = $entry->files;
$created[] = $entry;
}
return $created;
});
} catch (\Throwable $exception) {
Storage::disk('uploads')->delete(array_column($savedFiles, 'path'));
throw $exception;
}
}
/** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */
public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry
{

View File

@ -0,0 +1,53 @@
@once
<style>
.sq-periods { min-width:270px; display:grid; gap:10px; }
.sq-periods [data-period-list] { display:grid; gap:10px; }
.sq-period { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)) auto; align-items:end; gap:8px; padding-bottom:10px; border-bottom:1px solid var(--vip-border,#D8D2C7); }
.sq-period label { display:grid; gap:4px; color:var(--vip-content-muted,#595959); font-size:12px; }
.sq-period input { width:100%; min-width:0; height:34px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:#fff; color:var(--vip-content-ink,#171717); padding:0 8px; font:inherit; }
.sq-period input:focus { outline:none; border-color:var(--vip-content-accent-dark,#856632); box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.sq-periods button { min-height:34px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; padding:0 10px; background:#fff; color:var(--vip-content-accent-dark,#856632); cursor:pointer; font:inherit; }
.sq-periods button:disabled { opacity:.6; cursor:default; }
.sq-periods__hint { margin:0; color:var(--vip-content-muted,#595959); font-size:12px; }
</style>
<script>
document.addEventListener('click', event => {
const button = event.target.closest('[data-add-period], [data-remove-period]');
const container = button?.closest('[data-quote-periods]');
if (!container) return;
const list = container.querySelector('[data-period-list]');
if (button.hasAttribute('data-add-period') && list.children.length < 20) {
const next = list.firstElementChild.cloneNode(true);
next.querySelectorAll('input').forEach(input => { input.value = ''; input.removeAttribute('min'); });
list.appendChild(next);
} else if (button.hasAttribute('data-remove-period') && list.children.length > 1) {
button.closest('[data-period]').remove();
}
[...list.children].forEach((period, index) => {
period.querySelector('[data-period-label]').textContent = `第 ${index + 1} 段开始日期`;
period.querySelector('[data-remove-period]').disabled = list.children.length === 1;
for (const [selector, field] of [['[data-row-date]', 'effective_date'], ['[data-row-end]', 'end_date']]) {
const input = period.querySelector(selector);
input.setAttribute('aria-label', `第 ${index + 1} 段${field === 'end_date' ? '截止' : '开始'}日期`);
if (container.dataset.named === '1') input.name = `periods[${index}][${field}]`;
}
});
container.querySelector('[data-add-period]').disabled = list.children.length >= 20;
container.dispatchEvent(new Event('change', { bubbles: true }));
if (button.hasAttribute('data-add-period')) list.lastElementChild.querySelector('input').focus();
});
</script>
@endonce
<div class="sq-periods" data-quote-periods data-named="{{ ($named ?? false) ? '1' : '0' }}">
<div data-period-list>
@foreach($periods as $periodIndex => $period)
<div class="sq-period" data-period>
<label><span data-period-label>第 {{ $periodIndex + 1 }} 段开始日期</span><input type="date" data-row-date value="{{ $period['effective_date'] ?? '' }}" aria-label="第 {{ $periodIndex + 1 }} 段开始日期" @if($named ?? false) name="periods[{{ $periodIndex }}][effective_date]" required @endif></label>
<label>截止日期(含当天)<input type="date" data-row-end value="{{ $period['end_date'] ?? '' }}" aria-label="第 {{ $periodIndex + 1 }} 段截止日期" @if($named ?? false) name="periods[{{ $periodIndex }}][end_date]" @endif></label>
<button type="button" data-remove-period @disabled(count($periods) === 1) aria-label="删除此执行区间">移除</button>
</div>
@endforeach
</div>
<div><button type="button" data-add-period @disabled(count($periods) >= 20)>+ 添加执行区间</button></div>
<p class="sq-periods__hint">多段日期分别建报价,共用同一原件与价格;空档不执行本报价。最多 20 段。</p>
</div>

View File

@ -104,12 +104,12 @@
<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>
<div class="sb-notice" role="status">已录入 <strong>{{ count($result['created']) }}</strong> 个执行区间报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},来自 {{ $result['selected_sheets'] ?? count($result['created']) }} 张工作表,跳过 {{ $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'] }}</td><td><a href="{{ route('business.courier-quote-changes.show', $created['change_id']) }}">查看报价</a></td></tr>@endforeach
<div class="sb-table-wrap"><table class="sb-table"><thead><tr><th>原工作表</th><th>确认的快递名称</th><th>执行区间</th><th>查看</th></tr></thead><tbody>
@foreach($result['created'] as $created)<tr><td>{{ $created['sheet_name'] }}</td><td>{{ $created['courier_name'] }}</td><td>{{ $created['effective_date'] ?? '见报价详情' }} 至 {{ $created['end_date'] ?? '长期' }}</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>
@ -144,6 +144,7 @@
</div>
<p class="sb-hint" data-batch-count aria-live="polite"></p>
<p class="sb-hint">输入两字以上可搜索近期发货名称;找不到也可手填,不要求先建快递资料。请使用发货数据中的完整名称,不要只填品牌;说明页、重复报价可取消勾选。各表图片分别留存,存档不代表已核对一致。</p>
<p class="sb-hint">同一份报价有多段执行日期时,点击“添加执行区间”逐段填写,无需拆表或重复上传。请核对原件日期,不会自动识别图片中的日期。</p>
</div>
@include('business.pages.courier-payables.partials.stock-quote-courier-names')
<div class="sb-table-wrap" tabindex="0" aria-label="工作表匹配结果">
@ -160,7 +161,10 @@
<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="stock-courier-names" data-row-courier data-courier-name-input value="{{ $choiceName }}" maxlength="255" placeholder="选择或手填完整快递名称" aria-label="{{ $row['sheet_name'] }}的快递名称" @disabled($row['error'])><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><label>截止日期<input class="sb-control" type="date" data-row-end value="{{ $choice['end_date'] ?? '' }}" aria-label="{{ $row['sheet_name'] }}的截止日期"></label><span class="sb-hint">留空沿用共用截止日期;均空为长期</span></td>
<td>
@include('business.pages.courier-payables.partials.stock-quote-periods', ['named' => false, 'periods' => $choice['periods'] ?? [['effective_date' => $choice['effective_date'] ?? '', 'end_date' => $choice['end_date'] ?? '']]])
<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>
@ -215,37 +219,41 @@
const courierCode = row => courierName(row) === row.dataset.initialName ? row.dataset.initialCode : '';
const identity = row => courierCode(row) ? 'code:' + courierCode(row) : 'name:' + courierName(row).toLowerCase();
const existingPeriods = @json(app(\App\Support\CourierStockQuoteEntryService::class)->existingIdentities(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
const period = row => ({start: row.querySelector('[data-row-date]').value || commonDate.value, end: row.querySelector('[data-row-end]').value || form.querySelector('[data-common-end]').value || '9999-12-31'});
const periods = row => {
const inputs = [...row.querySelectorAll('[data-period]')];
return inputs.map((period, index) => ({
start: period.querySelector('[data-row-date]').value || (index === 0 ? commonDate.value : ''),
end: period.querySelector('[data-row-end]').value || (inputs.length === 1 ? form.querySelector('[data-common-end]').value : '') || '9999-12-31'
}));
};
const overlaps = (a,b) => !a.start || !b.start || (a.start <= b.end && b.start <= a.end);
const isExisting = row => existingPeriods.some(entry => {
const same = courierCode(row) && entry.code ? courierCode(row) === entry.code : courierName(row).toLowerCase() === entry.name.trim().toLowerCase();
return same && entry.quote_type === form.querySelector('[data-common-type]').value.trim() && overlaps(period(row), {start:entry.effective_date,end:entry.end_date || '9999-12-31'});
return same && entry.quote_type === form.querySelector('[data-common-type]').value.trim() && periods(row).some(period => overlaps(period, {start:entry.effective_date,end:entry.end_date || '9999-12-31'}));
});
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 key = identity(row);
if (row.querySelector('[data-row-selected]').checked && courierName(row)) identities.set(key, (identities.get(key) || 0) + 1);
});
let selected = 0, pending = 0, periodCount = 0;
rows.forEach(row => {
const name = courierName(row);
const checked = row.querySelector('[data-row-selected]').checked;
const known = knownNames.has(name);
const duplicate = isExisting(row);
const invalid = row.dataset.invalid === '1';
const repeated = checked && rows.some(other => other !== row && other.querySelector('[data-row-selected]').checked && identity(other) === identity(row) && overlaps(period(row), period(other)));
const dates = periods(row);
const internalOverlap = dates.some((period, index) => dates.slice(index + 1).some(other => overlaps(period, other)));
const invalidDate = dates.some(period => !period.start || period.end < period.start);
const repeated = checked && rows.some(other => other !== row && other.querySelector('[data-row-selected]').checked && identity(other) === identity(row) && dates.some(period => periods(other).some(otherPeriod => overlaps(period, otherPeriod))));
const status = row.querySelector('[data-row-status]');
status.textContent = invalid ? '空表,已跳过' : duplicate ? '与已有报价期间重叠,请调整日期' : repeated ? '与本批同一快递的期间重叠' : !name ? '请填写完整快递名称' : courierName(row) === row.dataset.initialName && row.dataset.initialStatus.includes('多条资料') ? row.dataset.initialStatus : known ? '名称已匹配,期间无重叠' : '按名称录入,请确认与发货数据一致';
status.classList.toggle('sb-error', !invalid && (!name || repeated));
row.querySelector('[data-row-date-hint]').textContent = row.querySelector('[data-row-date]').value ? '单独执行日期' : commonDate.value ? '沿用 ' + commonDate.value : '请填写共用或单独日期';
const needsAttention = !known || repeated || duplicate || invalid;
status.textContent = invalid ? '空表,已跳过' : invalidDate ? '请补全开始日期,截止不能早于开始' : internalOverlap ? '本表执行区间重叠,请调整日期' : duplicate ? '与已有报价期间重叠,请调整日期' : repeated ? '与本批同一快递的期间重叠' : !name ? '请填写完整快递名称' : courierName(row) === row.dataset.initialName && row.dataset.initialStatus.includes('多条资料') ? row.dataset.initialStatus : known ? '名称已匹配,期间无重叠' : '按名称录入,请确认与发货数据一致';
status.classList.toggle('sb-error', !invalid && (!name || repeated || duplicate || internalOverlap || invalidDate));
row.querySelector('[data-row-date-hint]').textContent = dates.length > 1 ? '多段截止留空表示长期,不沿用共用截止;区间不得重叠' : row.querySelector('[data-row-date]').value ? '单独执行日期;截止留空沿用共用截止' : commonDate.value ? '沿用 ' + commonDate.value + ';截止留空沿用共用截止' : '请填写共用或单独日期';
const needsAttention = !known || repeated || duplicate || invalid || internalOverlap || invalidDate;
row.hidden = !(row.dataset.sheetName + ' ' + name).toLowerCase().includes(search) || (onlyUnmatched && !needsAttention);
if (checked) { selected++; if (needsAttention) pending++; }
if (checked) { selected++; periodCount += dates.length; if (needsAttention) pending++; }
});
form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 家 · 其中 ${pending} 家需确认名称或处理重复 · 跳过 ${rows.length - selected} 张工作表`;
form.querySelector('[data-batch-count]').textContent = `已选 ${selected} 张工作表 · ${periodCount} 个执行区间 · 其中 ${pending} 张需处理 · 跳过 ${rows.length - selected} 张工作表`;
};
const updateGroups = () => {
const names = [...groups.querySelectorAll('[data-rule-name]')].map((input, index) => input.value || `规则分组 ${index + 1}`);
@ -276,6 +284,20 @@
visible.forEach(row => row.querySelector('[data-row-selected]').checked = select); update();
});
let saving = false;
const serializeRow = row => {
const selected = row.querySelector('[data-row-selected]').checked;
const dates = periods(row);
return {
selected, courier_name: courierName(row), courier_expcode: courierCode(row),
rule_group: Number(row.querySelector('[data-row-rule]').value),
...(selected && dates.length > 1 ? {
periods: dates.map(period => ({effective_date: period.start, end_date: period.end === '9999-12-31' ? null : period.end}))
} : {
effective_date: row.querySelector('[data-row-date]').value,
end_date: row.querySelector('[data-row-end]').value
})
};
};
form.addEventListener('submit', async event => {
event.preventDefault();
if (saving) return;
@ -286,7 +308,7 @@
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_name: courierName(row), courier_expcode: courierCode(row), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').value, end_date: row.querySelector('[data-row-end]').value }))
entries: rows.map(serializeRow)
};
const errors = root.querySelector('[data-save-errors]');
errors.hidden = true;

View File

@ -53,7 +53,7 @@
<label for="quote_type">报价类型</label>
<input class="stock-quote__control" id="quote_type" name="quote_type" list="stock-quote-types" value="{{ old('quote_type', request('quote_type')) }}" maxlength="64" required placeholder="例如:标准快递报价、超长件报价">
<datalist id="stock-quote-types"><option value="标准快递报价"><option value="超长件报价"><option value="大件快递报价"></datalist>
<p class="stock-quote__hint">同一家快递、同一类型只录入一次;后续调整请从对应报价发起变价。</p>
<p class="stock-quote__hint">同一家快递、同一类型可录入多个不重叠的执行区间;后续调整请从对应报价发起变价。</p>
</div>
</div>
@endif
@ -77,8 +77,14 @@
<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">
@if(!$entry)
<div class="stock-quote__field"><span>实际开始执行日期 · 可添加多个执行区间</span>
@include('business.pages.courier-payables.partials.stock-quote-periods', ['named' => true, 'periods' => old('periods', [['effective_date' => old('effective_date', ''), 'end_date' => old('end_date', '')]])])
</div>
@else
<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="end_date">执行截止日期(可留空)</label><input class="stock-quote__control" id="end_date" name="end_date" type="date" value="{{ old('end_date', $entry?->change?->end_date?->toDateString()) }}"><p class="stock-quote__hint">留空表示长期执行;同一快递、同类报价的执行期间不得重叠,截止日期当天仍适用。</p></div>
@endif
<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>

View File

@ -109,6 +109,85 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
}
public function test_one_sheet_with_three_periods_reuses_originals_and_only_executes_within_each_period(): void
{
$batch = $this->preview(['EXP-001', '说明'], [1]);
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('添加执行区间');
$selection = $this->selection(['EXP-001', '']);
$selection['entries'][1]['selected'] = false;
$periods = [
['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
];
$selection['entries'][0]['periods'] = $periods;
$selection['submit_for_review'] = $selection['confirmed'] = true;
$url = route('business.courier-stock-quotes.batch-save', $batch);
$this->postJson($url, $selection)->assertOk();
$entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get();
$this->assertCount(3, $entries);
$this->assertCount(1, $entries->pluck('values_json')->unique());
$this->assertCount(1, $entries->pluck('formulas_json')->unique());
$this->assertCount(1, $entries->pluck('files')->map(fn ($files) => json_encode($files))->unique());
$result = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id)['result'];
$this->assertSame(1, $result['selected_sheets']);
$this->assertSame(1, $result['skipped']);
$this->assertSame($periods, array_map(fn ($row) => \Illuminate\Support\Arr::only($row, ['effective_date', 'end_date']), $result['created']));
$reviewer = user_model_class()::create([
'name' => '区间审核员', 'email' => 'period-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);
$versions = $entries->map(fn ($entry) => app(CourierQuoteChangeService::class)->approve($entry->change, (int) $reviewer->id)->version);
foreach ($periods as $index => $period) {
foreach ($period as $date) {
$this->assertSame($versions[$index]->id, \App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date))?->id);
}
}
foreach (['2026-10-20', '2026-10-25', '2026-11-05', '2026-11-10', '2026-11-15'] as $date) {
$this->assertNull(\App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date)));
}
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('3')->assertSeeText('2026-11-14');
$this->postJson($url, $selection)->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
}
public function test_multi_period_validation_rejects_overlaps_missing_dates_and_bad_ranges_without_partial_imports(): void
{
$batch = $this->preview(['EXP-001', 'EXP-003']);
$selection = $this->selection(['EXP-001', 'EXP-003']);
$url = route('business.courier-stock-quotes.batch-save', $batch);
$this->withExceptionHandling();
foreach ([
[],
[['effective_date' => '', 'end_date' => '2026-11-04']],
[['effective_date' => '2026-02-30', 'end_date' => null]],
[['effective_date' => '2026-11-04', 'end_date' => '2026-11-01']],
array_fill(0, 21, ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04']),
[['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ['effective_date' => '2026-11-04', 'end_date' => '2026-11-08']],
[['effective_date' => '2026-11-01', 'end_date' => null], ['effective_date' => '2026-11-11', 'end_date' => null]],
] as $periods) {
$selection['entries'][0]['periods'] = $periods;
$this->postJson($url, $selection)->assertUnprocessable();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
$selection['entries'][0]['periods'] = [['effective_date' => '2026-11-01', 'end_date' => '2026-11-01']];
$selection['entries'][1]['periods'] = [['effective_date' => '2026-11-11', 'end_date' => null]];
$this->postJson($url, $selection)->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
// 新批次任意一段与已有区间重叠时,所有新段都不写入。
$nextBatch = $this->preview(['EXP-001']);
$next = $this->selection(['EXP-001']);
$next['entries'][0]['periods'] = [
['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
];
$this->postJson(route('business.courier-stock-quotes.batch-save', $nextBatch), $next)->assertUnprocessable()
->assertJsonValidationErrors('entries.0.periods.1');
$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']);
@ -254,7 +333,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$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
public function test_one_hundred_twenty_sheets_with_three_periods_preview_and_save_as_one_batch(): void
{
$couriers = $names = [];
for ($index = 1; $index <= 120; $index++) {
@ -262,12 +341,21 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$couriers[] = ['code' => $code, 'name' => '批量网点' . $index];
}
$this->mirror($couriers);
$batch = $this->preview($names, array_fill(0, 120, 1));
$batch = $this->preview($names, array_fill(0, 120, 1), 32);
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120');
config(['firewall.middleware.xss.enabled' => true]);
$this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $this->selection($names))->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 120);
$this->assertDatabaseCount('business_courier_quote_changes', 120);
$selection = $this->selection($names);
foreach ($selection['entries'] as &$row) {
$row['periods'] = [
['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
];
}
unset($row);
$this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 360);
$this->assertDatabaseCount('business_courier_quote_changes', 360);
$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());
}
@ -415,10 +503,10 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('无法无损提取');
}
private function preview(array $names, array $imageCounts = []): string
private function preview(array $names, array $imageCounts = [], int $regionRows = 1): string
{
$response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
'source_quote' => $this->workbook($names, $imageCounts), 'quote_type' => '标准快递报价',
'source_quote' => $this->workbook($names, $imageCounts, 'Xlsx', $regionRows), 'quote_type' => '标准快递报价',
'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计',
])->assertRedirect();
@ -434,7 +522,7 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
];
}
private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx'): UploadedFile
private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx', int $regionRows = 1): UploadedFile
{
$book = new Spreadsheet();
foreach ($names as $index => $name) {
@ -444,6 +532,11 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
}
$sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]);
$sheet->setCellValue('C2', '=B2/2');
for ($region = 2; $region <= $regionRows; $region++) {
$sheet->setCellValue('A' . ($region + 1), '测试地区' . $region);
$sheet->setCellValue('B' . ($region + 1), 5 + $index);
$sheet->setCellValue('C' . ($region + 1), '=B' . ($region + 1) . '/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));

View File

@ -47,7 +47,7 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
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()
->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertDontSee('name="courier_expcode"', false);
->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertSee('data-stock-courier-code', false);
$data = $this->payload();
$originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
$this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
@ -89,6 +89,40 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
$this->assertSame('存量报价,审核生效(无需签章)', $snapshot['signature_status']);
}
public function test_single_upload_can_create_disjoint_periods_and_cleans_up_files_if_any_period_conflicts(): void
{
$this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()->assertSeeText('添加执行区间');
$data = $this->payload();
unset($data['effective_date']);
$data['periods'] = [
['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'],
['effective_date' => '2026-11-11', 'end_date' => null],
];
$this->post(route('business.courier-stock-quotes.store'), $data)->assertSessionHasNoErrors()
->assertRedirect(route('business.courier-quote-changes.index'));
$entries = CourierStockQuoteEntry::orderBy('id')->get();
$this->assertCount(3, $entries);
$this->assertCount(1, $entries->pluck('values_json')->unique());
$this->assertCount(1, $entries->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
$this->assertSame('2026-11-04', $entries[1]->change->end_date->toDateString());
$this->assertNull($entries[2]->end_date);
$files = Storage::disk('uploads')->allFiles();
$this->assertCount(1, $files);
$conflict = $this->payload();
$conflict['periods'] = [
['effective_date' => '2026-10-01', 'end_date' => '2026-10-02'],
['effective_date' => '2026-10-24', 'end_date' => '2026-10-25'],
];
$this->withExceptionHandling()->post(route('business.courier-stock-quotes.store'), $conflict)->assertSessionHasErrors('courier_name');
$this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
$this->assertSame($files, Storage::disk('uploads')->allFiles());
$conflict['source_quote'] = $this->workbook();
$conflict['periods'][1] = ['effective_date' => '2026-11-04', 'end_date' => '2026-11-01'];
$this->post(route('business.courier-stock-quotes.store'), $conflict)->assertSessionHasErrors('periods.1.end_date');
$this->assertDatabaseCount('business_courier_stock_quote_entries', 3);
}
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')]]);