存量报价支持多时期导入、快递分组及按日期查看 (#382)
This commit is contained in:
parent
077701d24a
commit
bd39a7fdda
@ -38,6 +38,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_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());
|
||||
@ -77,6 +78,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
$data = Validator::make($selection, [
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_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'],
|
||||
@ -89,6 +91,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
|
||||
'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'],
|
||||
'submit_for_review' => ['required', 'boolean'],
|
||||
'confirmed' => ['accepted_if:submit_for_review,true'],
|
||||
])->validate();
|
||||
@ -178,6 +181,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
return [
|
||||
'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'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
];
|
||||
@ -198,4 +202,13 @@ class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
|
||||
}
|
||||
|
||||
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
|
||||
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
|
||||
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
|
||||
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
|
||||
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ class CourierQuoteChangeEvent extends Model
|
||||
'returned' => '退回修改',
|
||||
'approved' => '审核通过',
|
||||
'voided' => '版本作废',
|
||||
'period_end_updated' => '设置执行截止日期',
|
||||
default => $this->event_type,
|
||||
};
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ class CourierQuoteVersion extends Model
|
||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||
public function isTemporary(): bool
|
||||
{
|
||||
return $this->end_date !== null;
|
||||
return $this->end_date !== null && !$this->change?->isStockQuote();
|
||||
}
|
||||
|
||||
/** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */
|
||||
@ -52,7 +52,7 @@ class CourierQuoteVersion extends Model
|
||||
}
|
||||
|
||||
return $this->end_date !== null
|
||||
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
|
||||
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . ($this->isTemporary() ? '(临时)' : '')
|
||||
: $this->effective_date->toDateString() . ' 起';
|
||||
}
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ class CourierStockQuoteEntry extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'courier_expcode', 'courier_name', 'courier_name_key', 'quote_type', 'source_change_id',
|
||||
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by',
|
||||
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by', 'effective_date', 'end_date',
|
||||
];
|
||||
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array'];
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array', 'effective_date' => 'date', 'end_date' => 'date'];
|
||||
|
||||
public function change(): BelongsTo
|
||||
{
|
||||
|
||||
@ -35,6 +35,7 @@ final class CourierQuoteChangeService
|
||||
'change_type' => $data['change_type'],
|
||||
'status' => CourierQuoteChange::STATUS_DRAFT,
|
||||
'effective_date' => $data['effective_date'],
|
||||
'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
'is_retrospective' => $retrospective,
|
||||
'recalculation_status' => $retrospective
|
||||
? CourierQuoteChange::RECALCULATION_PENDING
|
||||
@ -66,6 +67,7 @@ final class CourierQuoteChangeService
|
||||
$change->update([
|
||||
'change_type' => $data['change_type'],
|
||||
'effective_date' => $data['effective_date'],
|
||||
'end_date' => $change->isStockQuote() ? (($data['end_date'] ?? null) ?: null) : $change->end_date,
|
||||
'is_retrospective' => $retrospective,
|
||||
'recalculation_status' => $retrospective
|
||||
? CourierQuoteChange::RECALCULATION_PENDING
|
||||
@ -139,6 +141,11 @@ final class CourierQuoteChangeService
|
||||
$source = $this->findSourceQuote($change->source_quote_id);
|
||||
|
||||
return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange {
|
||||
if ($change->isStockQuote()) {
|
||||
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
|
||||
$entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'));
|
||||
app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id);
|
||||
}
|
||||
$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) {
|
||||
|
||||
@ -77,7 +77,7 @@ final class CourierStockQuoteBatchService
|
||||
], true)));
|
||||
$courier = count($matches) === 1 ? $matches[0] : ['code' => '', 'name' => trim($sheet['sheet_name'])];
|
||||
$code = $courier['code'] ?? '';
|
||||
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing);
|
||||
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing, ($manifest['defaults']['effective_date'] ?? null) ?: null, ($manifest['defaults']['end_date'] ?? null) ?: null);
|
||||
|
||||
return [...$sheet, 'courier_expcode' => $code, 'courier_name' => $courier['name'], 'duplicate' => $duplicate,
|
||||
'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称对应多条资料,请确认发货名称;按名称保存' : '可手填名称,请与发货数据一致'))),
|
||||
@ -110,10 +110,12 @@ final class CourierStockQuoteBatchService
|
||||
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
|
||||
$metadata = [
|
||||
'quote_type' => trim($data['quote_type']), '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);
|
||||
@ -125,9 +127,9 @@ final class CourierStockQuoteBatchService
|
||||
}
|
||||
}
|
||||
if ($courier !== null) {
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing) ? '此快递的同类报价已录入,请取消选择;后续调价使用变价。' : null;
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities) ? '与本批其他工作表填写了同一家快递,请检查名称或取消选择。' : null;
|
||||
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type']];
|
||||
$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;
|
||||
|
||||
@ -73,17 +73,20 @@ final class CourierStockQuoteEntryService
|
||||
|
||||
public function existingIdentities(?int $exceptId = null): array
|
||||
{
|
||||
return CourierStockQuoteEntry::withTrashed()->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['courier_expcode', 'courier_name', 'quote_type'])
|
||||
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type])->all();
|
||||
return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
|
||||
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
|
||||
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
|
||||
}
|
||||
|
||||
public function isDuplicate(array $courier, string $quoteType, array $existing): bool
|
||||
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
|
||||
{
|
||||
foreach ($existing as $entry) {
|
||||
if ($entry['quote_type'] !== $quoteType) {
|
||||
continue;
|
||||
}
|
||||
if ($start !== null && isset($entry['effective_date'])
|
||||
&& ($start > ($entry['end_date'] ?? '9999-12-31') || ($end ?? '9999-12-31') < $entry['effective_date'])) continue;
|
||||
if (filled($courier['code']) && filled($entry['code'])) {
|
||||
if ($courier['code'] === $entry['code']) {
|
||||
return true;
|
||||
@ -96,6 +99,18 @@ final class CourierStockQuoteEntryService
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertPeriod(array $courier, string $quoteType, string $start, ?string $end, ?int $exceptId = null): void
|
||||
{
|
||||
if ($end !== null && $end < $start) throw ValidationException::withMessages(['end_date' => '截止日期不能早于开始日期。']);
|
||||
if ($this->isDuplicate($courier, $quoteType, $this->existingIdentities($exceptId), $start, $end)) $this->duplicateError();
|
||||
}
|
||||
|
||||
private function lockCompany(): void
|
||||
{
|
||||
// 同公司导入/改期串行,覆盖空集合及无编号快递,防止并发导入重叠时期。
|
||||
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
/** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */
|
||||
public function readWorkbook(UploadedFile $file, ?string $sheetName): array
|
||||
{
|
||||
@ -203,9 +218,7 @@ final class CourierStockQuoteEntryService
|
||||
{
|
||||
$data['quote_type'] = trim($data['quote_type']);
|
||||
$courier = $this->resolveCourier($data);
|
||||
if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
|
||||
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
|
||||
|
||||
return $this->createPrepared($data, $userId, $courier, $matrix);
|
||||
@ -217,19 +230,21 @@ final class CourierStockQuoteEntryService
|
||||
$savedPaths = [];
|
||||
try {
|
||||
return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry {
|
||||
$this->lockCompany();
|
||||
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
|
||||
$entry = CourierStockQuoteEntry::create([
|
||||
'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'],
|
||||
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
|
||||
'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' => [], 'created_by' => $userId, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
]);
|
||||
$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' => [],
|
||||
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, 'summary' => $data['summary'], 'items' => [],
|
||||
'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']],
|
||||
'proposed_values_json' => $entry->values_json,
|
||||
], $userId);
|
||||
@ -253,14 +268,13 @@ final class CourierStockQuoteEntryService
|
||||
$courier = ['code' => $entry->courier_expcode, 'name' => $entry->courier_name];
|
||||
if (isset($data['courier_name']) && trim($data['courier_name']) !== $entry->courier_name) {
|
||||
$courier = $this->resolveCourier(['courier_name' => $data['courier_name']]);
|
||||
if ($this->isDuplicate($courier, $entry->quote_type, $this->existingIdentities($entry->id))) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
}
|
||||
$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, $courier, &$savedPaths): void {
|
||||
$this->lockCompany();
|
||||
$this->assertPeriod($courier, $entry->quote_type, $data['effective_date'], ($data['end_date'] ?? null) ?: null, $entry->id);
|
||||
$files = $entry->files;
|
||||
if ($matrix !== null) {
|
||||
foreach ($files as &$file) {
|
||||
@ -279,6 +293,7 @@ final class CourierStockQuoteEntryService
|
||||
'courier_name' => $courier['name'], 'courier_expcode' => $courier['code'],
|
||||
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
|
||||
'files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)],
|
||||
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
])->save();
|
||||
$entry->change->update(['carrier_name' => $courier['name'], 'source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]);
|
||||
$this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId);
|
||||
@ -311,6 +326,28 @@ final class CourierStockQuoteEntryService
|
||||
|
||||
private function duplicateError(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
|
||||
throw ValidationException::withMessages(['courier_name' => '该快递的同类报价执行期间重叠,请核对起止日期;已有长期报价请先设置截止日期,不覆盖原报价。']);
|
||||
}
|
||||
|
||||
public function setPeriodEnd(CourierStockQuoteEntry $entry, string $end, string $reason, int $userId): void
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id(), 404);
|
||||
DB::transaction(function () use ($entry, $end, $reason, $userId): void {
|
||||
$this->lockCompany();
|
||||
$entry->refresh()->load('change.version');
|
||||
$version = $entry->change?->version;
|
||||
if (!$version?->isApprovedStockQuote()) throw ValidationException::withMessages(['end_date' => '仅已审核且未作废的存量报价可设置截止日期;草稿请从报价历史编辑。']);
|
||||
$this->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $entry->effective_date->toDateString(), $end, $entry->id);
|
||||
$old = $entry->end_date?->toDateString();
|
||||
$entry->update(['end_date' => $end]);
|
||||
$entry->change->update(['end_date' => $end]);
|
||||
$version->update(['end_date' => $end]);
|
||||
\App\Models\Business\CourierQuoteChangeEvent::create([
|
||||
'company_id' => company_id(), 'quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated',
|
||||
'from_status' => $entry->change->status, 'to_status' => $entry->change->status, 'created_by' => $userId,
|
||||
'note' => '设置执行截止日期:' . $reason,
|
||||
'details_json' => json_encode(['previous_end_date' => $old, 'end_date' => $end], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,7 +159,13 @@ final class QuoteManagementSnapshot
|
||||
'quote_type' => $version->quote_type,
|
||||
'effective_date' => $version->effective_date?->toDateString(),
|
||||
'end_date' => $version->end_date?->toDateString(),
|
||||
'period_label' => $version->isTemporary() ? $version->periodLabel() : null,
|
||||
'period_label' => $version->end_date !== null ? $version->periodLabel() : null,
|
||||
'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type,
|
||||
'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null,
|
||||
'eligible' => !$version->isVoided() && $this->isExecutable($version),
|
||||
'is_stock' => (bool) $version->change?->isStockQuote(),
|
||||
'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true)
|
||||
? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : null,
|
||||
'source_range' => $version->source_range ?? '',
|
||||
'row_count' => count($values),
|
||||
'column_count' => $columnCount,
|
||||
@ -242,6 +248,7 @@ final class QuoteManagementSnapshot
|
||||
if ($version->isVoided()) {
|
||||
return '已作废';
|
||||
}
|
||||
if ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期';
|
||||
|
||||
if (! $this->isExecutable($version)) {
|
||||
return '待生效(待签章)';
|
||||
@ -289,8 +296,8 @@ final class QuoteManagementSnapshot
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($version->isTemporary() && $version->end_date->lt($today)) {
|
||||
$result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()];
|
||||
if ($version->end_date?->lt($today)) {
|
||||
$result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $version->periodLabel()];
|
||||
} elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) {
|
||||
$result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复'];
|
||||
} elseif ($current !== null) {
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void {
|
||||
$table->date('effective_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
});
|
||||
DB::table('business_courier_stock_quote_entries')->orderBy('id')->chunkById(200, function ($entries): void {
|
||||
foreach ($entries as $entry) {
|
||||
$change = DB::table('business_courier_quote_changes')->where('id', $entry->source_change_id)->first();
|
||||
if ($change) DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update([
|
||||
'effective_date' => $change->effective_date, 'end_date' => $change->end_date,
|
||||
]);
|
||||
}
|
||||
});
|
||||
Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void {
|
||||
$table->dropUnique('courier_stock_quote_identity_unique');
|
||||
$table->dropUnique('courier_stock_quote_name_unique');
|
||||
$table->index(['company_id', 'courier_expcode', 'quote_type', 'effective_date'], 'stock_quote_code_period');
|
||||
$table->index(['company_id', 'courier_name_key', 'quote_type', 'effective_date'], 'stock_quote_name_period');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 多时期记录不能自动缩回单期唯一约束,避免回滚时丢失历史报价。
|
||||
throw new RuntimeException('多时期报价迁移需核对已录入版本后手动回退;应用可直接回滚镜像。');
|
||||
}
|
||||
};
|
||||
13
docs/存量报价多时期.md
Normal file
13
docs/存量报价多时期.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 存量报价多时期
|
||||
|
||||
报价管理按快递编号(无编号时按名称)和报价类型分组,同组保留多个执行时期。列表可展开/收起历史,按查看日期判断适用版本;分页以组计数,详情可切换同组版本,各期原件独立保留。按日期查看不写入或重算历史运费。
|
||||
|
||||
单份及批量录入均支持开始和截止日期。起止日期均包含当天,截止日期留空表示长期。同组时期不得重叠,包括未审核草稿;批量输入也按相同规则校验,整批同进同退。已作废版本不再占用时期。按编号隔离同名不同网点,跨公司隔离。
|
||||
|
||||
存量报价审核后按执行期间生效,有限期间不是临时变价,不会在到期后自动恢复其他存量报价。真正的临时变价沿用原有恢复规则。未来时期显示待生效,结束时期显示已到期。
|
||||
|
||||
已有长期存量报价需要衔接下一期时,管理员可先点“设置截止日期”,填写截止日期和依据。该操作同步条目、审核单及版本的期间并记录前后日期和操作者,不修改原文、金额和原件;不得把截止日期延长到其他时期内。草稿仍从报价历史编辑。
|
||||
|
||||
迁移 `2026_10_17_150000_add_stock_quote_periods` 从原审核单回填条目日期,将单期唯一约束改为时期查询索引。公司行事务锁串行导入和改期,保存及审核均校验重叠。执行迁移前备份存量条目及关联审核单、版本和事件;应用回滚使用上一版镜像,数据库不自动恢复单期唯一约束,以免丢失新增时期。
|
||||
|
||||
验证覆盖单份/批量多时期、同日边界、长期重叠、倒序补录、历史日期选版、权限隔离、调整留痕及原件不变,并回归原有报价审核与临时变价流程。
|
||||
@ -391,7 +391,7 @@
|
||||
}
|
||||
|
||||
.quote-modal__panel > .quote-detail-table-wrap {
|
||||
grid-row: 4;
|
||||
grid-row: 5;
|
||||
}
|
||||
|
||||
.quote-signed[hidden],
|
||||
@ -490,7 +490,7 @@
|
||||
|
||||
.quote-modal__panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto auto minmax(0, 1fr);
|
||||
width: min(78rem, calc(100vw - 3rem));
|
||||
height: 100%;
|
||||
background: #FFFFFF;
|
||||
@ -854,6 +854,7 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<div style="padding:10px 20px;border-bottom:1px solid var(--quote-border)"><label>报价时期 <select data-quote-version-picker class="quote-control" style="max-width:100%"></select></label></div>
|
||||
<dl class="quote-detail-meta">
|
||||
<div><dt>版本号</dt><dd data-quote-detail="id"></dd></div>
|
||||
<div><dt>快递公司</dt><dd data-quote-detail="carrier"></dd></div>
|
||||
@ -875,6 +876,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog data-period-dialog style="max-width:480px;width:calc(100% - 32px);padding:20px;border:1px solid #D8D2C7;border-radius:12px;color:#171717;background:#fff">
|
||||
<form method="POST" data-period-form>@csrf
|
||||
<h3 style="font-size:14px;margin:0 0 12px">设置存量报价截止日期</h3>
|
||||
<p data-period-title></p>
|
||||
<label style="display:grid;gap:6px;margin-bottom:12px">截止日期(当天仍适用)<input class="quote-control" type="date" name="end_date" required></label>
|
||||
<label style="display:grid;gap:6px;margin-bottom:12px">调整依据<textarea class="quote-control" name="reason" minlength="2" maxlength="1000" required></textarea></label>
|
||||
<p style="font-size:12px;color:#595959">仅更新执行期间并保留操作记录,不修改报价原文或签署原件。期间不得与其他已录入报价重叠。</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end"><button type="button" class="quote-button" data-period-cancel>取消</button><button class="quote-button" type="submit">保存截止日期</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.querySelector('[data-quote-management]');
|
||||
@ -904,12 +915,37 @@
|
||||
});
|
||||
}
|
||||
var lastFocusedElement = null;
|
||||
var expandedGroups = new Set();
|
||||
var collapsedGroups = new Set();
|
||||
var asOf = @json(today()->toDateString());
|
||||
var dateLabel = create('label', 'quote-filter-label', '查看日期 ');
|
||||
var dateInput = create('input', 'quote-control');
|
||||
dateInput.type = 'date'; dateInput.value = asOf;
|
||||
dateInput.setAttribute('aria-label', '按日期查看报价版本');
|
||||
dateLabel.appendChild(dateInput);
|
||||
root.querySelector('[data-quote-result-count]').before(dateLabel);
|
||||
dateInput.addEventListener('change', function () { if (dateInput.value) { asOf = dateInput.value; filterQuotes(true); } });
|
||||
function groupKey(quote) { return quote.group_key || ('sheet:' + quote.id); }
|
||||
function versionsFor(quote) { return quotes.filter(function (v) { return groupKey(v) === groupKey(quote); }).sort(function (a,b) { return (b.effective_date || '').localeCompare(a.effective_date || '') || b.id.localeCompare(a.id); }); }
|
||||
function effectiveIn(versions) { return versions.filter(function (v) { return v.eligible && v.effective_date && v.effective_date <= asOf && (!v.end_date || v.end_date >= asOf); }).sort(function(a,b){return b.effective_date.localeCompare(a.effective_date) || b.id.localeCompare(a.id);})[0]; }
|
||||
function atDate(quote) {
|
||||
if (!quote.eligible) return quote;
|
||||
var copy = Object.assign({}, quote);
|
||||
if (asOf !== @json(today()->toDateString())) { delete copy.status_note; delete copy.superseded_by; }
|
||||
var current = effectiveIn(versionsFor(quote));
|
||||
if (quote.effective_date > asOf) copy.status = '待生效';
|
||||
else if (quote.end_date && quote.end_date < asOf) copy.status = '已到期';
|
||||
else if (current && current.id !== quote.id) copy.status = current.end_date && !current.is_stock ? '暂停中' : '已失效';
|
||||
else copy.status = quote.end_date && !quote.is_stock ? '生效中(临时)' : '生效中';
|
||||
return copy;
|
||||
}
|
||||
var fields = {
|
||||
keyword: root.querySelector('[data-quote-filter="keyword"]'),
|
||||
carrier: root.querySelector('[data-quote-filter="carrier"]'),
|
||||
type: root.querySelector('[data-quote-filter="type"]'),
|
||||
status: root.querySelector('[data-quote-filter="status"]')
|
||||
};
|
||||
['生效中','待生效','已到期','已失效','已作废','暂停中','生效中(临时)'].forEach(function(status){var option=create('option','',status);option.value=status;fields.status.appendChild(option);});
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
@ -943,6 +979,7 @@
|
||||
|
||||
function updateUrl() {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('quote_date', asOf);
|
||||
|
||||
Object.keys(fields).forEach(function (key) {
|
||||
var value = fields[key].value.trim();
|
||||
@ -965,6 +1002,8 @@
|
||||
|
||||
function restoreUrlFilters() {
|
||||
var params = new URL(window.location.href).searchParams;
|
||||
var requestedDate=params.get('quote_date');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(requestedDate || '')) { dateInput.value=requestedDate;if(dateInput.value)asOf=dateInput.value; }
|
||||
|
||||
Object.keys(fields).forEach(function (key) {
|
||||
var value = params.get(key);
|
||||
@ -1027,6 +1066,11 @@
|
||||
action.dataset.quoteOpen = quote.id;
|
||||
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
|
||||
actionCell.appendChild(action);
|
||||
if (quote.period_end_url) {
|
||||
var periodButton = create('button', 'quote-action', '设置截止日期');
|
||||
periodButton.type = 'button'; periodButton.dataset.periodEnd = quote.id;
|
||||
actionCell.appendChild(periodButton);
|
||||
}
|
||||
var changeAction = create('a', 'quote-action', '变价');
|
||||
changeAction.href = quote.requote_url || (@json(route('business.courier-quote-changes.start')) + '?source_quote_id=' + encodeURIComponent(quote.id));
|
||||
changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿';
|
||||
@ -1038,16 +1082,29 @@
|
||||
}
|
||||
|
||||
function render() {
|
||||
var pageCount = Math.max(Math.ceil(filteredQuotes.length / pageSize), 1);
|
||||
var groups = [];
|
||||
filteredQuotes.forEach(function (quote) { var group = groups.find(function (g) { return g.key === groupKey(quote); }); if (!group) { group = {key:groupKey(quote), versions:[]};groups.push(group); } group.versions.push(quote); });
|
||||
var pageCount = Math.max(Math.ceil(groups.length / pageSize), 1);
|
||||
currentPage = Math.min(currentPage, pageCount);
|
||||
var start = (currentPage - 1) * pageSize;
|
||||
var visibleQuotes = filteredQuotes.slice(start, start + pageSize);
|
||||
var visibleGroups = groups.slice(start, start + pageSize);
|
||||
body.replaceChildren();
|
||||
visibleQuotes.forEach(function (quote) {
|
||||
body.appendChild(renderRow(quote));
|
||||
visibleGroups.forEach(function (group) {
|
||||
group.versions.sort(function(a,b){return (b.effective_date || '').localeCompare(a.effective_date || '') || b.id.localeCompare(a.id);});
|
||||
var quote = group.versions[0];
|
||||
var head = create('tr');
|
||||
var cell = create('td');cell.colSpan = 9;cell.style.background = '#F7F0DE';
|
||||
var all = versionsFor(quote), current = effectiveIn(all);
|
||||
cell.appendChild(create('strong', '', quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : '') + ' / ' + quote.quote_type));
|
||||
cell.appendChild(create('span', 'quote-table__secondary', ' · ' + all.length + ' 个时期 · ' + asOf + (current ? ' 适用 ' + current.id : ' 无已生效报价')));
|
||||
var toggle = create('button', 'quote-action', collapsedGroups.has(group.key) ? '展开历史' : '收起历史');
|
||||
toggle.type='button';toggle.dataset.quoteGroup=group.key;toggle.setAttribute('aria-expanded',collapsedGroups.has(group.key)?'false':'true');cell.appendChild(toggle);
|
||||
var add = create('a', 'quote-action', '补录另一时期');add.href=@json(route('business.courier-stock-quotes.single'))+'?courier_name='+encodeURIComponent(quote.company_name)+'&courier_expcode='+encodeURIComponent(quote.courier_expcode||'')+'"e_type='+encodeURIComponent(quote.quote_type);cell.appendChild(add);
|
||||
head.appendChild(cell);body.appendChild(head);
|
||||
if (!collapsedGroups.has(group.key)) group.versions.forEach(function (version) { body.appendChild(renderRow(version)); });
|
||||
});
|
||||
|
||||
root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本';
|
||||
root.querySelector('[data-quote-result-count]').textContent = '共 ' + groups.length + ' 组快递报价 · ' + filteredQuotes.length + ' 个版本';
|
||||
var exportLink = root.querySelector('[data-quote-export]');
|
||||
if (exportLink) {
|
||||
var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.export; })
|
||||
@ -1058,7 +1115,7 @@
|
||||
exportLink.querySelector('[data-quote-export-label]').textContent = '导出回签单' + (signedCodes.length ? '(' + signedCodes.length + ')' : '');
|
||||
}
|
||||
root.querySelector('[data-quote-page-label]').textContent = filteredQuotes.length
|
||||
? '共 ' + filteredQuotes.length + ' 条 · 共 ' + pageCount + ' 页 · 当前第 ' + currentPage + ' 页'
|
||||
? '共 ' + groups.length + ' 组 · 共 ' + pageCount + ' 页 · 当前第 ' + currentPage + ' 页'
|
||||
: '共 0 条 · 共 0 页';
|
||||
root.querySelector('[data-quote-page="previous"]').disabled = currentPage <= 1;
|
||||
root.querySelector('[data-quote-page="next"]').disabled = currentPage >= pageCount;
|
||||
@ -1071,7 +1128,7 @@
|
||||
function filterQuotes(resetPage) {
|
||||
var keyword = fields.keyword.value.trim().toLocaleLowerCase('zh-CN');
|
||||
|
||||
filteredQuotes = quotes.filter(function (quote) {
|
||||
filteredQuotes = quotes.map(atDate).filter(function (quote) {
|
||||
var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier + ' ' + (quote.courier_expcode || ''))
|
||||
.toLocaleLowerCase('zh-CN')
|
||||
.includes(keyword);
|
||||
@ -1107,7 +1164,10 @@
|
||||
}
|
||||
|
||||
function openDetail(quote, trigger) {
|
||||
lastFocusedElement = trigger;
|
||||
if (trigger) lastFocusedElement = trigger;
|
||||
var picker = root.querySelector('[data-quote-version-picker]');
|
||||
picker.replaceChildren();
|
||||
versionsFor(quote).forEach(function (version) {var option=create('option','',version.id+' · '+(version.effective_date||'未标日期')+' 至 '+(version.end_date||'长期有效'));option.value=version.id;option.selected=version.id===quote.id;picker.appendChild(option);});
|
||||
setDetail('title', quote.sheet_name);
|
||||
setDetail('id', quote.id);
|
||||
setDetail('carrier', quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : ''));
|
||||
@ -1203,6 +1263,10 @@
|
||||
});
|
||||
|
||||
body.addEventListener('click', function (event) {
|
||||
var groupButton=event.target.closest('[data-quote-group]');
|
||||
if(groupButton){var key=groupButton.dataset.quoteGroup;if(collapsedGroups.has(key))collapsedGroups.delete(key);else collapsedGroups.add(key);render();return;}
|
||||
var period=event.target.closest('[data-period-end]');
|
||||
if(period){var item=quotes.find(function(q){return q.id===period.dataset.periodEnd;});var form=root.querySelector('[data-period-form]');form.action=item.period_end_url;form.elements.end_date.value=item.end_date||'';form.elements.end_date.min=item.effective_date;form.elements.reason.value='';root.querySelector('[data-period-title]').textContent=item.company_name+' · '+item.id+' · '+item.effective_date+' 起';root.querySelector('[data-period-dialog]').showModal();return;}
|
||||
var trigger = event.target.closest('[data-quote-open]');
|
||||
if (!trigger) {
|
||||
return;
|
||||
@ -1216,6 +1280,8 @@
|
||||
});
|
||||
|
||||
root.querySelector('[data-quote-close]').addEventListener('click', closeDetail);
|
||||
root.querySelector('[data-quote-version-picker]').addEventListener('change',function(event){var quote=quotes.find(function(q){return q.id===event.target.value;});if(quote)openDetail(quote);});
|
||||
root.querySelector('[data-period-cancel]').addEventListener('click',function(){root.querySelector('[data-period-dialog]').close();});
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closeDetail();
|
||||
|
||||
@ -105,7 +105,7 @@
|
||||
</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>
|
||||
<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>
|
||||
@ -122,7 +122,7 @@
|
||||
<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 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 class="sb-field"><label>共用截止日期(可留空)<input class="sb-control" type="date" data-common-end value="{{ $previous['end_date'] ?? $defaults['end_date'] ?? '' }}"></label></div>
|
||||
</div>
|
||||
<div data-rule-groups>
|
||||
@foreach($groups as $index => $group)
|
||||
@ -160,7 +160,7 @@
|
||||
<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></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>
|
||||
@if(count($row['signed_images'] ?? []))
|
||||
<details class="sb-original"><summary>已留存 {{ count($row['signed_images']) }} 张 · 待核对</summary>
|
||||
@ -214,7 +214,13 @@
|
||||
const courierName = row => row.querySelector('[data-row-courier]').value.trim();
|
||||
const courierCode = row => courierName(row) === row.dataset.initialName ? row.dataset.initialCode : '';
|
||||
const identity = row => courierCode(row) ? 'code:' + courierCode(row) : 'name:' + courierName(row).toLowerCase();
|
||||
const isExisting = row => row.dataset.duplicate === '1' && courierName(row) === row.dataset.initialName && form.querySelector('[data-common-type]').value.trim() === @json($defaults['quote_type'] ?? '');
|
||||
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 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'});
|
||||
});
|
||||
const update = () => {
|
||||
const search = form.querySelector('[data-batch-search]').value.trim().toLowerCase();
|
||||
const onlyUnmatched = form.querySelector('[data-only-unmatched]').checked;
|
||||
@ -230,9 +236,9 @@
|
||||
const known = knownNames.has(name);
|
||||
const duplicate = isExisting(row);
|
||||
const invalid = row.dataset.invalid === '1';
|
||||
const repeated = checked && (identities.get(identity(row)) || 0) > 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 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.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;
|
||||
@ -275,12 +281,12 @@
|
||||
if (saving) return;
|
||||
const payload = {
|
||||
quote_type: form.querySelector('[data-common-type]').value,
|
||||
effective_date: commonDate.value,
|
||||
effective_date: commonDate.value, end_date: form.querySelector('[data-common-end]').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_name: courierName(row), courier_expcode: courierCode(row), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').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 }))
|
||||
};
|
||||
const errors = root.querySelector('[data-save-errors]');
|
||||
errors.hidden = true;
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
@include('business.pages.courier-payables.partials.stock-quote-courier-names')
|
||||
<div class="stock-quote__field">
|
||||
<label for="courier_name">快递名称(与发货数据一致)</label>
|
||||
<input class="stock-quote__control" id="courier_name" name="courier_name" list="stock-courier-names" data-courier-name-input value="{{ old('courier_name', $entry?->courier_name) }}" maxlength="255" required placeholder="选择或手填完整快递名称">
|
||||
<input class="stock-quote__control" id="courier_name" name="courier_name" list="stock-courier-names" data-courier-name-input value="{{ old('courier_name', $entry?->courier_name ?? request('courier_name')) }}" maxlength="255" required placeholder="选择或手填完整快递名称">
|
||||
<p class="stock-quote__hint">输入两字以上可搜索近期发货名称;找不到也可手填,不会新增或改写快递资料。请使用完整名称,不要只填品牌。</p>
|
||||
</div>
|
||||
@if($entry)
|
||||
@ -50,7 +50,7 @@
|
||||
<div class="stock-quote__grid">
|
||||
<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="例如:标准快递报价、超长件报价">
|
||||
<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>
|
||||
</div>
|
||||
@ -77,6 +77,7 @@
|
||||
<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="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>
|
||||
<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>
|
||||
|
||||
@ -139,6 +139,7 @@ Route::prefix('workspace/courier-payables/quote-management/stock-entries')
|
||||
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::post('{stockQuoteEntry}/period-end', [CourierStockQuoteEntries::class, 'periodEnd'])->name('period-end');
|
||||
Route::get('{stockQuoteEntry}/files/{file}', [CourierStockQuoteEntries::class, 'download'])->whereNumber('file')->name('download');
|
||||
});
|
||||
|
||||
|
||||
@ -62,6 +62,27 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
|
||||
$this->assertDatabaseCount('business_courier_quote_changes', 0);
|
||||
}
|
||||
|
||||
public function test_batch_accepts_same_courier_in_disjoint_periods_and_rejects_overlaps_atomically(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', 'EXP-002']);
|
||||
$this->loginAs()->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSee('data-row-end', false)->assertSee('data-common-end', false);
|
||||
$selection = $this->selection(['EXP-001', 'EXP-002']);
|
||||
$selection['entries'][1]['courier_expcode'] = 'EXP-001';
|
||||
$selection['entries'][0]['effective_date'] = '2025-12-01';
|
||||
$selection['entries'][0]['end_date'] = '2026-09-30';
|
||||
$selection['entries'][1]['effective_date'] = '2026-09-30';
|
||||
$url = route('business.courier-stock-quotes.batch-save', $batch);
|
||||
$this->withExceptionHandling()->loginAs()->postJson($url, $selection)->assertUnprocessable();
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
|
||||
$selection['entries'][1]['effective_date'] = '2026-10-01';
|
||||
$this->loginAs()->postJson($url, $selection)->assertOk();
|
||||
$entries = CourierStockQuoteEntry::orderBy('effective_date')->get();
|
||||
$this->assertCount(2, $entries);
|
||||
$this->assertSame(['EXP-001', 'EXP-001'], $entries->pluck('courier_expcode')->all());
|
||||
$this->assertSame('2026-09-30', $entries[0]->end_date->toDateString());
|
||||
$this->assertSame('2026-10-01', $entries[1]->change->effective_date->toDateString());
|
||||
}
|
||||
|
||||
public function test_bulk_save_reuses_rule_groups_overrides_dates_and_preserves_separate_prices(): void
|
||||
{
|
||||
$batch = $this->preview(['EXP-001', 'EXP-002', '说明']);
|
||||
|
||||
@ -288,6 +288,67 @@ class CourierStockQuoteEntryTest extends FeatureTestCase
|
||||
return CourierStockQuoteEntry::latest('id')->firstOrFail();
|
||||
}
|
||||
|
||||
public function test_same_courier_can_import_disjoint_periods_and_resolve_history_without_temporary_price_semantics(): void
|
||||
{
|
||||
$first = $this->createEntry(['effective_date' => '2025-12-01', 'end_date' => '2026-09-30']);
|
||||
$second = $this->createEntry(['effective_date' => '2026-10-01', 'end_date' => null]);
|
||||
$reviewer = $this->reviewer();
|
||||
foreach ([$first, $second] as $entry) {
|
||||
app(CourierQuoteChangeService::class)->submit($entry->change, $this->user->id);
|
||||
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), $reviewer->id);
|
||||
}
|
||||
$versions = CourierQuoteVersion::with('change')->get();
|
||||
$this->assertCount(2, $versions);
|
||||
$this->assertFalse($versions[0]->isTemporary());
|
||||
$this->assertSame('2025-12-01 至 2026-09-30', $versions[0]->periodLabel());
|
||||
$this->assertSame($versions[0]->id, \App\Support\CourierRequoteService::resolveAt($versions, new \DateTimeImmutable('2026-09-30'))->id);
|
||||
$this->assertSame($versions[1]->id, \App\Support\CourierRequoteService::resolveAt($versions, new \DateTimeImmutable('2026-10-01'))->id);
|
||||
$quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
|
||||
$this->assertSame($quotes[0]['group_key'], $quotes[1]['group_key']);
|
||||
$this->assertSame('已到期', $quotes[0]['status']);
|
||||
$this->assertSame('生效中', $quotes[1]['status']);
|
||||
}
|
||||
|
||||
public function test_overlapping_periods_and_shared_boundary_day_are_rejected_but_earlier_history_is_allowed(): void
|
||||
{
|
||||
$this->createEntry(['effective_date' => '2026-10-01']);
|
||||
foreach ([['2026-09-01', '2026-10-01'], ['2026-10-02', null], ['2026-11-01', '2026-10-01']] as [$start, $end]) {
|
||||
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'effective_date' => $start, 'end_date' => $end])->assertSessionHasErrors();
|
||||
}
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 1);
|
||||
$this->createEntry(['effective_date' => '2025-12-01', 'end_date' => '2026-09-30']);
|
||||
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
|
||||
}
|
||||
|
||||
public function test_explicit_period_end_preserves_originals_and_audits_change_before_next_period_import(): void
|
||||
{
|
||||
$entry = $this->createEntry(['effective_date' => '2025-12-01']);
|
||||
$reviewer = $this->reviewer();
|
||||
app(CourierQuoteChangeService::class)->submit($entry->change, $this->user->id);
|
||||
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), $reviewer->id);
|
||||
$original = $entry->values_json;
|
||||
$files = $entry->files;
|
||||
$this->loginAs($reviewer, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '依据新旧报价交接日期'])->assertRedirect();
|
||||
$this->assertSame('2026-09-30', $entry->fresh()->end_date->toDateString());
|
||||
$this->assertSame('2026-09-30', $entry->change->fresh()->version->end_date->toDateString());
|
||||
$this->assertSame($original, $entry->fresh()->values_json);
|
||||
$this->assertSame($files, $entry->fresh()->files);
|
||||
$this->assertDatabaseHas('business_courier_quote_change_events', ['quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated', 'created_by' => $reviewer->id]);
|
||||
$this->createEntry(['effective_date' => '2026-10-01']);
|
||||
$this->withExceptionHandling()->loginAs($reviewer, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-10-01', 'reason' => '重叠边界验证'])->assertSessionHasErrors('courier_name');
|
||||
$this->assertSame('2026-09-30', $entry->fresh()->end_date->toDateString());
|
||||
}
|
||||
|
||||
public function test_period_end_requires_admin_and_current_company(): void
|
||||
{
|
||||
$entry = $this->createEntry();
|
||||
$reader = $this->reviewer();
|
||||
$reader->roles()->detach();
|
||||
$this->withExceptionHandling()->loginAs($reader, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '权限检查'])->assertForbidden();
|
||||
\DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
|
||||
$this->withExceptionHandling()->loginAs($this->reviewer(), $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '公司隔离检查'])->assertNotFound();
|
||||
}
|
||||
|
||||
private function payload(): array
|
||||
{
|
||||
return ['courier_expcode' => 'EXP-001', 'quote_type' => '标准快递报价', 'source_quote' => $this->workbook(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user