feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通

快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。

- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
  链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
  复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
  不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
  财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
  点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
  按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
This commit is contained in:
limeng 2026-10-14 13:40:02 +08:00
commit c9dfeddeca
104 changed files with 10476 additions and 724 deletions

View File

@ -29,3 +29,6 @@ docs
scripts
npm-debug.log
yarn-error.log
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

3
.gitignore vendored
View File

@ -113,3 +113,6 @@ modules/*
.workbuddy/
runtime/.env
/.private/kaifa-progress-sync/
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

View File

@ -0,0 +1,314 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierRequoteService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Throwable;
/**
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
*
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
*/
final class CourierOverviewData
{
public const SOURCES = [
'erp_pending' => '待 ERP 开通',
'both' => '入驻 + 工作平台',
'onboarding' => '只有入驻',
'platform' => '只在工作平台(缺入驻资料)',
'mismatch' => '账户对不上',
];
private ?Collection $onboardingCache = null;
public function __construct(
private readonly CourierInformationMirrorRepository $mirror,
private readonly CourierRequoteService $requotes,
) {
}
/**
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
*/
public function page(array $filters, int $page, int $perPage): array
{
$all = $this->rows();
$platformReady = $all['platform_ready'];
$rows = collect($all['rows']);
$counts = [
'all' => $rows->count(),
'both' => $rows->where('source', 'both')->count(),
'platform' => $rows->where('source', 'platform')->count(),
'onboarding' => $rows->where('source', 'onboarding')->count(),
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
];
$source = (string) ($filters['source'] ?? '');
if ($source === 'mismatch') {
$rows = $rows->where('account_status', 'mismatch');
} elseif ($source === 'erp_pending') {
$rows = $rows->where('erp_status', 'pending');
} elseif (array_key_exists($source, self::SOURCES)) {
$rows = $rows->where('source', $source);
}
$keyword = trim((string) ($filters['search'] ?? ''));
if ($keyword !== '') {
$rows = $rows->filter(static fn (array $row): bool => str_contains(
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
mb_strtolower($keyword),
));
}
$rows = $rows->values();
return [
'rows' => new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$rows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
),
'counts' => $counts,
'platform_ready' => $platformReady,
];
}
/**
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
*/
public function rows(): array
{
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable $exception) {
report($exception);
$platform = null;
}
$onboardings = Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$used = [];
$rows = [];
foreach ($platform['express'] ?? [] as $express) {
$code = (string) $express->expcode;
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
if ($onboarding !== null && isset($used[$onboarding->id])) {
$onboarding = null;
}
if ($onboarding !== null) {
$used[$onboarding->id] = true;
}
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
}
foreach ($onboardings as $onboarding) {
if (! isset($used[$onboarding->id])) {
$rows[] = $this->row($onboarding, null, []);
}
}
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
return ['rows' => $rows, 'platform_ready' => $platform !== null];
}
/**
* @param list<object> $accounts
* @return array<string,mixed>
*/
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
{
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
$platformNumbers = collect($accounts)
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
->filter()
->values();
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
->filter()
->values();
$accountStatus = match (true) {
$source !== 'both' => null,
$platformNumbers->isEmpty() => 'platform_missing',
$onboardingNumbers->isEmpty() => 'onboarding_missing',
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
default => 'mismatch',
};
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
$missingFiles = array_values(array_diff($required, $kinds));
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
$erpStatus = match (true) {
$onboarding === null => 'opened',
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
default => 'pending',
};
$erpAccountId = $onboarding?->erp_account_id
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
return [
'erp_status' => $erpStatus,
'erp_account_id' => $erpAccountId ?: null,
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
'source' => $source,
'sort' => match (true) {
$erpStatus === 'pending' => 0,
$accountStatus === 'mismatch' => 1,
$source === 'onboarding' => 2,
$source === 'both' => 3,
default => 4,
},
'platform' => $express,
'platform_accounts' => $accounts,
'onboarding' => $onboarding,
'account_status' => $accountStatus,
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
'effective_version' => $effective?->version_code,
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
];
}
/**
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
*
* @param list<object> $expressRows 本页工作平台快递
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
*/
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
{
$onboardings = $this->onboardings();
if ($onboardings->isEmpty()) {
return ['matches' => [], 'extra' => []];
}
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$matches = [];
foreach ($expressRows as $express) {
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
if ($onboarding !== null) {
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
}
}
$extra = [];
if ($page === 1) {
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable) {
$platform = null;
}
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
foreach ($onboardings as $onboarding) {
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
if ($inPlatform) {
continue;
}
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
continue;
}
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
continue;
}
$extra[] = $this->onboardingInfo($onboarding, false);
}
}
return ['matches' => $matches, 'extra' => $extra];
}
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
public function infoFor(?string $code, ?string $name): ?array
{
$onboardings = $this->onboardings();
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
}
/** @return Collection<int,CourierOnboarding> */
private function onboardings(): Collection
{
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
}
/** @return array<string,mixed> */
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
{
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
return [
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
'onboarding' => $onboarding,
'erp_status' => match (true) {
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
default => 'pending',
},
'checks' => $onboarding->accountChecks(),
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
'effective_version' => $effective?->version_code,
];
}
public static function mask(?string $number): string
{
$digits = preg_replace('/\s+/', '', (string) $number);
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
}
}

View File

@ -0,0 +1,343 @@
<?php
namespace App\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use App\Models\Business\CourierProfileEvent;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 快递资料在财务系统这一侧的数据:编辑覆盖值、打款信息(入驻对公/对私 + 新增/变更记录)、操作日志。
* 工作平台镜像只读,这里的数据都存在财务系统自己的表里。
*/
final class CourierProfileData
{
/** @return array<string,array<string,mixed>> 快递代码 => 覆盖值 */
public function overrides(array $codes): array
{
$codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled')));
if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) {
return [];
}
return CourierProfile::query()->whereIn('expcode', $codes)->get()
->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides])
->all();
}
/** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */
public function apply(object $row, array $overrides): object
{
$row = clone $row;
$changed = [];
foreach ($overrides as $field => $value) {
if (array_key_exists($field, CourierProfile::EDITABLE)) {
$row->{$field} = $value;
$changed[] = $field;
}
}
$row->_overridden = $changed;
return $row;
}
/** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */
public function onboardingRow(array $info): object
{
/** @var CourierOnboarding $onboarding */
$onboarding = $info['onboarding'];
return (object) [
'Id' => null,
'expcode' => $onboarding->workplatform_expcode,
'expname' => $onboarding->courier_name,
'paymethod' => $onboarding->settlement_cycle,
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0',
'tradebtime' => $info['contract_start'] ?? null,
'tradeetime' => $info['contract_end'] ?? null,
'linker' => $onboarding->contact_name,
'linkmobile' => $onboarding->contact_phone,
'dzqzjbr' => $onboarding->esign_account,
'modified' => $onboarding->updated_at?->toDateTimeString(),
'_onboarding_only' => true,
'_info' => $info,
];
}
/** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */
public function currentRow(string $code): ?object
{
$mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
$overview = app(CourierOverviewData::class);
$row = null;
try {
if ($mirror->available() && $mirror->ready()) {
$found = $mirror->paginate(array_merge(
array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''),
['expcode' => $code],
), 1, 1)['data'][0] ?? null;
if ($found !== null) {
$row = clone $found;
$row->_info = $overview->infoFor($code, $found->expname ?? null);
}
}
} catch (Throwable $exception) {
report($exception);
}
if ($row === null && ($info = $overview->infoFor($code, null)) !== null) {
$row = $this->onboardingRow($info);
}
return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null;
}
/**
* 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。
*
* @return array<string,mixed>|null
*/
public static function agreementPrefill(mixed $id): ?array
{
if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) {
return null;
}
$record = CourierPaymentAccount::query()->find((int) $id);
if ($record === null) {
return null;
}
$onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null;
$fields = $record->action === CourierPaymentAccount::ACTION_CHANGE
? [
'account_change_date' => $record->change_date?->toDateString(),
'old_account_name' => $record->old_account_name,
'old_account_number' => $record->old_account_number,
'old_account_stop_date' => $record->old_stop_date?->toDateString(),
'new_account_effective_date' => $record->effective_date?->toDateString(),
'new_account_name' => $record->account_name,
'new_bank_name' => $record->bank_name,
'new_bank_account' => $record->account_number,
]
: [
'account_proof_effective_date' => $record->effective_date?->toDateString(),
'account_proof_account_name' => $record->account_name,
'account_proof_bank_name' => $record->bank_name,
'account_proof_bank_account' => $record->account_number,
];
if ($onboarding !== null && filled($onboarding->company_full_name)) {
$fields['counterparty_organization_name'] = $onboarding->company_full_name;
}
return [
'id' => $record->id,
'action' => $record->actionLabel(),
'courier_name' => $record->courier_name,
'expcode' => $record->expcode,
'document_type' => $record->documentType(),
'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization',
'esign_account' => $onboarding?->esign_account,
'fields' => array_filter($fields, static fn ($value): bool => filled($value)),
];
}
/** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */
public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int
{
$profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]);
$overrides = (array) ($profile->overrides ?? []);
$changes = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
if (! array_key_exists($field, $values)) {
continue;
}
$new = $this->normalize($field, $values[$field]);
$old = $this->normalize($field, $current->{$field} ?? null);
if ($new === $old) {
continue;
}
$overrides[$field] = $new;
$changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new];
}
if ($changes === []) {
return 0;
}
$profile->fill([
'courier_name' => $courierName ?: $profile->courier_name,
'overrides' => $overrides,
'updated_by' => $userId,
]);
$profile->created_by ??= $userId;
$profile->save();
$this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId);
return count($changes);
}
public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void
{
CourierProfileEvent::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'action' => $action,
'summary' => mb_substr($summary, 0, 500),
'changes' => $changes,
'created_by' => $userId,
]);
}
/** @return Collection<int,CourierProfileEvent> */
public function events(?string $expcode = null, int $limit = 100): Collection
{
if (! Schema::hasTable('business_courier_profile_events')) {
return collect();
}
return CourierProfileEvent::query()
->with('creator')
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->limit($limit)
->get();
}
/**
* 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。
*
* @return list<array<string,mixed>>
*/
public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array
{
$rows = [];
if (Schema::hasTable('business_courier_payment_accounts')) {
$records = CourierPaymentAccount::query()
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->get();
foreach ($records as $record) {
$status = $this->paymentStatus($record);
$rows[] = [
'source' => $record->actionLabel(),
'expcode' => $record->expcode,
'courier_name' => $record->courier_name,
'type' => $record->account_type,
'name' => $record->account_name,
'bank' => $record->bank_name,
'number' => $record->account_number,
'effective_date' => $record->effective_date?->toDateString(),
'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE
? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '')
: null,
'status' => $status['label'],
'tone' => $status['tone'],
'erp_account_id' => null,
'created_at' => $record->created_at?->toDateTimeString(),
'record' => $record,
];
}
}
$onboardings = $onboarding !== null
? collect([$onboarding])
: (Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode))
->latest('id')
->get()
: collect());
foreach ($onboardings as $item) {
$checks = $item->accountChecks();
foreach ([
['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null],
['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null],
] as [$type, $name, $bank, $number, $check]) {
if (! filled($name) && ! filled($number)) {
continue;
}
$rows[] = [
'source' => '快递入驻 ' . $item->number,
'expcode' => $item->workplatform_expcode,
'courier_name' => $item->courier_name,
'type' => $type,
'name' => $name,
'bank' => $bank,
'number' => $number,
'effective_date' => $item->activated_at?->toDateString(),
'old' => null,
'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(),
'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'),
'erp_account_id' => $item->erp_account_id,
'created_at' => $item->created_at?->toDateTimeString(),
'onboarding' => $item,
];
}
}
return $rows;
}
/**
* 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。
*
* @return array{label:string,tone:string,signature:?CourierAgreementSignature}
*/
public function paymentStatus(CourierPaymentAccount $record): array
{
$signature = null;
if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) {
$field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account';
$number = $this->digits($record->account_number);
$signature = CourierAgreementSignature::query()
->where('courier_name', $record->courier_name)
->where('document_type', $record->documentType())
->where('created_at', '>=', $record->created_at)
->latest('id')
->get()
->first(function (CourierAgreementSignature $signature) use ($field, $number): bool {
try {
return $this->digits((string) data_get($signature->payload, $field)) === $number;
} catch (Throwable) {
return false;
}
});
}
return match (true) {
$signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null],
in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature],
in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature],
default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature],
};
}
private function digits(?string $value): string
{
return (string) preg_replace('/[\s\-]+/u', '', (string) $value);
}
private function normalize(string $field, mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
if ($value === '' || $value === '-') {
return null;
}
if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
}
if (in_array($field, CourierProfile::DATE_FIELDS, true)) {
return substr($value, 0, 10);
}
return $value;
}
}

View File

@ -0,0 +1,273 @@
<?php
namespace App\Business;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use Throwable;
/**
* 店铺违规信息预警:预警级别、责任人、处理截止等派生字段的计算。
*
* - 级别/是否提醒:按「平台 + 违规类型」匹配违规字典;原文先去(…)括号处置说明,
* 再做 相等/前缀/关键词 匹配;匹配不到 = 不需要提醒(只入库不推送)。
* - 责任人:店铺账号镜像的运营负责人(与店铺维度报表同口径),
* 未命中时回退店铺档案(ops_fys_shop_info.shopHead)。
* - 处理截止:申诉截止日期非空→当天 23:59:59;否则按字典时效(小时)或
* 级别默认天数(P0+1 / P1+2 / P2+3 天)的当天 23:59:59。
*/
class StoreViolationAlertCalculator
{
/** @var list<array<string,mixed>>|null */
private ?array $rules = null;
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
private ?array $ownerMap = null;
/** @var array<string,?string> */
private array $ownerFallback = [];
public function __construct(
private readonly StoreViolationRuleRepository $ruleRepository,
private readonly StoreAccountMirrorRepository $storeAccounts,
) {
}
public function flush(): void
{
$this->rules = null;
$this->ownerMap = null;
$this->ownerFallback = [];
}
/** @return array<string,mixed> 预警相关字段(用于同步/导入写入) */
public function calculate(
string $platform,
?string $shopName,
?string $violationType,
?string $violationDate,
?string $appealDeadline,
): array {
$rule = self::matchRule($this->rules(), $platform, $violationType);
$needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true);
$level = null;
if ($needsAlert) {
$level = trim((string) ($rule['alert_level'] ?? ''));
if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) {
$level = 'P2';
}
}
$hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null
? (int) $rule['handling_hours']
: null;
return [
'needs_alert' => $needsAlert,
'alert_level' => $level,
'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null,
'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null,
'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null,
'handling_hours' => $needsAlert ? $hours : null,
'owner_name' => $this->ownerFor($shopName),
'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level),
];
}
/** @return list<array<string,mixed>> */
private function rules(): array
{
if ($this->rules === null) {
try {
$this->rules = $this->ruleRepository->activeRules();
} catch (Throwable) {
$this->rules = [];
}
}
return $this->rules;
}
/**
* 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。
*/
public static function cleanViolationType(?string $text): string
{
$text = trim((string) $text);
if ($text === '') {
return '';
}
$previous = null;
while ($previous !== $text) {
$previous = $text;
$text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text;
}
return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text;
}
private static function loose(string $text): string
{
return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text;
}
/**
* 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。
*
* @param list<array<string,mixed>> $rules 已按优先级排序
* @return array<string,mixed>|null
*/
public static function matchRule(array $rules, string $platform, ?string $violationType): ?array
{
$record = self::cleanViolationType($violationType);
if ($record === '') {
return null;
}
$looseRecord = self::loose($record);
foreach ($rules as $rule) {
if (trim((string) ($rule['platform'] ?? '')) !== $platform) {
continue;
}
$ruleType = self::cleanViolationType($rule['violation_type'] ?? null);
if ($ruleType !== '') {
if ($record === $ruleType || str_starts_with($record, $ruleType)) {
return $rule;
}
foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) {
$part = trim((string) $part);
if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) {
return $rule;
}
}
$looseRule = self::loose($ruleType);
if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) {
return $rule;
}
}
foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) {
$keyword = self::loose(self::cleanViolationType($keyword));
if ($keyword !== '' && str_contains($looseRecord, $keyword)) {
return $rule;
}
}
}
return null;
}
/**
* 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。
*/
public static function deadlineFor(
?string $violationDate,
?string $appealDeadline,
?int $handlingHours,
?string $alertLevel,
): ?string {
$appeal = trim((string) $appealDeadline);
if ($appeal !== '') {
try {
return Carbon::parse($appeal)->endOfDay()->toDateTimeString();
} catch (Throwable) {
// 解析失败时退回按级别计算
}
}
$date = trim((string) $violationDate);
if ($date === '') {
return null;
}
try {
$base = Carbon::parse($date)->startOfDay();
} catch (Throwable) {
return null;
}
if ($handlingHours !== null && $handlingHours > 0) {
return $base->addHours($handlingHours)->toDateTimeString();
}
$level = trim((string) $alertLevel);
if ($level === '') {
return null;
}
$days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0);
return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null;
}
/** 是否超时:处理状态不在终态且当前时间已过处理截止。 */
public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool
{
$status = trim((string) $handlingStatus);
if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) {
return false;
}
$deadline = trim((string) $handlingDeadline);
if ($deadline === '') {
return false;
}
try {
return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline));
} catch (Throwable) {
return false;
}
}
private function ownerFor(?string $shopName): ?string
{
$name = trim((string) $shopName);
if ($name === '') {
return null;
}
try {
if ($this->ownerMap === null) {
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
}
$owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? ''));
if ($owner !== '') {
return mb_substr($owner, 0, 64);
}
} catch (Throwable) {
$this->ownerMap ??= [];
}
// 兜底:店铺档案(经营分析副本库)的店铺运营负责人。
if (array_key_exists($name, $this->ownerFallback)) {
return $this->ownerFallback[$name];
}
$head = null;
try {
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_fys_shop_info')
->where('shopName', $name)
->orderByDesc('id')
->first(['shopHead']);
$head = self::nullableText($row->shopHead ?? null, 64);
} catch (Throwable) {
$head = null;
}
return $this->ownerFallback[$name] = $head;
}
private static function nullableText(mixed $value, int $limit): ?string
{
$text = trim((string) $value);
return $text === '' ? null : mb_substr($text, 0, $limit);
}
}

View File

@ -72,6 +72,9 @@ class StoreViolationMapper
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
'violation_category' => $this->categoryForContent($content),
// 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。
'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500),
'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500),
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
'status' => $this->normalizeLabel($row['status'] ?? null, 64),

View File

@ -0,0 +1,237 @@
<?php
namespace App\Business;
use App\Models\Common\Company;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
use RuntimeException;
use Throwable;
/**
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
*
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
* - P2 不即时推,每日 10:00 汇总成一条日报;
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
*/
class StoreViolationNotifier
{
public function __construct(private readonly StoreViolationPushRepository $pushes)
{
}
public function enabled(): bool
{
$config = (array) config('store_violations.push', []);
return (bool) ($config['enabled'] ?? false)
&& trim((string) ($config['webhook'] ?? '')) !== '';
}
/**
* @return array{attempted:int,succeeded:int,failed:int}
*/
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
{
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
$candidates = $this->pushes->instantCandidates($levels, $retryFailed, $limit);
$summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0];
$ownerContacts = (array) config('store_violations.push.owner_contacts', []);
foreach ($candidates as $record) {
$owner = trim((string) ($record->owner_name ?? ''));
$atMobiles = [];
if ($owner !== '' && ! empty($ownerContacts[$owner])) {
$atMobiles[] = (string) $ownerContacts[$owner];
}
[$title, $text] = $this->instantMessage($record);
$pushNo = $this->pushNo();
$now = Carbon::now();
try {
$this->send($title, $text, $atMobiles);
$this->pushes->markPushed([(int) $record->id], $now);
$summary['succeeded']++;
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'success',
'error' => null,
]);
} catch (Throwable $exception) {
$summary['failed']++;
$this->pushes->markPushFailed((int) $record->id, $now);
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
}
}
return $summary;
}
/**
* @return array{pending:array<string,int>,overdue:int,p2:int,sent:bool}
*/
public function pushDigest(): array
{
$data = $this->pushes->digestData();
$pending = $data['pending'];
$overdue = $data['overdue'];
$p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$lines = [
'### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')',
'- 待处理:P0 ' . (int) ($pending['P0'] ?? 0)
. ' 条 / P1 ' . (int) ($pending['P1'] ?? 0)
. ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条',
'- 已超时未认领:' . $overdue->count() . ' 条',
];
foreach ($overdue as $index => $record) {
$lines[] = sprintf(
' %d. [%s] %s(责任人:%s) · 截止 %s',
$index + 1,
(string) ($record->alert_level ?? ''),
(string) ($record->shop_name ?? ''),
trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配',
(string) ($record->handling_deadline ?? ''),
);
}
$now = Carbon::now();
try {
$this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []);
} catch (Throwable $exception) {
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false];
}
// 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。
$this->pushes->markPushed($p2Ids, $now);
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'success',
'error' => null,
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true];
}
/** @return array{0:string,1:string} */
private function instantMessage(object $record): array
{
$level = (string) ($record->alert_level ?? '');
$levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level);
$platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? ''));
$title = '店铺违规预警(' . $levelLabel . ')';
$lines = [
'### 【' . $levelLabel . '】店铺违规预警',
'- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''),
'- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200),
'- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'),
'- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'),
'- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'),
'- 处理截止:' . (string) ($record->handling_deadline ?? '—'),
];
$claimUrl = $this->claimUrl((int) $record->id);
if ($claimUrl !== null) {
$lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)';
}
return [$title, implode("\n", $lines)];
}
private function claimUrl(int $recordId): ?string
{
try {
$companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1);
return URL::signedRoute('business.store-violation-alert.claim', [
'record' => $recordId,
'company_id' => $companyId,
]);
} catch (Throwable) {
return null;
}
}
/** @param list<string> $atMobiles */
private function send(string $title, string $text, array $atMobiles): void
{
$config = (array) config('store_violations.push', []);
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw new RuntimeException('推送 Webhook 未配置。');
}
$url = $webhook;
$secret = trim((string) ($config['secret'] ?? ''));
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(10)->post($url, [
'msgtype' => 'markdown',
'markdown' => ['title' => $title, 'text' => $text],
'at' => [
'atMobiles' => array_values($atMobiles),
'isAtAll' => (bool) ($config['at_all'] ?? false),
],
]);
if (! $response->successful()) {
throw new RuntimeException('推送接口返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? ''));
}
}
private function pushNo(): string
{
return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
}
}

View File

@ -19,6 +19,7 @@ class StoreViolationWorkbookImporter
public function __construct(
private readonly StoreViolationMapper $mapper,
private readonly StoreViolationRecordRepository $repository,
private readonly StoreViolationAlertCalculator $alerts,
) {
}
@ -139,6 +140,11 @@ class StoreViolationWorkbookImporter
}
$updateColumns[] = 'source';
$updateColumns[] = 'updated_at';
// 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
$payload = [];
$created = 0;
@ -155,6 +161,17 @@ class StoreViolationWorkbookImporter
continue;
}
$matchType = trim((string) ($data['violation_type'] ?? '')) !== ''
? (string) $data['violation_type']
: ($data['violation_reason'] ?? null);
$data = array_merge($data, $this->alerts->calculate(
$platform,
(string) ($data['shop_name'] ?? ''),
$matchType,
$data['violation_date'] ?? null,
$data['appeal_deadline'] ?? null,
));
foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) {
if (! array_key_exists($column, $data)) {
$data[$column] = null;
@ -305,13 +322,25 @@ class StoreViolationWorkbookImporter
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
}
$reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null;
if ($has('violation_reason')) {
$data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500);
}
if ($has('disposition')) {
$disposition = trim((string) ($raw['disposition'] ?? ''));
$data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500);
}
if ($has('violation_category')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$category = trim((string) ($raw['violation_category'] ?? ''));
$data['violation_category'] = $category === ''
? $this->mapper->categoryForContent($type)
? $this->mapper->categoryForContent($categorySource)
: mb_substr($category, 0, 64);
} elseif ($has('violation_type')) {
$data['violation_category'] = $this->mapper->categoryForContent($type);
} elseif ($has('violation_type') || $has('violation_reason')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$data['violation_category'] = $this->mapper->categoryForContent($categorySource);
}
if ($has('violation_date')) {
@ -357,6 +386,8 @@ class StoreViolationWorkbookImporter
if ($has('handling_status')) {
$handling = trim((string) ($raw['handling_status'] ?? ''));
$legacyMap = (array) config('store_violations.handling_status_legacy', []);
$handling = (string) ($legacyMap[$handling] ?? $handling);
$options = (array) config('store_violations.handling_statuses', []);
if ($handling === '') {
$data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
@ -368,6 +399,15 @@ class StoreViolationWorkbookImporter
}
}
if ($has('ignore_reason')) {
$ignoreReason = trim((string) ($raw['ignore_reason'] ?? ''));
$data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191);
}
if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') {
$errors[] = '处理状态为“已忽略”时必须填写忽略原因';
}
return [
'row_number' => $rowNumber,
'status' => $errors === [] ? 'valid' : 'invalid',

View File

@ -0,0 +1,187 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 店铺违规信息预警:违规字典维护(Excel 导入 / 更新,按 平台+违规类型 幂等)。
*
* 表头(顺序不限):平台、违规分类、违规类型、匹配关键词、预警级别、是否需要提醒、
* 建议处理动作、处理时效、提醒方式、排序、启用、备注。
* 导入后按需执行 store-violations:recalc-alerts 让存量记录套用新字典。
*/
class ImportStoreViolationRules extends Command
{
protected $signature = 'store-violations:import-rules
{file : xlsx / xls / csv 文件路径}
{--dry-run : 只解析并统计,不写库}';
protected $description = '导入违规字典(预警级别、提醒规则等)';
private const HEADERS = [
'platform' => ['平台', 'platform'],
'violation_category' => ['违规分类', '分类', 'violation_category'],
'violation_type' => ['违规类型', '类型', 'violation_type'],
'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'],
'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'],
'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'],
'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'],
'handling_hours' => ['处理时效', '时效', 'handling_hours'],
'remind_channel' => ['提醒方式', 'remind_channel'],
'sort' => ['排序', 'sort'],
'enabled' => ['启用', 'enabled'],
'remark' => ['备注', 'remark'],
];
private const PLATFORM_MAP = [
'淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao',
'京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin',
'拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba',
];
public function handle(): int
{
$file = (string) $this->argument('file');
if (! is_file($file)) {
$this->error('文件不存在:' . $file);
return self::FAILURE;
}
try {
$reader = IOFactory::createReaderForFile($file);
if (method_exists($reader, 'setReadDataOnly')) {
$reader->setReadDataOnly(true);
}
if (method_exists($reader, 'setReadFilter')) {
// 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。
$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
public function readCell($columnAddress, $row, $worksheetName = ''): bool
{
return $row <= 5000;
}
});
}
$matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false);
} catch (Throwable $exception) {
$this->error('无法读取文件:' . $exception->getMessage());
return self::FAILURE;
}
if ($matrix === []) {
$this->error('文件为空。');
return self::FAILURE;
}
$map = [];
foreach ($matrix[0] as $index => $cell) {
$label = mb_strtolower(trim((string) $cell));
foreach (self::HEADERS as $key => $aliases) {
foreach ($aliases as $alias) {
if ($label === mb_strtolower($alias)) {
$map[$key] = $index;
break 2;
}
}
}
}
if (! isset($map['platform'], $map['violation_type'])) {
$this->error('缺少必需表头:平台、违规类型。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! app(StoreViolationRuleRepository::class)->available()) {
$this->error('违规字典表尚未建立,请先执行数据库迁移。');
return self::FAILURE;
}
$created = 0;
$updated = 0;
$skipped = 0;
$sort = 0;
foreach (array_slice($matrix, 1) as $row) {
$value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : '';
$platform = $value('platform');
$platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform);
$type = $value('violation_type');
if ($platform === '' || $type === '') {
$skipped++;
continue;
}
$sort += 10;
$levelRaw = strtoupper($value('alert_level'));
$level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null;
$needsAlertRaw = mb_strtolower($value('needs_alert'));
$needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true);
$hoursRaw = $value('handling_hours');
$hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null;
$payload = [
'platform' => $platform,
'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null,
'violation_type' => mb_substr($type, 0, 500),
'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null,
'alert_level' => $needsAlert ? $level : null,
'needs_alert' => $needsAlert ? 1 : 0,
'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null,
'handling_hours' => $hours,
'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null,
'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort,
'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0,
'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null,
'updated_at' => now(),
];
$exists = $connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->exists();
if ($this->option('dry-run')) {
$exists ? $updated++ : $created++;
continue;
}
if ($exists) {
$connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->update($payload);
$updated++;
} else {
$connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [
'created_at' => now(),
]);
$created++;
}
}
$this->info(sprintf(
'字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。',
$this->option('dry-run') ? '(试运行,未写库)' : '完成',
$created,
$updated,
$skipped,
));
$this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P0/P1 即时推送(每天 09:00 起由生产 cron 每 5 分钟触发)。
*/
class PushStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:push-instant
{--limit=200 : 单轮最多推送条数}
{--retry-failed : 同时重试推送失败的记录}';
protected $description = '推送店铺违规预警即时消息(P0/P1,含责任人@与去认领链接)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushInstant(
max((int) $this->option('limit'), 1),
(bool) $this->option('retry-failed'),
);
$this->info(sprintf(
'即时推送:待推 %d 条,成功 %d 条,失败 %d 条。',
$summary['attempted'],
$summary['succeeded'],
$summary['failed'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P2 每日 10:00 汇总日报。
*/
class PushStoreViolationDigest extends Command
{
protected $signature = 'store-violations:push-digest';
protected $description = '推送店铺违规预警每日汇总(P2 汇总与超时未认领点名)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushDigest();
$this->info(sprintf(
'日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。',
$summary['sent'] ? '已发送' : '发送失败',
(int) ($summary['pending']['P0'] ?? 0),
(int) ($summary['pending']['P1'] ?? 0),
(int) ($summary['pending']['P2'] ?? 0),
(int) $summary['overdue'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
/**
* 店铺违规信息预警:按当前字典/归属重新计算全部记录的预警派生字段。
*
* 用于字典维护或店铺归属调整后的校准;不触碰处理状态、忽略原因与推送状态。
*/
class RecalcStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:recalc-alerts
{--platform= : 只重算指定平台}
{--chunk=500 : 每批更新行数}';
protected $description = '重算店铺违规记录的预警级别、责任人与处理截止';
public function handle(StoreViolationAlertCalculator $calculator, StoreViolationRecordRepository $repository): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$chunk = max(min((int) $this->option('chunk'), 2000), 100);
$platform = trim((string) $this->option('platform'));
$total = 0;
$query = $connection->table(StoreViolationRecordRepository::TABLE)
->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline'])
->orderBy('id');
if ($platform !== '') {
$query->where('platform', $platform);
}
$query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void {
$updates = [];
foreach ($rows as $row) {
$matchType = trim((string) ($row->violation_type ?? '')) !== ''
? (string) $row->violation_type
: ($row->violation_reason ?? null);
// upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式;
// 命中已有记录时只会更新派生字段(update 列表)。
$updates[] = [
'id' => $row->id,
'platform' => (string) $row->platform,
'violation_no' => (string) ($row->violation_no ?? ''),
] + $calculator->calculate(
(string) $row->platform,
$row->shop_name,
$matchType,
$row->violation_date ? (string) $row->violation_date : null,
$row->appeal_deadline ? (string) $row->appeal_deadline : null,
);
}
if ($updates !== []) {
$connection->table(StoreViolationRecordRepository::TABLE)->upsert(
$updates,
['id'],
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
);
$total += count($updates);
}
});
$this->info(sprintf('预警重算完成:%d 行。', $total));
return self::SUCCESS;
}
}

View File

@ -162,6 +162,17 @@ class SyncStoreReceiptOrderMatches extends Command
/** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */
private const REMARK_PROMO_ONLY = '无收款账单';
/**
* 1688 采销专用账户(按账单店铺名识别):
* 这个账户收的是采销/供货结算款,不是店铺销售单收款,订单侧本来就没有对应的销售单,
* 所以未匹配原因单独成档,避免和真实丢单混在「订单表无此单」里。
*/
private const CAIXIAO_STORE_KEYWORD = '采销专用';
private const REMARK_EMPTY_NO_CAIXIAO = '账单无单号(采销收款)';
private const REMARK_NO_ORDER_CAIXIAO = '1688采销收款(非销售单)';
public function handle(): int
{
// 全量重建时账单与订单侧映射都在内存里:拼多多 / 淘系支付宝这类大平台需要 1G 以上,
@ -536,6 +547,8 @@ class SyncStoreReceiptOrderMatches extends Command
// 子单的平台子订单号与主单不同时,按「子订单号 + 条码」的去重键拦不住,会把同一批
// 商品重复累加。整单没有任何主单行时退化为取全部行,避免把真实金额抹掉——与实付
// 「主单优先,无主单退化」保持同一套规则。
// 合并发货行(merge_so_id 非空)会把组内其它订单的金额一起挂在本单号上:
// 当单号下存在「非合并行」时,商品与实付都只认非合并行,避免把合并组合计当成单笔金额。
$mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_main_nos');
$mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_main_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB');
$mirror->statement(
@ -545,6 +558,15 @@ class SyncStoreReceiptOrderMatches extends Command
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
WHERE o.link_o_id IS NULL OR o.link_o_id = 0"
);
$mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_plain_nos');
$mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_plain_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB');
$mirror->statement(
"INSERT IGNORE INTO tmp_receipt_plain_nos (no)
SELECT DISTINCT o.so_id as no
FROM tmp_receipt_order_nos t
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
WHERE COALESCE(o.merge_so_id, '') = ''"
);
// 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含
// 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。
@ -561,22 +583,26 @@ class SyncStoreReceiptOrderMatches extends Command
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id
LEFT JOIN tmp_receipt_main_nos m ON m.no = o.so_id
LEFT JOIN tmp_receipt_plain_nos p ON p.no = o.so_id
WHERE (o.link_o_id IS NULL OR o.link_o_id = 0)
OR m.no IS NULL
OR (m.no IS NULL AND (COALESCE(o.merge_so_id, '') = '' OR p.no IS NULL))
GROUP BY o.so_id, o.shop_name, dedup_key
) d
GROUP BY so_id, shop_name"
);
// 订单实付:取订单级字段(明细行上的实付会重复);拆单取主单(link_o_id 为空),
// 没有主单时退化为取该单号下的最大值。
// 其次取「非合并行」(merge_so_id 为空——合并发货行带的是组内合计金额,不能算单笔),
// 都没有时退化为取该单号下的最大值。
$paid = $mirror->select(
"SELECT so_id, shop_name,
COALESCE(
MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid END),
MAX(CASE WHEN COALESCE(merge_so_id, '') = '' THEN paid END),
MAX(paid)
) as paid
FROM (
SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.{$paidColumn} as paid
SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id,
COALESCE(o.merge_so_id, '') as merge_so_id, o.{$paidColumn} as paid
FROM tmp_receipt_order_nos t
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
) x
@ -691,6 +717,8 @@ class SyncStoreReceiptOrderMatches extends Command
$status = $basis === 'product' ? $productStatus : $paidStatus;
$remark = $basis === 'product' ? $productRemark : $paidRemark;
// 1688 采销专用账户的未匹配原因单独成档(采销收款不是店铺销售单)。
$remark = self::caixiaoRemark((string) $group['store_name'], $remark);
$basisAmount = $basis === 'product' ? $productAmount : $paidAmount;
if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') {
@ -1081,7 +1109,9 @@ class SyncStoreReceiptOrderMatches extends Command
'paid_amount' => null,
'difference' => null,
'match_status' => self::STATUS_UNMATCHED,
'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY,
'remark' => $core === ''
? self::caixiaoRemark((string) $group['store_name'], self::REMARK_EMPTY_NO)
: self::REMARK_PROMO_ONLY,
'imported_at' => $now,
];
}
@ -1197,6 +1227,30 @@ class SyncStoreReceiptOrderMatches extends Command
return strtoupper($value);
}
/**
* 是否 1688 采销专用账户(按账单店铺名判断)。
*/
private static function isCaixiaoStore(string $storeName): bool
{
return str_contains(trim($storeName), self::CAIXIAO_STORE_KEYWORD);
}
/**
* 1688 采销专用账户的未匹配原因单独成档;其余店铺的标注原样返回。
*/
private static function caixiaoRemark(string $storeName, string $remark): string
{
if (! self::isCaixiaoStore($storeName)) {
return $remark;
}
return match ($remark) {
self::REMARK_EMPTY_NO => self::REMARK_EMPTY_NO_CAIXIAO,
self::REMARK_NO_ORDER => self::REMARK_NO_ORDER_CAIXIAO,
default => $remark,
};
}
/**
* 账单行上的业务备注:科目映射表的「业务备注关键词」用它匹配。
*

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use App\Business\StoreViolationMapper;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
@ -23,7 +24,11 @@ class SyncStoreViolations extends Command
protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)';
public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int
public function handle(
StoreViolationMapper $mapper,
StoreViolationRecordRepository $repository,
StoreViolationAlertCalculator $calculator,
): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。');
@ -68,11 +73,32 @@ class SyncStoreViolations extends Command
$skipped = 0;
$read = 0;
// 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列,
// 避免每日同步把导入补充的数据清空。
$extraColumns = $this->availableExtraColumns($pdo);
$updateColumns = $extraColumns === []
? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS
: array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns));
// 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
try {
while (true) {
$sql = 'SELECT TOP ' . $batchSize
. ' id, shop_name, violation_no, violation_date, deadline, violation_content,'
. ' status, window_name, platform, crawl_date'
// FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"),
// 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。
$selectedColumns = ' id, shop_name, violation_no,'
. ' CONVERT(varchar(10), violation_date, 23) AS violation_date,'
. ' CONVERT(varchar(10), deadline, 23) AS deadline,'
. ' violation_content, status, window_name, platform,'
. ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date';
foreach ($extraColumns as $target => $sourceColumn) {
$selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target;
}
$sql = 'SELECT TOP ' . $batchSize . $selectedColumns
. ' FROM rpt.crawl_shop_violation WHERE id > ?';
$params = [$lastId];
@ -102,6 +128,14 @@ class SyncStoreViolations extends Command
continue;
}
$mapped = array_merge($mapped, $calculator->calculate(
(string) $mapped['platform'],
$mapped['shop_name'],
$mapped['violation_type'],
$mapped['violation_date'],
$mapped['appeal_deadline'],
));
$payload[] = $mapped + [
'synced_at' => $now,
'created_at' => $now,
@ -109,7 +143,7 @@ class SyncStoreViolations extends Command
];
}
$upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS);
$upserted += $repository->upsertRows($payload, $updateColumns);
}
} catch (Throwable $exception) {
report($exception);
@ -132,4 +166,47 @@ class SyncStoreViolations extends Command
return self::SUCCESS;
}
/** @return array<string,string> 统一表字段 => 上游视图列名 */
private function availableExtraColumns(PDO $pdo): array
{
try {
$statement = $pdo->query(
"SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')"
);
$names = $statement->fetchAll(PDO::FETCH_COLUMN);
$statement->closeCursor();
} catch (Throwable) {
return [];
}
return self::pickExtraColumns(array_map('strval', $names));
}
/**
* 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。
*
* @param list<string> $availableColumns
* @return array<string,string>
*/
public static function pickExtraColumns(array $availableColumns): array
{
$candidates = [
'violation_reason' => ['violation_reason', 'reason'],
'disposition' => ['disposition', 'handling_method', 'disposal'],
];
$available = array_map(static fn (string $name): string => strtolower($name), $availableColumns);
$selected = [];
foreach ($candidates as $target => $names) {
foreach ($names as $name) {
if (in_array($name, $available, true)) {
$selected[$target] = $name;
break;
}
}
}
return $selected;
}
}

View File

@ -65,6 +65,17 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
// 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。
$schedule->command('store-violations:push-instant')
->cron('*/5 9-21 * * *')
->withoutOverlapping(5)
->runInBackground();
$schedule->command('store-violations:push-digest')
->dailyAt('10:00')
->withoutOverlapping(30)
->runInBackground();
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
->dailyAt('00:00')
->withoutOverlapping(180)

View File

@ -100,6 +100,12 @@ class Handler extends ExceptionHandler
*/
public function render($request, Throwable $exception)
{
// 快递扫码填报页:手机原图超过上传上限时,回到填报页给出能看懂的提示,而不是报错页。
if ($exception instanceof \Illuminate\Http\Exceptions\PostTooLargeException
&& $request->is('onboarding/courier/*')) {
return redirect()->to($request->url() . '?too_large=1');
}
if (request_is_api($request)) {
return $this->handleApiExceptions($request, $exception);
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierOnboardingService;
use App\Support\QrCode;
@ -12,6 +13,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -25,15 +27,31 @@ class CourierOnboardings extends Controller
{
$this->ensureReady();
$data = $request->validate([
'courier_name' => ['required', 'string', 'max:100'],
'invite_label' => ['nullable', 'string', 'max:100'],
'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'],
'workplatform_expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/'],
], [
'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。',
'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。',
'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。',
]);
$code = strtoupper(trim((string) $data['workplatform_expcode']));
$taken = CourierOnboarding::query()
->where('workplatform_expcode', $code)
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
->exists();
if ($taken) {
throw ValidationException::withMessages(['workplatform_expcode' => "快递公司代码「{$code}」已被其他入驻申请使用。"]);
}
$onboarding = $this->service->invite(
$data['invite_label'] ?? null,
(int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS),
(int) user_id(),
$data['courier_name'],
);
$onboarding->update(['workplatform_expcode' => $code]);
return redirect()->route('business.courier-onboardings.show', $onboarding)
->with('success', '入驻二维码已生成,发给快递方扫码填写即可。');
@ -42,18 +60,92 @@ class CourierOnboardings extends Controller
public function show(CourierOnboarding $onboarding): View
{
$this->ensureReady();
$onboarding->load(['files', 'events.actor', 'quoteVersion']);
try {
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
return view('business.pages.courier-payables.courier-onboarding-show', [
'onboarding' => $onboarding,
'formUrl' => $this->formUrl($onboarding),
'signatures' => $this->service->agreementSignatures($onboarding),
'signedAgreement' => $this->service->signedAgreement($onboarding),
'canFinalReview' => $this->isAdmin() && (int) $onboarding->first_reviewed_by !== (int) user_id(),
'quoteSignature' => $onboarding->quoteSignature(),
'esignReady' => $this->service->esignReady(),
'canFinalReview' => $this->isAdmin() && (
(int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer')
),
'isAdmin' => $this->isAdmin(),
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
]);
}
/** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */
public function requote(CourierOnboarding $onboarding): View|RedirectResponse
{
$this->ensureReady();
$quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
return redirect()->to($quoteManagement)->with('success', '这家快递还没开通,开通后才能变价。');
}
$requotes = $this->requotes();
$open = $requotes->openChange($onboarding);
if ($open !== null && $open->status === \App\Models\Business\CourierQuoteChange::STATUS_PENDING_REVIEW) {
return redirect()->route('business.courier-quote-changes.show', $open)
->with('success', "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。");
}
$version = $requotes->latestVersion($onboarding);
$payload = $open?->quote_payload ?? $requotes->currentPayload($onboarding);
if ($open === null) {
// 新变价默认从明天起执行。
$payload['quote_effective_date'] = today()->addDay()->toDateString();
}
return view('onboarding.courier-form', [
'onboarding' => $requotes->draftModel($onboarding, $payload),
'token' => '',
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
'requote' => [
'action' => route('business.courier-onboardings.requote.store', $onboarding),
'back' => $quoteManagement,
'courier' => $onboarding->courier_name,
'signer' => trim(($onboarding->isPerson() ? '' : $onboarding->company_full_name . ' · ') . $onboarding->esign_operator_name . ' ' . $onboarding->esign_account),
'version' => $version,
'change' => $open,
'summary' => old('summary', $open?->summary),
],
]);
}
public function storeRequote(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureReady();
$data = $request->validate([
...\App\Http\Controllers\Onboarding\CourierOnboardingForms::quoteRules(),
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'quote_end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:quote_effective_date'],
], [
'summary.required' => '请写一下这次变价的原因或依据,审核和对账时要看。',
'quote_end_date.after_or_equal' => '结束日期不能早于执行日期。',
]);
$change = $this->requotes()->submit($onboarding, $data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', "变价单 {$change->number} 已提交审核。审核通过后会自动推送到快递的 e签宝签署。");
}
private function requotes(): \App\Support\CourierRequoteService
{
return app(\App\Support\CourierRequoteService::class);
}
public function qr(Request $request, CourierOnboarding $onboarding): Response
{
$this->ensureReady();
@ -126,17 +218,84 @@ class CourierOnboardings extends Controller
return $this->back($onboarding, '内部信息已保存。');
}
/**
* 手动补录 ERP 开通信息(钉钉流程同步不到、或名称对不上时用)。
* 快递资料按快递代码把入驻和工作平台老数据合成一行。
*/
public function erpOpening(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'workplatform_expcode' => ['nullable', 'string', 'max:64'],
'erp_account_id' => ['nullable', 'string', 'max:64'],
]);
// 快递公司代码在发码时已必填;这里留空表示沿用原代码。填了代码或 ERP账户ID 视为已开通,都空则恢复「待 ERP 开通」。
$code = filled($data['workplatform_expcode'] ?? null) ? strtoupper(trim((string) $data['workplatform_expcode'])) : null;
$accountId = filled($data['erp_account_id'] ?? null) ? trim((string) $data['erp_account_id']) : null;
$opened = $code !== null || $accountId !== null;
$onboarding->update([
'workplatform_expcode' => $code ?? $onboarding->workplatform_expcode,
'erp_account_id' => $accountId,
'erp_opened_at' => $opened ? ($onboarding->erp_opened_at ?? now()) : null,
'erp_opened_source' => $opened ? 'manual' : null,
'updated_by' => (int) user_id(),
]);
return $this->back($onboarding, $opened ? 'ERP 开通信息已保存(快递公司代码 ' . $onboarding->workplatform_expcode . ')。' : '已清空 ERP 开通信息,恢复为「待 ERP 开通」。');
}
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$data = $request->validate([
'courier_name' => ['required', 'string', 'max:100'],
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
'note' => ['nullable', 'string', 'max:1000'],
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
$this->service->finalApprove($onboarding, $data, (int) user_id());
return $this->back($onboarding, '财务复审通过。下一步:到协议签署页发起合作协议,签署完成后回来确认开通。');
if (! $onboarding->quote_version_id) {
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');
}
if (! $this->service->esignReady()) {
return $this->back($onboarding, '财务复审通过,首版报价已生成。E 签宝尚未配置,报价确认单暂不能推送;线下签署后可直接开通。');
}
try {
$this->service->pushQuote($onboarding->fresh(), (int) user_id());
} catch (ValidationException $exception) {
$message = collect($exception->errors())->flatten()->first() ?: '推送失败';
CourierOnboardingEvent::create([
'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => 'quote_push_failed',
'from_status' => $onboarding->status, 'to_status' => $onboarding->status, 'note' => $message,
'actor_type' => 'user', 'actor_id' => (int) user_id(),
]);
return $this->back($onboarding, '财务复审通过,首版报价已生成,但报价确认单推送失败:' . $message . '。可在右侧重新推送。');
}
return $this->back($onboarding, '财务复审通过,报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function pushQuote(CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$this->service->pushQuote($onboarding, (int) user_id());
return $this->back($onboarding, '报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function syncQuote(CourierOnboarding $onboarding): RedirectResponse
{
$signature = $this->service->syncQuote($onboarding, (int) user_id());
return $this->back($onboarding, '已同步 e签宝状态:' . $signature->statusLabel() . '。');
}
public function signUrl(CourierOnboarding $onboarding): RedirectResponse
{
$url = $this->service->quoteSignUrl($onboarding);
return $this->back($onboarding, '已获取签署链接,签署人用手机打开或扫码即可签署。')->with('quote_sign_url', $url);
}
public function activate(Request $request, CourierOnboarding $onboarding): RedirectResponse
@ -150,7 +309,12 @@ class CourierOnboardings extends Controller
private function formUrl(CourierOnboarding $onboarding): string
{
return route('courier-onboarding.form', ['token' => $onboarding->token]);
$base = trim((string) config('courier_onboarding.public_url', ''));
if ($base === '') {
return route('courier-onboarding.form', ['token' => $onboarding->token]);
}
return rtrim($base, '/') . route('courier-onboarding.form', ['token' => $onboarding->token], false);
}
private function back(CourierOnboarding $onboarding, string $message): RedirectResponse

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Business;
use App\Abstracts\Http\Controller;
use App\Business\CourierProfileData;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* 快递资料在财务系统的维护:编辑主信息(存覆盖值,不回写工作平台),打款信息新增/变更(保存后跳协议签署推送)。
*/
class CourierProfiles extends Controller
{
public function __construct(private readonly CourierProfileData $profiles)
{
}
public function update(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
abort_if($row === null, 404);
$rules = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
$rules[$field] = match (true) {
in_array($field, CourierProfile::BOOLEAN_FIELDS, true) => ['nullable', 'in:0,1'],
in_array($field, CourierProfile::DATE_FIELDS, true) => ['nullable', 'date'],
$field === 'expname' => ['required', 'string', 'max:100'],
default => ['nullable', 'string', 'max:255'],
};
}
$data = $request->validate($rules, ['expname.required' => '快递公司名称不能为空。'], CourierProfile::EDITABLE);
$count = $this->profiles->update($expcode, $row->expname ?? null, $row, $data, (int) user_id());
return redirect($this->pageUrl($expcode, 'logs'))
->with('success', $count > 0 ? "已保存 {$count} 项修改(财务系统维护,不回写工作平台),修改记录见下方操作日志。" : '没有修改任何字段。');
}
/** 打款信息页签里发起:快递公司代码在表单里填。 */
public function storePaymentAccountForCode(Request $request): RedirectResponse
{
$data = $request->validate(
['expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/']],
['expcode.required' => '请填写快递公司代码。', 'expcode.regex' => '快递公司代码只能是字母、数字、下划线或短横线。'],
);
return $this->storePaymentAccount($request, trim($data['expcode']));
}
public function storePaymentAccount(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
if ($row === null) {
throw \Illuminate\Validation\ValidationException::withMessages(['expcode' => "快递资料里没有快递公司代码「{$expcode}」,请核对。"]);
}
$change = $request->input('action') === CourierPaymentAccount::ACTION_CHANGE;
$number = ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z\- ]+$/'];
$data = $request->validate([
'action' => ['required', 'in:add,change'],
'account_type' => ['required', 'in:' . implode(',', CourierPaymentAccount::ACCOUNT_TYPES)],
'account_name' => ['required', 'string', 'max:255'],
'bank_name' => ['required', 'string', 'max:255'],
'account_number' => $number,
'effective_date' => ['required', 'date'],
'old_account_name' => [$change ? 'required' : 'nullable', 'string', 'max:255'],
'old_account_number' => $change ? $number : ['nullable', 'string', 'max:64'],
'old_stop_date' => [$change ? 'required' : 'nullable', 'date'],
'change_date' => [$change ? 'required' : 'nullable', 'date'],
'note' => ['nullable', 'string', 'max:500'],
], [
'account_number.regex' => '银行账号只能包含数字、字母和短横线。',
'old_account_number.regex' => '原账号只能包含数字、字母和短横线。',
], [
'account_type' => '账户类型', 'account_name' => $change ? '新开户名称' : '开户名称', 'bank_name' => $change ? '新开户银行' : '开户银行',
'account_number' => $change ? '新银行账号' : '银行账号', 'effective_date' => $change ? '新账户启用日期' : '生效日期',
'old_account_name' => '原开户名称', 'old_account_number' => '原账号', 'old_stop_date' => '原账户停止使用日期', 'change_date' => '账户变更日期',
]);
$strip = static fn (?string $value): ?string => $value === null ? null : (string) preg_replace('/\s+/u', '', $value);
$record = CourierPaymentAccount::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'courier_name' => $row->expname ?? null,
'action' => $data['action'],
'account_type' => $data['account_type'],
'account_name' => trim($data['account_name']),
'bank_name' => trim($data['bank_name']),
'account_number' => $strip($data['account_number']),
'effective_date' => $data['effective_date'],
'old_account_name' => $change ? trim((string) $data['old_account_name']) : null,
'old_account_number' => $change ? $strip($data['old_account_number']) : null,
'old_stop_date' => $change ? $data['old_stop_date'] : null,
'change_date' => $change ? $data['change_date'] : null,
'note' => $data['note'] ?? null,
'created_by' => (int) user_id(),
'updated_by' => (int) user_id(),
]);
$summary = $change
? "打款信息变更:{$record->old_account_name} → {$record->account_name}({$record->account_type})"
: "打款信息新增:{$record->account_name}({$record->account_type},{$record->bank_name})";
$this->profiles->log($expcode, $change ? 'account_change' : 'account_add', $summary . ',待推送' . ($change ? '收款账户变更' : '收款账户证明') . '协议', null, (int) user_id());
return redirect()->route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_view' => 'create',
'payment_account' => $record->id,
])->with('success', $summary . '已保存。请核对下方预填的协议内容,补齐签署人信息后推送给快递签署;签署完成后这条打款信息显示为「已生效」。');
}
private function pageUrl(string $expcode, string $detailTab): string
{
return route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'selected' => $expcode,
'detail_tab' => $detailTab,
]) . '#courier-detail';
}
}

View File

@ -5,7 +5,9 @@ namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierOnboardingService;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierRequoteService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
@ -19,6 +21,7 @@ class CourierQuoteChanges extends Controller
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
private readonly CourierRequoteService $requotes,
) {
}
@ -35,7 +38,7 @@ class CourierQuoteChanges extends Controller
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
$quotes = $this->snapshot->load()['quotes'] ?? [];
abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。');
@ -43,6 +46,11 @@ class CourierQuoteChanges extends Controller
$selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']);
$source = $this->service->findSourceQuote($selectedId);
// 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。
if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source));
}
@ -56,20 +64,34 @@ class CourierQuoteChanges extends Controller
public function show(CourierQuoteChange $quoteChange): View
{
$this->refreshSignatures();
$quoteChange->load(['items', 'events.actor', 'version.signature']);
$onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
$requoteDiff = $onboarding !== null
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
: null;
return view('business.pages.courier-payables.quote-change-show', [
'change' => $quoteChange,
'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(),
'onboarding' => $onboarding,
'requoteDiff' => $requoteDiff,
'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
: [],
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')),
'canManageSignature' => $this->canReview(),
'esignConfigured' => EsignClient::configured(),
'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'),
]);
}
public function edit(CourierQuoteChange $quoteChange): View
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
{
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
$quotes = $this->snapshot->load()['quotes'] ?? [];
$source = $this->service->findSourceQuote($quoteChange->source_quote_id);
$quoteChange->load('items');
@ -112,10 +134,48 @@ class CourierQuoteChanges extends Controller
$message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。';
}
// 已入驻快递:按入驻资料直接推送到快递的 e签宝。
if ($change->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($change->carrier_name))) {
try {
app(CourierOnboardingService::class)->pushVersion($onboarding, $change->version, (int) user_id());
$message .= " 已推送到快递 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account}),等待签署。";
} catch (\Throwable $exception) {
report($exception);
$reason = $exception instanceof \Illuminate\Validation\ValidationException ? collect($exception->errors())->flatten()->first() : '推送失败';
$message .= " 推送 e签宝失败:{$reason},可在下方重新推送。";
}
}
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', $message);
}
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
abort_if($onboarding === null || $quoteChange->version === null, 404);
app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)
->with('success', "{$quoteChange->version->version_code} 已推送到快递 e签宝,等待签署。");
}
/** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */
public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->version === null, 404);
$data = $request->validate(
['void_reason' => ['required', 'string', 'min:2', 'max:500']],
['void_reason.required' => '请写明作废原因,例如:江苏面单多打了一位。'],
);
$message = $this->requotes->voidVersion($quoteChange->version, trim($data['void_reason']), (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)->with('success', $message);
}
public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
@ -242,6 +302,15 @@ class CourierQuoteChanges extends Controller
return $name;
}
private function refreshSignatures(): void
{
try {
$this->signatureService->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
}
private function canReview(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);

View File

@ -11,6 +11,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
@ -86,6 +87,146 @@ class CourierQuoteSignatures extends Controller
return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']);
}
/**
* 批量导出已签署的回签单电子档(签署 PDF + 签署完成证书)为 zip。
* codes 为报价版本号,逗号分隔;不传则导出全部已归档的回签单。
*/
public function export(Request $request): BinaryFileResponse
{
$codes = array_values(array_filter(array_map('trim', explode(',', (string) $request->query('codes', '')))));
$signatures = CourierQuoteSignature::query()
->with('version')
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->whereNotNull('signed_pdf_path')
->when($codes !== [], fn ($query) => $query->whereHas('version', fn ($version) => $version->whereIn('version_code', $codes)))
->orderBy('id')
->get();
abort_if($signatures->isEmpty(), 404, '没有可导出的已签署回签单。');
$disk = \Storage::disk('uploads');
$zipPath = tempnam(sys_get_temp_dir(), 'quote-signed-');
$zip = new \ZipArchive();
abort_unless($zip->open($zipPath, \ZipArchive::OVERWRITE) === true, 500, '生成压缩包失败。');
$added = 0;
foreach ($signatures as $signature) {
$version = $signature->version;
$base = $this->safeName(($version->company_name ?: $version->carrier_name) . '-' . $version->version_code);
if ($disk->exists($signature->signed_pdf_path)) {
$zip->addFromString("{$base}-回签单.pdf", $disk->get($signature->signed_pdf_path));
$added++;
}
if ($signature->certificate_path && $disk->exists($signature->certificate_path)) {
$zip->addFromString("{$base}-签署完成证书.pdf", $disk->get($signature->certificate_path));
}
}
$zip->close();
abort_if($added === 0, 404, '回签单归档文件不存在。');
$name = $signatures->count() === 1
? $this->safeName($signatures->first()->version->version_code) . '-回签单电子档.zip'
: '快递报价回签单-' . now()->format('Ymd-His') . '.zip';
return response()->download($zipPath, $name, ['Content-Type' => 'application/zip'])->deleteFileAfterSend();
}
/**
* 导出报价电子表格(xlsx,WPS / Excel 都能打开):只保留「省份」表头和各省价格,
* 不带标题、执行时间、计费说明和备注等文字行。
*/
public function sheet(CourierQuoteVersion $quoteVersion): StreamedResponse
{
$values = json_decode((string) $quoteVersion->values_json, true);
$table = self::quoteTable(is_array($values) ? $values : []);
abort_if($table === [], 404, '这份报价没有可导出的价格表。');
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(mb_substr($this->safeName($quoteVersion->version_code), 0, 31));
foreach ($table as $rowIndex => $row) {
foreach ($row as $columnIndex => $value) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1);
if (is_int($value) || is_float($value)) {
$sheet->setCellValue($cell, $value);
} elseif ($value !== null && $value !== '') {
$sheet->setCellValueExplicit($cell, (string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
}
}
}
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($table[0]));
$range = 'A1:' . $lastColumn . count($table);
$sheet->getStyle($range)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getStyle($range)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER)
->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER)->setWrapText(true);
$header = $sheet->getStyle('A1:' . $lastColumn . '1');
$header->getFont()->setBold(true);
$header->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('F3EAD7');
$sheet->getColumnDimension('A')->setWidth(12);
for ($i = 2; $i <= count($table[0]); $i++) {
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setWidth(16);
}
$sheet->freezePane('B2');
$fileName = $this->safeName(($quoteVersion->company_name ?: $quoteVersion->carrier_name) . '-' . $quoteVersion->version_code) . '-报价.xlsx';
return response()->streamDownload(function () use ($spreadsheet): void {
(new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');
}, $fileName, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
/**
* 从报价矩阵里截出价格表:从「省份」表头行开始,到第一行只有一格文字(说明、备注)为止。
*
* @param array<int,mixed> $values
* @return list<list<string|int|float|null>>
*/
public static function quoteTable(array $values): array
{
$rows = array_values(array_filter($values, 'is_array'));
$filled = static fn (array $row): int => count(array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== ''));
$start = null;
foreach ($rows as $index => $row) {
$first = trim((string) ($row[0] ?? ''));
if (preg_match('/^(省份|省市|区域|地区|目的地|目的省份)/u', $first) === 1 && $filled($row) >= 2) {
$start = $index;
break;
}
}
if ($start === null) {
foreach ($rows as $index => $row) {
if ($filled($row) >= 2) {
$start = $index;
break;
}
}
}
if ($start === null) {
return [];
}
$table = [];
for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) {
$table[] = array_values($rows[$i]);
}
$width = 0;
foreach ($table as $row) {
foreach ($row as $column => $cell) {
if ($cell !== null && trim((string) $cell) !== '') {
$width = max($width, $column + 1);
}
}
}
return array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table);
}
private function safeName(string $name): string
{
return trim(preg_replace('~[\\\\/:*?"<>|\s]+~u', '_', $name), '_') ?: 'quote';
}
private function validateAccountForNotice(string $account, string $noticeType): void
{
$valid = $noticeType === '1'

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,8 @@ class CourierOnboardingForms extends Controller
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
]);
}
@ -90,7 +92,10 @@ class CourierOnboardingForms extends Controller
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'remark' => ['nullable', 'string', 'max:2000'],
'action' => ['required', Rule::in(['save', 'submit'])],
'entity_type' => ['nullable', Rule::in(array_keys(CourierOnboarding::ENTITY_TYPES))],
'id_number' => ['nullable', 'string', 'regex:/^\s*\d{17}[\dXx]\s*$/'],
'company_full_name' => $text,
'unified_social_credit_code' => ['nullable', 'string', 'regex:/^[0-9A-Za-z]{18}$/'],
'legal_representative' => ['nullable', 'string', 'max:100'],
@ -103,28 +108,55 @@ class CourierOnboardingForms extends Controller
'finance_contact_name' => ['nullable', 'string', 'max:100'],
'finance_contact_phone' => $phone,
'pickup_warehouses' => ['nullable', 'string', 'max:1000'],
'esign_operator_name' => ['nullable', 'string', 'max:100'],
'esign_account' => ['nullable', 'string', 'max:255'],
'bank_account_name' => $text,
'bank_name' => $text,
'bank_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'private_account_name' => $text,
'private_bank_name' => $text,
'private_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'invoice_type' => ['nullable', Rule::in(CourierOnboarding::INVOICE_TYPES)],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:20'],
'settlement_cycle' => ['nullable', Rule::in(CourierOnboarding::SETTLEMENT_CYCLES)],
...self::quoteRules(),
'remove_files' => ['nullable', 'array'],
'remove_files.*' => ['integer'],
'files' => ['nullable', 'array'],
'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND],
'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,heic,heif,pdf,xls,xlsx'],
];
}
/**
* 报价部分的校验规则,后台变价页共用。
*
* @return array<string,mixed>
*/
public static function quoteRules(): array
{
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'quote_mode' => ['nullable', Rule::in(array_keys(CourierOnboarding::QUOTE_MODES))],
'volumetric_rule' => ['nullable', Rule::in(CourierOnboarding::VOLUMETRIC_RULES)],
'volumetric_divisor' => ['nullable', 'integer', 'min:1000', 'max:20000'],
'quote_effective_date' => ['nullable', 'date'],
'quote_note' => ['nullable', 'string', 'max:2000'],
'remark' => ['nullable', 'string', 'max:2000'],
'quote_rows' => ['nullable', 'array', 'max:80'],
'quote_rows.*.region' => ['nullable', 'string', 'max:50'],
'quote_rows.*.first_weight' => $money,
'quote_rows.*.first_price' => $money,
'quote_rows.*.rate' => $money,
'remove_files' => ['nullable', 'array'],
'remove_files.*' => ['integer'],
'files' => ['nullable', 'array'],
'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND],
'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,pdf,xls,xlsx,doc,docx'],
'quote_rows.*.flat' => ['nullable', 'array', 'max:' . \App\Support\CourierQuoteStructure::MAX_BANDS],
'quote_rows.*.flat.*' => $money,
'quote_rows.*.base' => $money,
'quote_rows.*.rates' => ['nullable', 'array', 'max:' . (\App\Support\CourierQuoteStructure::MAX_TIERS + 1)],
'quote_rows.*.rates.*' => $money,
'quote_flat_bands' => ['nullable', 'string', 'max:200'],
'quote_first_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'quote_rate_tiers' => ['nullable', 'string', 'max:200'],
'quote_then_mode' => ['nullable', Rule::in(['waybill_rate', 'first_additional'])],
'min_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'weight_rounding' => ['nullable', Rule::in(array_keys(\App\Support\CourierQuoteStructure::ROUNDING))],
'per_ticket_surcharge' => ['nullable', 'numeric', 'min:0', 'max:1000'],
];
}
@ -136,11 +168,13 @@ class CourierOnboardingForms extends Controller
return [
'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。',
'contact_phone.regex' => '请填写正确的联系电话。',
'id_number.regex' => '身份证号应为 18 位。',
'finance_contact_phone.regex' => '请填写正确的联系电话。',
'bank_account_number.regex' => '银行账号只能包含数字。',
'private_account_number.regex' => '银行卡号只能包含数字。',
'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。',
'files.*.*.max' => '单个文件不能超过 10MB。',
'files.*.*.mimes' => '只支持图片、PDF、Excel、Word 文件。',
'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。',
];
}
}

View File

@ -34,14 +34,17 @@ class CourierOnboarding extends Model
/** 快递方可以继续编辑资料的状态。 */
public const COURIER_EDITABLE = [self::STATUS_INVITED, self::STATUS_RETURNED];
public const ENTITY_COMPANY = 'company';
public const ENTITY_PERSON = 'person';
public const ENTITY_TYPES = [
self::ENTITY_COMPANY => '公司 / 个体户',
self::ENTITY_PERSON => '个人',
];
public const SERVICE_TYPES = ['快递', '快运', '大件专线', '其他'];
public const INVOICE_TYPES = ['增值税专用发票', '增值税普通发票', '不开票'];
public const SETTLEMENT_CYCLES = ['月结', '半月结', '周结', '其他'];
public const QUOTE_MODES = [
'waybill_rate' => '面单 + 费率 × 重量',
'first_additional' => '首重 + 续重',
'flat' => '一口价 / 分段价',
];
public const QUOTE_MODES = \App\Support\CourierQuoteStructure::MODES;
public const VOLUMETRIC_RULES = ['不计泡', '计泡'];
public const PROVINCES = [
@ -54,11 +57,14 @@ class CourierOnboarding extends Model
protected $fillable = [
'company_id', 'number', 'token', 'status', 'invite_label', 'expires_at',
'company_full_name', 'unified_social_credit_code', 'legal_representative', 'business_address',
'entity_type', 'id_number', 'company_full_name', 'unified_social_credit_code', 'legal_representative', 'business_address',
'carrier_brand', 'network_name', 'service_type', 'contact_name', 'contact_phone',
'finance_contact_name', 'finance_contact_phone', 'pickup_warehouses',
'finance_contact_name', 'finance_contact_phone', 'pickup_warehouses', 'esign_operator_name', 'esign_account',
'bank_account_name', 'bank_name', 'bank_account_number', 'invoice_type', 'tax_rate', 'settlement_cycle',
'quote_mode', 'volumetric_rule', 'volumetric_divisor', 'quote_effective_date', 'quote_rows', 'quote_note', 'remark',
'private_account_name', 'private_bank_name', 'private_account_number',
'workplatform_expcode', 'erp_account_id', 'erp_opened_at', 'erp_opened_source', 'dingtalk_instance_id',
'quote_mode', 'volumetric_rule', 'volumetric_divisor', 'quote_effective_date', 'quote_structure', 'quote_rows',
'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'remark',
'courier_name', 'reconciler', 'return_reason', 'submitted_at',
'first_reviewed_by', 'first_reviewed_at', 'final_reviewed_by', 'final_reviewed_at',
'quote_change_id', 'quote_version_id', 'activated_by', 'activated_at', 'created_by', 'updated_by',
@ -73,7 +79,13 @@ class CourierOnboarding extends Model
'activated_at' => 'datetime',
'quote_effective_date' => 'date',
'quote_rows' => 'array',
'quote_structure' => 'array',
'min_weight' => 'float',
'per_ticket_surcharge' => 'float',
'bank_account_number' => 'encrypted',
'private_account_number' => 'encrypted',
'erp_opened_at' => 'datetime',
'id_number' => 'encrypted',
'tax_rate' => 'float',
'volumetric_divisor' => 'integer',
'first_reviewed_by' => 'integer',
@ -83,7 +95,7 @@ class CourierOnboarding extends Model
'deleted_at' => 'datetime',
];
protected $hidden = ['token', 'bank_account_number'];
protected $hidden = ['token', 'bank_account_number', 'private_account_number', 'id_number'];
public function files(): HasMany
{
@ -100,6 +112,29 @@ class CourierOnboarding extends Model
return $this->belongsTo(CourierQuoteVersion::class, 'quote_version_id');
}
public function isPerson(): bool
{
return $this->entity_type === self::ENTITY_PERSON;
}
public function entityLabel(): string
{
return $this->isPerson() ? '个人' : '公司';
}
/** 主体名称:公司为公司全称,个人为姓名。 */
public function subjectLabel(): string
{
return $this->isPerson() ? '姓名' : '公司全称';
}
public function maskedIdNumber(): string
{
$value = (string) ($this->id_number ?? '');
return strlen($value) < 8 ? $value : substr($value, 0, 3) . str_repeat('*', strlen($value) - 7) . substr($value, -4);
}
public function statusLabel(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
@ -126,9 +161,103 @@ class CourierOnboarding extends Model
return $this->number;
}
/** e签宝通知方式:1 短信(手机号账号),2 邮件(邮箱账号)。 */
public function esignNoticeType(): string
{
return str_contains((string) $this->esign_account, '@') ? '2' : '1';
}
public function quoteSignature(): ?CourierQuoteSignature
{
return $this->quoteVersion?->signature;
}
public function quoteModeLabel(): string
{
return self::QUOTE_MODES[$this->quote_mode] ?? '';
return \App\Support\CourierQuoteStructure::MODES[$this->quote_mode] ?? '';
}
/**
* @return array{mode:string,flat_bands:list<float>,then_mode:string,first_weight:?float,rate_tiers:list<float>}
*/
public function quoteStructure(): array
{
$structure = is_array($this->quote_structure) ? $this->quote_structure : [];
return [
'mode' => (string) ($structure['mode'] ?? $this->quote_mode ?? 'waybill_rate'),
'flat_bands' => array_values($structure['flat_bands'] ?? []),
'then_mode' => (string) ($structure['then_mode'] ?? ''),
'first_weight' => isset($structure['first_weight']) ? (float) $structure['first_weight'] : null,
'rate_tiers' => array_values($structure['rate_tiers'] ?? []),
];
}
/** @return list<array{key:string,label:string}> */
public function quoteColumns(): array
{
return \App\Support\CourierQuoteStructure::columns($this->quoteStructure());
}
/** 附加计费规则的文字(计泡、起算重量、取整、每票加收)。 */
public function quoteRuleLines(): array
{
$kg = static fn ($value): string => \App\Support\CourierQuoteStructure::kg((float) $value);
return array_values(array_filter([
'计费方式:' . \App\Support\CourierQuoteStructure::describe($this->quoteStructure()),
$this->volumetric_rule === '计泡' ? '计泡:长*宽*高/' . ($this->volumetric_divisor ?: 6000) . ',取实重与体积重较大值' : ($this->volumetric_rule ? '不计泡' : null),
$this->min_weight ? '起算重量:不足' . $kg($this->min_weight) . 'kg按' . $kg($this->min_weight) . 'kg计' : null,
$this->weight_rounding && $this->weight_rounding !== 'none' ? '重量取整:' . (\App\Support\CourierQuoteStructure::ROUNDING[$this->weight_rounding] ?? $this->weight_rounding) : null,
$this->per_ticket_surcharge ? '每票加收:' . $kg($this->per_ticket_surcharge) . '元' : null,
]));
}
/** ERP 开通信息来源:钉钉「新建快递」流程同步 / 手动补录 / 工作平台老数据。 */
public const ERP_SOURCES = [
'dingtalk' => '钉钉流程同步',
'manual' => '手动补录',
'workplatform' => '工作平台',
];
/** 开通后等 ERP 专员在钉钉发起「新建快递」流程、开通发货系统账号。 */
public function erpPending(): bool
{
return $this->status === self::STATUS_ACTIVATED && $this->erp_opened_at === null;
}
public function erpSourceLabel(): string
{
return self::ERP_SOURCES[$this->erp_opened_source] ?? '';
}
/** 户名比对:去掉空格,全角括号转半角,英文统一大写。 */
public static function normalizeName(?string $name): string
{
$name = str_replace(['(', ')', ' '], ['(', ')', ''], (string) $name);
return mb_strtoupper((string) preg_replace('/\s+/u', '', $name));
}
/** 对私收款人应为谁:公司入驻是法定代表人,个人入驻是本人。 */
public function expectedPrivatePayee(): ?string
{
return $this->isPerson() ? $this->company_full_name : $this->legal_representative;
}
/**
* 打款账户核对结果:对公户名 = 营业执照公司全称,对私收款人 = 法定代表人(个人为本人)。
*
* @return array{corporate:?bool,private:?bool}
*/
public function accountChecks(): array
{
$same = static fn (?string $a, ?string $b): ?bool => blank($a) || blank($b) ? null : self::normalizeName($a) === self::normalizeName($b);
return [
'corporate' => $this->isPerson() ? null : $same($this->bank_account_name, $this->company_full_name),
'private' => $same($this->private_account_name, $this->expectedPrivatePayee()),
];
}
public function maskedBankAccount(): string
@ -142,7 +271,7 @@ class CourierOnboarding extends Model
}
/**
* @return list<array{region:string,first_price:?float,rate:?float,first_weight:?float}>
* @return list<array{region:string,flat:list<?float>,base:?float,rates:list<?float>}>
*/
public function quoteRowList(): array
{

View File

@ -37,6 +37,8 @@ class CourierOnboardingEvent extends Model
'cancelled' => '作废邀请',
'extended' => '延长链接有效期',
'internal_updated' => '更新内部信息',
'quote_pushed' => '推送报价确认单到 e签宝',
'quote_push_failed' => '报价确认单推送失败',
default => $this->event_type,
};
}

View File

@ -8,15 +8,47 @@ class CourierOnboardingFile extends Model
{
public const KINDS = [
'business_license' => '营业执照',
'legal_id_card' => '法人身份证(正反面)',
'bank_account_proof' => '开户许可证 / 银行账户证明',
'operating_permit' => '快递经营许可证',
'quote_sheet' => '盖章报价单',
'other' => '其他资料',
'legal_id_card_front' => '法人身份证 · 人像面',
'legal_id_card_back' => '法人身份证 · 国徽面',
'bank_card_front' => '对私银行卡 · 正面',
'bank_card_back' => '对私银行卡 · 反面',
];
/** 提交审核前必须上传的资料。 */
public const REQUIRED_KINDS = ['business_license', 'bank_account_proof', 'quote_sheet'];
/** 计费方式选「其他」时上传的报价表,单独显示在报价区块里。 */
public const QUOTE_SHEET_KIND = 'quote_sheet';
/** @return array<string,string> */
public static function allKinds(): array
{
return self::KINDS + [self::QUOTE_SHEET_KIND => '报价表'];
}
/** 个人入驻时的资料名称。 */
public const PERSON_KINDS = [
'business_license' => '营业执照(有就上传,选填)',
'legal_id_card_front' => '身份证 · 人像面',
'legal_id_card_back' => '身份证 · 国徽面',
'bank_card_front' => '对私银行卡 · 正面',
'bank_card_back' => '对私银行卡 · 反面',
];
/** 提交审核前必须上传的资料(每项一张,重新上传会替换)。 */
public const REQUIRED_KINDS = ['business_license', 'legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back'];
public const PERSON_REQUIRED_KINDS = ['legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back'];
/** @return list<string> */
public static function requiredKindsFor(?string $entityType): array
{
return $entityType === CourierOnboarding::ENTITY_PERSON ? self::PERSON_REQUIRED_KINDS : self::REQUIRED_KINDS;
}
public static function labelFor(string $kind, ?string $entityType): string
{
return ($entityType === CourierOnboarding::ENTITY_PERSON ? (self::PERSON_KINDS[$kind] ?? null) : null)
?? self::allKinds()[$kind]
?? $kind;
}
protected $table = 'business_courier_onboarding_files';

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 快递资料「打款信息新增 / 变更」。保存后跳到协议签署推送收款账户证明(新增)或收款账户变更(变更),
* 状态按协议签署记录推导:待推送协议 → 签署中 → 已生效。
*/
class CourierPaymentAccount extends Model
{
public const ACTION_ADD = 'add';
public const ACTION_CHANGE = 'change';
public const ACTIONS = [self::ACTION_ADD => '打款信息新增', self::ACTION_CHANGE => '打款信息变更'];
public const ACCOUNT_TYPES = ['对公', '对私'];
/** 对应协议签署里的文件类型。 */
public const DOCUMENT_TYPES = [self::ACTION_ADD => 'account-proof', self::ACTION_CHANGE => 'account-change'];
protected $table = 'business_courier_payment_accounts';
protected $fillable = [
'company_id', 'expcode', 'courier_name', 'action', 'account_type', 'account_name', 'bank_name', 'account_number',
'effective_date', 'old_account_name', 'old_account_number', 'old_stop_date', 'change_date', 'note',
'created_by', 'updated_by',
];
protected $casts = [
'account_number' => 'encrypted',
'old_account_number' => 'encrypted',
'effective_date' => 'date',
'old_stop_date' => 'date',
'change_date' => 'date',
];
public function documentType(): string
{
return self::DOCUMENT_TYPES[$this->action] ?? 'account-proof';
}
public function actionLabel(): string
{
return self::ACTIONS[$this->action] ?? $this->action;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 快递资料在财务系统里的「编辑」覆盖值(按快递公司代码)。工作平台镜像只读,编辑结果存在这里,
* 页面和导出显示时用覆盖值盖在镜像值上。
*/
class CourierProfile extends Model
{
/** 可在财务系统编辑的快递资料字段(键与工作平台 wp_express 列名一致)。 */
public const EDITABLE = [
'expname' => '快递公司名称',
'paymethod' => '运费打款方式',
'status' => '是否启用',
'kuaiyunsgn' => '是否快运',
'stopdate' => '停运时间',
'tradebtime' => '合同开始时间',
'tradeetime' => '合同结束时间',
'linker' => '联系人',
'linkmobile' => '联系电话',
'undefined1' => '自定义1',
'undefined2' => '自定义2',
'undefined3' => '自定义3',
'dzqzjbr' => '电子签章经办人',
];
public const BOOLEAN_FIELDS = ['status', 'kuaiyunsgn'];
public const DATE_FIELDS = ['stopdate', 'tradebtime', 'tradeetime'];
protected $table = 'business_courier_profiles';
protected $fillable = ['company_id', 'expcode', 'courier_name', 'overrides', 'created_by', 'updated_by'];
protected $casts = ['overrides' => 'array'];
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 快递资料操作日志(财务系统侧):编辑、打款信息新增/变更。 */
class CourierProfileEvent extends Model
{
public const ACTIONS = [
'edit' => '编辑快递资料',
'account_add' => '打款信息新增',
'account_change' => '打款信息变更',
];
protected $table = 'business_courier_profile_events';
protected $fillable = ['company_id', 'expcode', 'action', 'summary', 'changes', 'created_by'];
protected $casts = ['changes' => 'array'];
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Auth\User::class, 'created_by');
}
public function actionLabel(): string
{
return self::ACTIONS[$this->action] ?? $this->action;
}
}

View File

@ -21,17 +21,19 @@ class CourierQuoteChange extends Model
protected $fillable = [
'company_id', 'number', 'source_quote_id', 'source_sheet_name', 'carrier_name', 'quote_type',
'change_type', 'status', 'effective_date', 'is_retrospective', 'recalculation_status',
'change_type', 'status', 'effective_date', 'end_date', 'is_retrospective', 'recalculation_status',
'recalculation_note', 'summary', 'created_by', 'submitted_by', 'submitted_at', 'approved_by',
'approved_at', 'new_quote_version_id',
'approved_at', 'new_quote_version_id', 'quote_payload', 'proposed_values_json',
];
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
'end_date' => 'date',
'is_retrospective' => 'boolean',
'submitted_at' => 'datetime',
'approved_at' => 'datetime',
'quote_payload' => 'array',
];
public function items(): HasMany
@ -49,6 +51,20 @@ class CourierQuoteChange extends Model
return $this->hasOne(CourierQuoteVersion::class, 'source_change_id');
}
/** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
public function periodLabel(): string
{
return $this->end_date !== null
? $this->effective_date?->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
: $this->effective_date?->toDateString() . ' 起';
}
public function hasQuotePayload(): bool
{
return is_array($this->quote_payload) && $this->quote_payload !== [];
}
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true);

View File

@ -33,6 +33,7 @@ class CourierQuoteChangeEvent extends Model
'submitted' => '提交审核',
'returned' => '退回修改',
'approved' => '审核通过',
'voided' => '版本作废',
default => $this->event_type,
};
}

View File

@ -12,15 +12,55 @@ class CourierQuoteVersion extends Model
protected $fillable = [
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'quote_type', 'effective_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by',
'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
];
public const STATUS_APPROVED = 'approved';
/** 发错作废:不再参与生效判断,原件和回签单保留。 */
public const STATUS_VOIDED = 'voided';
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
public function isTemporary(): bool
{
return $this->end_date !== null;
}
/** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */
public function coversDate(\DateTimeInterface $date): bool
{
$day = \Illuminate\Support\Carbon::instance($date)->startOfDay();
return $this->effective_date !== null
&& $this->effective_date->lte($day)
&& ($this->end_date === null || $this->end_date->gte($day));
}
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
public function periodLabel(): string
{
if ($this->effective_date === null) {
return '-';
}
return $this->end_date !== null
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
: $this->effective_date->toDateString() . ' 起';
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;
}
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');

View File

@ -172,6 +172,39 @@ final class EsignClient
return $payload;
}
/**
* 获取签署人的签署页面链接(免登录,扫码或点开即可签署)。企业签署时带上企业名称,直接进入以该企业身份签署的页面。
*/
public function signUrl(string $signFlowId, string $psnAccount, ?string $orgName = null): string
{
$payload = [
'needLogin' => false,
'urlType' => 2,
'operator' => ['psnAccount' => $psnAccount],
'clientType' => 'ALL',
];
if ($orgName !== null && $orgName !== '') {
$payload['organization'] = ['orgName' => $orgName];
}
$data = $this->request('POST', "/v3/sign-flow/{$signFlowId}/sign-url", $payload);
$url = (string) ($data['shortUrl'] ?? '') ?: (string) ($data['url'] ?? '');
if ($url === '') {
throw new RuntimeException('E 签宝未返回签署链接。');
}
return $url;
}
/** 撤回还没签完的签署流程(签署人那边的待签任务随之失效)。 */
public function revokeSignFlow(string $signFlowId, string $reason): void
{
$this->request('POST', "/v3/sign-flow/{$signFlowId}/revoke", [
'revokeReason' => mb_substr($reason, 0, 50),
]);
}
public function signFlowDetail(string $signFlowId): array
{
return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail");

View File

@ -8,7 +8,10 @@ use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteStructure;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@ -21,20 +24,34 @@ class CourierOnboardingService
{
public const DEFAULT_VALID_DAYS = 30;
public const MAX_FILES_PER_KIND = 6;
public const MAX_FILES_PER_KIND = 1;
/** 提交审核前快递方必须填写的字段。 */
private const REQUIRED_FIELDS = [
private const COMPANY_REQUIRED_FIELDS = [
'company_full_name' => '公司全称',
'unified_social_credit_code' => '统一社会信用代码',
'legal_representative' => '法定代表人',
'bank_account_name' => '对公账户户名',
'bank_name' => '对公账户开户行',
'bank_account_number' => '对公账号',
];
private const PERSON_REQUIRED_FIELDS = [
'company_full_name' => '姓名',
'id_number' => '身份证号',
];
private const REQUIRED_FIELDS = [
'carrier_brand' => '快递品牌',
'network_name' => '网点名称',
'service_type' => '业务类型',
'contact_name' => '业务对接人',
'contact_phone' => '对接人手机',
'bank_account_name' => '收款户名',
'bank_name' => '开户行',
'bank_account_number' => '银行账号',
'esign_operator_name' => 'e签宝签署人姓名',
'esign_account' => 'e签宝账号',
'private_account_name' => '对私收款人',
'private_bank_name' => '对私账户开户行',
'private_account_number' => '对私银行卡号',
'invoice_type' => '开票类型',
'settlement_cycle' => '结算周期',
'quote_mode' => '计费方式',
@ -42,6 +59,11 @@ class CourierOnboardingService
'quote_effective_date' => '报价执行日期',
];
public const QUOTE_FIELDS = [
'quote_mode', 'quote_structure', 'quote_rows', 'volumetric_rule', 'volumetric_divisor',
'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'quote_effective_date',
];
public function ready(): bool
{
return Schema::hasTable('business_courier_onboardings')
@ -49,10 +71,16 @@ class CourierOnboardingService
&& Schema::hasTable('business_courier_onboarding_events');
}
public function invite(?string $label, int $validDays, int $userId): CourierOnboarding
public function invite(?string $label, int $validDays, int $userId, ?string $courierName = null): CourierOnboarding
{
return DB::transaction(function () use ($label, $validDays, $userId): CourierOnboarding {
$courierName = trim((string) $courierName);
if ($courierName !== '') {
$this->assertCourierNameAvailable(null, $courierName);
}
return DB::transaction(function () use ($label, $validDays, $userId, $courierName): CourierOnboarding {
$onboarding = CourierOnboarding::create([
'courier_name' => $courierName !== '' ? $courierName : null,
'company_id' => company_id(),
'number' => $this->nextNumber(),
'token' => $this->newToken(),
@ -62,7 +90,8 @@ class CourierOnboardingService
'created_by' => $userId,
'updated_by' => $userId,
]);
$this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED, $onboarding->invite_label, 'user', $userId);
$this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED,
trim('系统快递名称:' . ($onboarding->courier_name ?? '—') . ($onboarding->invite_label ? ';备注:' . $onboarding->invite_label : '')), 'user', $userId);
return $onboarding;
});
@ -216,11 +245,12 @@ class CourierOnboardingService
public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
if ((int) $onboarding->first_reviewed_by === $userId) {
if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']);
}
$courierName = trim((string) $data['courier_name']);
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
$this->assertCourierNameAvailable($onboarding, $courierName);
return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding {
@ -236,9 +266,9 @@ class CourierOnboardingService
if ($version !== null) {
$extra['quote_change_id'] = $version->source_change_id;
$extra['quote_version_id'] = $version->id;
$note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code},可在协议签署页发起报价确认单。");
$note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code}。");
} else {
$note = trim(($note ?? '') . "\n快递未填写报价明细,仅有报价附件,请在报价管理中补录。");
$note = trim(($note ?? '') . "\n快递未填写报价明细,请在报价管理中补录后再发起报价确认。");
}
return $this->transition($onboarding, CourierOnboarding::STATUS_APPROVED, 'final_approved', $note, $userId, $extra);
@ -249,14 +279,18 @@ class CourierOnboardingService
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。');
$quoteSignature = $onboarding->quoteSignature();
$quoteSigned = $quoteSignature?->status === CourierQuoteSignature::STATUS_ARCHIVED;
$signature = $this->signedAgreement($onboarding);
if ($signature === null && blank($offlineNote)) {
throw ValidationException::withMessages(['offline_note' => '未找到已签署的电子协议;如已线下签署纸质协议,请填写说明后再开通。']);
if (! $quoteSigned && $signature === null && blank($offlineNote)) {
throw ValidationException::withMessages(['offline_note' => '报价确认单还没在 e签宝签署完成;如已线下签署,请填写说明后再开通。']);
}
$note = $signature !== null
? '电子协议已签署:' . $signature->business_no
: '线下签署:' . trim((string) $offlineNote);
$note = match (true) {
$quoteSigned => '报价确认单已在 e签宝签署:' . $onboarding->quoteVersion->version_code,
$signature !== null => '电子协议已签署:' . $signature->business_no,
default => '线下签署:' . trim((string) $offlineNote),
};
return $this->transition($onboarding, CourierOnboarding::STATUS_ACTIVATED, 'activated', $note, $userId, [
'activated_by' => $userId,
@ -264,6 +298,104 @@ class CourierOnboardingService
]);
}
public function esignReady(): bool
{
return EsignClient::configured();
}
/**
* 用快递填写的报价生成报价确认单 PDF,推送到快递方的 e签宝账号签署。
*/
public function pushQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature
{
$onboarding->loadMissing('quoteVersion.signature');
if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true) || $onboarding->quoteVersion === null) {
throw ValidationException::withMessages(['signature' => '财务复审通过、生成首版报价后才能推送报价确认单。']);
}
return $this->pushVersion($onboarding, $onboarding->quoteVersion, $userId);
}
/**
* 按入驻资料(公司 / 个人、e签宝签署人和账号)把某个报价版本推送给快递签署。
* 首版报价和后续变价共用。
*/
public function pushVersion(CourierOnboarding $onboarding, CourierQuoteVersion $version, int $userId): CourierQuoteSignature
{
if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true)) {
throw ValidationException::withMessages(['signature' => '这家快递的入驻还没有审核通过,不能推送报价。']);
}
if ($version->isVoided()) {
throw ValidationException::withMessages(['signature' => "{$version->version_code} 已作废,不能推送。"]);
}
$existing = $version->signature()->first();
if ($existing !== null && ! $existing->canSend()) {
throw ValidationException::withMessages(['signature' => "{$version->version_code} 已经推送过,正在签署或已签署。"]);
}
if (! EsignClient::configured()) {
throw ValidationException::withMessages(['signature' => 'E 签宝尚未配置,暂时无法推送;可线下签署后在开通时填写说明。']);
}
if (blank($onboarding->esign_operator_name) || ! $this->validEsignAccount((string) $onboarding->esign_account)) {
throw ValidationException::withMessages(['signature' => '快递方填写的 e签宝签署人或账号不完整,请退回补件或联系快递确认。']);
}
$quoteSignatures = app(CourierQuoteSignatureService::class);
$signature = $quoteSignatures->prepareVersion($version, $userId, today()->toDateString());
$signature = $quoteSignatures->send($signature, [
'signer_type' => $onboarding->isPerson() ? 'person' : 'organization',
'organization_name' => (string) $onboarding->company_full_name,
'operator_name' => (string) $onboarding->esign_operator_name,
'operator_account' => (string) $onboarding->esign_account,
'notice_type' => $onboarding->esignNoticeType(),
'signature_date' => today()->toDateString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
], $userId);
$this->log($onboarding, 'quote_pushed', $onboarding->status, $onboarding->status,
"报价确认单 {$version->version_code} 已推送到 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account})", 'user', $userId);
return $signature;
}
public function syncQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature
{
$signature = $onboarding->quoteSignature();
if ($signature === null) {
throw ValidationException::withMessages(['signature' => '报价确认单还没有推送。']);
}
return app(CourierQuoteSignatureService::class)->synchronize($signature, $userId);
}
/**
* 取快递签署人的 e签宝 签署链接,可直接发给快递或我方扫码核对。
*/
public function quoteSignUrl(CourierOnboarding $onboarding): string
{
$signature = $onboarding->quoteSignature();
if ($signature === null || blank($signature->esign_sign_flow_id)) {
throw ValidationException::withMessages(['signature' => '报价确认单还没有推送到 e签宝。']);
}
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
throw ValidationException::withMessages(['signature' => '报价确认单已签署完成。']);
}
try {
return app(\App\Services\Esign\EsignClient::class)->signUrl(
(string) $signature->esign_sign_flow_id,
(string) $signature->operator_account,
$onboarding->isPerson() ? null : (string) $signature->organization_name,
);
} catch (\RuntimeException $exception) {
throw ValidationException::withMessages(['signature' => '获取签署链接失败:' . mb_substr($exception->getMessage(), 0, 300)]);
}
}
private function validEsignAccount(string $account): bool
{
return preg_match('/^1\d{10}$/', $account) === 1 || filter_var($account, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。
*
@ -327,8 +459,9 @@ class CourierOnboardingService
foreach ([
'company_full_name', 'legal_representative', 'business_address', 'carrier_brand', 'network_name',
'service_type', 'contact_name', 'contact_phone', 'finance_contact_name', 'finance_contact_phone',
'pickup_warehouses', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle',
'quote_mode', 'volumetric_rule', 'quote_note', 'remark',
'pickup_warehouses', 'esign_operator_name', 'esign_account', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle',
'private_account_name', 'private_bank_name',
'remark',
] as $key) {
$attributes[$key] = $text($key);
}
@ -337,52 +470,85 @@ class CourierOnboardingService
$attributes['unified_social_credit_code'] = $code === null ? null : strtoupper($code);
$account = $text('bank_account_number');
$attributes['bank_account_number'] = $account === null ? null : preg_replace('/\s+/', '', $account);
$privateAccount = $text('private_account_number');
$attributes['private_account_number'] = $privateAccount === null ? null : preg_replace('/\s+/', '', $privateAccount);
$attributes['tax_rate'] = is_numeric($data['tax_rate'] ?? null) ? (float) $data['tax_rate'] : null;
$attributes['volumetric_divisor'] = $attributes['volumetric_rule'] === '计泡' && is_numeric($data['volumetric_divisor'] ?? null)
? (int) $data['volumetric_divisor']
: null;
$attributes['quote_effective_date'] = $text('quote_effective_date');
$attributes['quote_rows'] = $this->normalizeQuoteRows((array) ($data['quote_rows'] ?? []));
$attributes = [...$attributes, ...$this->quoteAttributes($data)];
$attributes['entity_type'] = ($data['entity_type'] ?? null) === CourierOnboarding::ENTITY_PERSON
? CourierOnboarding::ENTITY_PERSON
: CourierOnboarding::ENTITY_COMPANY;
if ($attributes['entity_type'] === CourierOnboarding::ENTITY_PERSON) {
// 个人以本人身份签署:没有企业信息,e签宝签署人就是本人。
$idNumber = $text('id_number');
$attributes['id_number'] = $idNumber === null ? null : strtoupper((string) preg_replace('/\s+/', '', $idNumber));
$attributes['unified_social_credit_code'] = null;
$attributes['legal_representative'] = null;
// 个人入驻:对私账户必填,对公账户暂为选填(有个体户/公司账户的可以填)。
$attributes['esign_operator_name'] = $attributes['company_full_name'];
} else {
$attributes['id_number'] = null;
}
return $attributes;
}
/**
* @param array<int|string,mixed> $rows
* @return list<array{region:string,first_weight:?float,first_price:?float,rate:?float}>
* 报价相关字段(计费结构、各省价格、附加规则、执行日期),入驻表单和后台变价共用。
*
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
private function normalizeQuoteRows(array $rows): array
public function quoteAttributes(array $data): array
{
$number = static fn ($value): ?float => is_numeric($value) ? round((float) $value, 4) : null;
$result = [];
$text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null;
$structure = CourierQuoteStructure::normalize($data);
$volumetricRule = $text('volumetric_rule');
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$region = trim((string) ($row['region'] ?? ''));
$values = [
'first_weight' => $number($row['first_weight'] ?? null),
'first_price' => $number($row['first_price'] ?? null),
'rate' => $number($row['rate'] ?? null),
];
if ($region === '' || array_filter($values, static fn ($value): bool => $value !== null) === []) {
continue;
}
$result[] = ['region' => mb_substr($region, 0, 50)] + $values;
return [
'volumetric_rule' => $volumetricRule,
'quote_note' => $text('quote_note'),
'volumetric_divisor' => $volumetricRule === '计泡' && is_numeric($data['volumetric_divisor'] ?? null)
? (int) $data['volumetric_divisor']
: null,
'quote_effective_date' => $text('quote_effective_date'),
'quote_mode' => filled($data['quote_mode'] ?? null) ? $structure['mode'] : null,
'quote_structure' => $structure,
'quote_rows' => CourierQuoteStructure::normalizeRows((array) ($data['quote_rows'] ?? []), $structure),
'min_weight' => is_numeric($data['min_weight'] ?? null) && (float) $data['min_weight'] > 0 ? round((float) $data['min_weight'], 2) : null,
'weight_rounding' => array_key_exists((string) ($data['weight_rounding'] ?? ''), CourierQuoteStructure::ROUNDING) ? (string) $data['weight_rounding'] : null,
'per_ticket_surcharge' => is_numeric($data['per_ticket_surcharge'] ?? null) && (float) $data['per_ticket_surcharge'] > 0 ? round((float) $data['per_ticket_surcharge'], 2) : null,
];
}
/**
* 当前入驻记录里的报价部分,写进报价变更单,后续变价以此为底稿。
*
* @return array<string,mixed>
*/
public function quotePayload(CourierOnboarding $onboarding): array
{
$payload = [];
foreach (self::QUOTE_FIELDS as $field) {
$payload[$field] = $onboarding->getAttribute($field);
}
$payload['quote_effective_date'] = $onboarding->quote_effective_date?->toDateString();
$payload['quote_structure'] = $onboarding->quoteStructure();
$payload['quote_rows'] = $onboarding->quoteRowList();
return $result;
return $payload;
}
private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void
{
if (! array_key_exists($kind, CourierOnboardingFile::KINDS)) {
if (! array_key_exists($kind, CourierOnboardingFile::allKinds())) {
return;
}
if ($onboarding->files()->where('kind', $kind)->count() >= self::MAX_FILES_PER_KIND) {
throw ValidationException::withMessages(["files.{$kind}" => CourierOnboardingFile::KINDS[$kind] . '最多上传 ' . self::MAX_FILES_PER_KIND . ' 个文件。']);
}
// 每项资料只保留一张,重新上传即替换。
$onboarding->files()->where('kind', $kind)->get()->each(function (CourierOnboardingFile $old): void {
Storage::disk('uploads')->delete($old->path);
$old->delete();
});
$extension = strtolower($upload->getClientOriginalExtension() ?: ($upload->guessExtension() ?: 'bin'));
$path = sprintf('courier-onboarding/%d/%d/%s-%s.%s', $onboarding->company_id, $onboarding->id, $kind, Str::uuid()->toString(), $extension);
@ -405,7 +571,8 @@ class CourierOnboardingService
private function assertCompleteForSubmit(CourierOnboarding $onboarding): void
{
$errors = [];
foreach (self::REQUIRED_FIELDS as $field => $label) {
$subjectFields = $onboarding->isPerson() ? self::PERSON_REQUIRED_FIELDS : self::COMPANY_REQUIRED_FIELDS;
foreach ($subjectFields + self::REQUIRED_FIELDS as $field => $label) {
if (blank($onboarding->{$field})) {
$errors[$field] = "请填写{$label}。";
}
@ -414,10 +581,30 @@ class CourierOnboardingService
$errors['volumetric_divisor'] = '计泡请填写抛比系数(如 6000)。';
}
$checks = $onboarding->accountChecks();
if ($checks['corporate'] === false) {
$errors['bank_account_name'] = "对公账户户名「{$onboarding->bank_account_name}」与营业执照公司全称「{$onboarding->company_full_name}」不一致,对公账户须为公司本身的账户。";
}
if ($checks['private'] === false) {
$who = $onboarding->isPerson() ? '本人姓名' : '营业执照法定代表人';
$errors['private_account_name'] = "对私收款人「{$onboarding->private_account_name}」与{$who}「{$onboarding->expectedPrivatePayee()}」不一致,对私账户须为{$who}本人的银行卡。";
}
if (filled($onboarding->esign_account) && ! $this->validEsignAccount((string) $onboarding->esign_account)) {
$errors['esign_account'] = 'e签宝账号请填写注册时用的 11 位手机号或邮箱。';
}
$kinds = $onboarding->files()->pluck('kind')->unique()->all();
foreach (CourierOnboardingFile::REQUIRED_KINDS as $kind) {
$errors += CourierQuoteStructure::validate($onboarding->quoteStructure(), $onboarding->quoteRowList());
if ($onboarding->quote_mode === 'other' && ! in_array(CourierOnboardingFile::QUOTE_SHEET_KIND, $kinds, true)) {
$errors['files.' . CourierOnboardingFile::QUOTE_SHEET_KIND] = '计费方式选「其他」时,请上传报价表(Excel 或照片)。';
}
if ($onboarding->isPerson() && filled($onboarding->id_number) && preg_match('/^\d{17}[\dX]$/', (string) $onboarding->id_number) !== 1) {
$errors['id_number'] = '身份证号应为 18 位。';
}
foreach (CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) as $kind) {
if (! in_array($kind, $kinds, true)) {
$errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::KINDS[$kind] . '。';
$errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::labelFor($kind, $onboarding->entity_type) . '。';
}
}
@ -426,7 +613,7 @@ class CourierOnboardingService
}
}
private function assertCourierNameAvailable(CourierOnboarding $onboarding, string $courierName): void
private function assertCourierNameAvailable(?CourierOnboarding $onboarding, string $courierName): void
{
if ($courierName === '') {
throw ValidationException::withMessages(['courier_name' => '请填写系统快递名称。']);
@ -434,7 +621,7 @@ class CourierOnboardingService
$taken = CourierOnboarding::query()
->where('courier_name', $courierName)
->whereKeyNot($onboarding->id)
->when($onboarding !== null, static fn ($query) => $query->whereKeyNot($onboarding->id))
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
->exists();
@ -468,6 +655,7 @@ class CourierOnboardingService
'is_retrospective' => $effectiveDate->copy()->startOfDay()->lt(today()),
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => "快递入驻 {$onboarding->number} 首版报价({$onboarding->company_full_name})。",
'quote_payload' => Schema::hasColumn('business_courier_quote_changes', 'quote_payload') ? $this->quotePayload($onboarding) : null,
'created_by' => (int) ($onboarding->first_reviewed_by ?: $userId),
'submitted_by' => (int) ($onboarding->first_reviewed_by ?: $userId),
'submitted_at' => $onboarding->first_reviewed_at ?? now(),
@ -518,34 +706,32 @@ class CourierOnboardingService
* @param list<array{region:string,first_weight:?float,first_price:?float,rate:?float}> $rows
* @return list<list<string|int|float|null>>
*/
private function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array
public function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array
{
$volumetric = $onboarding->volumetric_rule === '计泡'
? '计泡:长*宽*高/' . ($onboarding->volumetric_divisor ?: 6000)
: '不计泡';
$columns = $onboarding->quoteColumns();
$width = count($columns) + 1;
$line = static fn (string $text): array => array_pad([$text], $width, null);
$matrix = [
[$onboarding->courier_name . ' 报价', null, null, null],
['执行时间:' . $effectiveDate->format('Y.m.d'), '计费方式:' . $onboarding->quoteModeLabel(), $volumetric, null],
$line($onboarding->courier_name . ' 报价'),
$line('执行时间:' . $effectiveDate->format('Y.m.d')),
];
$matrix[] = match ($onboarding->quote_mode) {
'first_additional' => ['省份', '首重(kg)', '首重价(元)', '续重(元/kg)'],
'flat' => ['省份/重量段', '重量(kg)', '价格(元)', '续重(元/kg)'],
default => ['省份', '首重(kg)', '面单(元)', '费率(元/kg)'],
};
$matrix[] = ['省份', ...array_column($columns, 'label')];
foreach ($rows as $row) {
$matrix[] = [$row['region'], $row['first_weight'], $row['first_price'], $row['rate']];
$matrix[] = [$row['region'], ...CourierQuoteStructure::rowValues($row, $columns)];
}
$notes = array_filter([
filled($onboarding->quote_note) ? '备注:' . $onboarding->quote_note : null,
'适用快递:' . $onboarding->courier_name,
]);
foreach ($notes as $line) {
$matrix[] = [$line, null, null, null];
// 规则说明放在价格表下方,避免报价确认单抬头的「面单费」误读说明文字。
foreach ($onboarding->quoteRuleLines() as $rule) {
$matrix[] = $line($rule);
}
if (filled($onboarding->quote_note)) {
$matrix[] = $line('备注:' . $onboarding->quote_note);
}
$matrix[] = $line('适用快递:' . $onboarding->courier_name);
return $matrix;
}

View File

@ -120,7 +120,8 @@ final class CourierQuoteChangeService
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
}
if ($change->created_by === $userId) {
// 测试环境可用 COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 放开(一人走完流程),正式环境保持发起人不能自审。
if ($change->created_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']);
}
@ -128,8 +129,15 @@ final class CourierQuoteChangeService
return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange {
$values = $source['values'];
$formulas = $source['formulas'];
foreach ($change->items as $item) {
// 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。
if ($change->hasQuotePayload() && filled($change->proposed_values_json)) {
$values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR);
$formulas = [];
}
foreach ($change->hasQuotePayload() ? [] : $change->items as $item) {
if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) {
throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]);
}
@ -150,9 +158,10 @@ final class CourierQuoteChangeService
'carrier_name' => $source['company_name'],
'quote_type' => $source['quote_type'],
'effective_date' => $change->effective_date,
'end_date' => $change->end_date,
'source_range' => $source['source_range'],
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($source['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $change->created_by,
@ -282,6 +291,7 @@ final class CourierQuoteChangeService
return [
'change_type' => $change->change_type,
'effective_date' => $change->effective_date->toDateString(),
'end_date' => $change->end_date?->toDateString(),
'is_retrospective' => $change->isRetrospective(),
'recalculation_status' => $change->recalculation_status,
'recalculation_note' => $change->recalculation_note,

View File

@ -13,9 +13,19 @@ final class CourierQuoteConfirmation
$nextVersion = CourierQuoteVersion::query()
->where('carrier_name', $version->carrier_name)
->where('quote_type', $version->quote_type)
->where('status', 'approved')
->where('effective_date', '>', $version->effective_date)
->whereNull('end_date')
->orderBy('effective_date')
->first();
// 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。
$restore = $version->isTemporary()
? \App\Support\CourierRequoteService::resolveAt(
CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(),
$version->end_date->copy()->addDay(),
false,
)
: null;
return [
'version_id' => $version->id,
@ -25,7 +35,13 @@ final class CourierQuoteConfirmation
'carrier_name' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'expiry_date' => $nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期',
'expiry_date' => $version->isTemporary()
? $version->end_date->toDateString()
: ($nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期'),
'temporary_note' => $version->isTemporary()
? '本报价为短期(临时)报价,仅在 ' . $version->effective_date->toDateString() . ' 至 ' . $version->end_date->toDateString() . ' 期间执行;'
. $version->end_date->copy()->addDay()->toDateString() . ' 起自动恢复' . ($restore ? '原报价 ' . $restore->version_code . '(' . $restore->effective_date->toDateString() . ' 起执行)' : '原长期报价') . ',无需另行签署。'
: null,
'face_fee' => $this->matrixMetric($values, ['面单费', '面单费用', '面单']),
'bubble_coefficient' => $this->matrixMetric($values, ['计泡系数', '泡系数', '计泡']),
'values' => $values,

View File

@ -23,6 +23,7 @@ final class CourierQuotePdf
'formulas' => $data['formulas'],
'partyA' => $data['party_a'],
'expiryDate' => $data['expiry_date'],
'temporaryNote' => $data['temporary_note'] ?? null,
'faceFee' => $data['face_fee'],
'bubbleCoefficient' => $data['bubble_coefficient'],
'signatureDate' => $signatureDate,

View File

@ -96,13 +96,23 @@ final class CourierQuoteSignatureService
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'quote-' . $signature->version->version_code . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signFlowId = $client->createSignFlow($fileId, $fileName, [
$signer = [
'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认',
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
], $signature->expires_at->getTimestampMs());
];
if (($data['signer_type'] ?? 'organization') === 'person') {
// 个人快递以本人身份签署,不走企业经办人。
$signer['signers'] = [[
'signer_type' => 0,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
]];
}
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierQuoteSignature::STATUS_SIGNING,
@ -139,6 +149,26 @@ final class CourierQuoteSignatureService
return $signature;
}
// 同一份确认单同时只查一次:几个页面同时打开时,避免后到的查询把已归档的状态又写回「签署完成,待归档」。
$lock = \Illuminate\Support\Facades\Cache::lock('esign-quote-sync-' . $signature->id, 120);
if (! $lock->get()) {
return $signature->fresh() ?? $signature;
}
try {
$signature = $signature->fresh() ?? $signature;
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
return $this->synchronizeUnlocked($signature, $userId);
} finally {
$lock->release();
}
}
private function synchronizeUnlocked(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature
{
try {
$client = app(EsignClient::class);
$detail = $client->signFlowDetail($signature->esign_sign_flow_id);
@ -162,6 +192,52 @@ final class CourierQuoteSignatureService
}
}
/**
* 打开报价页面时顺手查一下还在签署中的报价确认单(未开启定时轮询、回调又到不了本系统时,
* 签完能尽快显示「已签署并归档」)。每次最多查几条,距上次查询不足 $minAgeSeconds 秒的跳过,失败不影响页面。
*/
public function refreshPending(int $limit = 5, int $minAgeSeconds = 60): int
{
if (! EsignClient::configured() || ! \Illuminate\Support\Facades\Schema::hasTable('business_courier_quote_signatures')) {
return 0;
}
// 已归档但版本上的文字状态没跟上的,补成「已签署并归档」。
$archivedVersionIds = CourierQuoteSignature::query()
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->pluck('quote_version_id');
if ($archivedVersionIds->isNotEmpty()) {
CourierQuoteVersion::query()
->whereIn('id', $archivedVersionIds->all())
->where('signature_status', '<>', '已签署并归档')
->update(['signature_status' => '已签署并归档']);
}
$environment = strtolower(trim((string) config('services.esign.environment', 'sandbox')));
$cutoff = now()->subSeconds($minAgeSeconds);
$pending = CourierQuoteSignature::query()
->where('environment', $environment)
->whereNotNull('esign_sign_flow_id')
->where('esign_sign_flow_id', '<>', '')
->whereIn('status', [CourierQuoteSignature::STATUS_SIGNING, CourierQuoteSignature::STATUS_COMPLETED])
->where(fn ($query) => $query->whereNull('last_synced_at')->orWhere('last_synced_at', '<=', $cutoff))
->orderBy('last_synced_at')
->limit($limit)
->get();
$refreshed = 0;
foreach ($pending as $signature) {
try {
$this->synchronize($signature, (int) ($signature->updated_by ?: $signature->created_by));
$refreshed++;
} catch (Throwable) {
// 查询失败已记在 last_error,页面照常显示,可手动「同步签署状态」。
}
}
return $refreshed;
}
public function synchronizeById(int $signatureId): void
{
$signature = CourierQuoteSignature::allCompanies()->find($signatureId);

View File

@ -0,0 +1,293 @@
<?php
namespace App\Support;
/**
* 快递入驻报价的计费结构。
*
* 由现有约 110 张报价表归纳出 4 种常规计费方式,另有「其他」(按条码、全包单价、
* 城市流向等)由快递上传报价表、我方录入:
*
* - waybill_rate 面单 + 费率×重量(费率可按总重量所在区间分段)
* - first_additional 首重 + 续重×(重量−首重)(续重可分段)
* - flat_bands 按重量段一口价(只设一段即纯一口价)
* - flat_then 小重量段一口价,超出部分按「面单+费率」或「首重+续重」
* - other 其他,上传报价表
*
* 每个省份一行,列由结构决定:一口价各段 flat[]、面单/首重价 base、费率/续重各段 rates[]。
*/
final class CourierQuoteStructure
{
public const MODES = [
'waybill_rate' => '面单 + 费率×重量',
'first_additional' => '首重 + 续重',
'flat_bands' => '按重量段一口价',
'flat_then' => '小重量一口价 + 超出另算',
'other' => '其他(上传报价表)',
];
public const MODE_HINTS = [
'waybill_rate' => '运费 = 面单 + 重量 × 费率;费率可按重量区间分段,如 0-35kg / 35-50kg / 50kg 以上',
'first_additional' => '运费 = 首重价 +(重量 − 首重公斤数)× 续重;首重内按首重价',
'flat_bands' => '可设多档,如 0-1kg、1-2kg、2-3kg 各一个固定价;只设一档就是纯一口价,最后一档以上不收',
'flat_then' => '如 0-1、1-2、2-3kg 各档一口价,3kg 以上改按 面单+费率×重量 或 首重+续重',
'other' => '按条码、含货全包、按城市流向等特殊报价:直接上传报价表(Excel 或照片),由我方录入',
];
public const ROUNDING = [
'none' => '不取整',
'ceil' => '向上取整(如 2.1kg 按 3kg)',
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
];
public const MAX_BANDS = 12;
public const MAX_TIERS = 6;
/**
* @param array<string,mixed> $input
* @return array{mode:string,flat_bands:list<float>,then_mode:string,first_weight:?float,rate_tiers:list<float>}
*/
public static function normalize(array $input): array
{
$mode = array_key_exists((string) ($input['quote_mode'] ?? ''), self::MODES) ? (string) $input['quote_mode'] : 'waybill_rate';
$thenMode = ($input['quote_then_mode'] ?? '') === 'first_additional' ? 'first_additional' : 'waybill_rate';
return [
'mode' => $mode,
'flat_bands' => in_array($mode, ['flat_bands', 'flat_then'], true)
? array_slice(self::parseList($input['quote_flat_bands'] ?? ''), 0, self::MAX_BANDS)
: [],
'then_mode' => $mode === 'flat_then' ? $thenMode : '',
'first_weight' => self::usesFirstWeight($mode, $thenMode) && is_numeric($input['quote_first_weight'] ?? null)
? round((float) $input['quote_first_weight'], 2)
: null,
'rate_tiers' => self::usesRates($mode)
? array_slice(self::parseList($input['quote_rate_tiers'] ?? ''), 0, self::MAX_TIERS)
: [],
];
}
/**
* 解析「3,4,5」「3、4、5」这类重量节点,去重并升序。
*
* @return list<float>
*/
public static function parseList(mixed $value): array
{
if (is_array($value)) {
$parts = $value;
} else {
$parts = preg_split('/[\s,,、;;\/]+/u', (string) $value) ?: [];
}
$numbers = [];
foreach ($parts as $part) {
$part = preg_replace('/(kg|KG|公斤)$/u', '', trim((string) $part));
if (is_numeric($part) && (float) $part > 0) {
$numbers[] = round((float) $part, 2);
}
}
$numbers = array_values(array_unique($numbers, SORT_REGULAR));
sort($numbers);
return $numbers;
}
/**
* @param array<string,mixed> $structure
* @return list<array{key:string,label:string}>
*/
public static function columns(array $structure): array
{
$mode = $structure['mode'] ?? 'waybill_rate';
if ($mode === 'other') {
return [];
}
$columns = [];
$bands = $structure['flat_bands'] ?? [];
if (in_array($mode, ['flat_bands', 'flat_then'], true)) {
$lower = 0.0;
foreach ($bands as $index => $upper) {
$columns[] = ['key' => "flat.{$index}", 'label' => self::kg($lower) . '-' . self::kg($upper) . 'kg(含' . self::kg($upper) . ') 一口价'];
$lower = $upper;
}
}
if (! self::usesRates($mode)) {
return $columns;
}
$thenMode = $mode === 'flat_then' ? ($structure['then_mode'] ?? 'waybill_rate') : $mode;
$prefix = $mode === 'flat_then' && $bands !== [] ? self::kg((float) end($bands)) . 'kg 以上 ' : '';
if ($thenMode === 'first_additional') {
$columns[] = ['key' => 'base', 'label' => $prefix . '首重' . self::kg((float) ($structure['first_weight'] ?? 0)) . 'kg(元)'];
$rateName = '续重';
} else {
$columns[] = ['key' => 'base', 'label' => $prefix . '面单'];
$rateName = '费率';
}
$tiers = $structure['rate_tiers'] ?? [];
if ($tiers === []) {
$columns[] = ['key' => 'rates.0', 'label' => "{$rateName}(元/kg)"];
return $columns;
}
foreach (array_values($tiers) as $index => $edge) {
$lower = $index === 0 ? ($mode === 'flat_then' && $bands !== [] ? (float) end($bands) : 0.0) : (float) $tiers[$index - 1];
$label = self::kg($lower) . '-' . self::kg($edge) . 'kg';
$columns[] = ['key' => "rates.{$index}", 'label' => "{$rateName} {$label}"];
}
$columns[] = ['key' => 'rates.' . count($tiers), 'label' => "{$rateName} " . self::kg((float) end($tiers)) . 'kg以上'];
return $columns;
}
/**
* 按列顺序取一行的值。
*
* @param array<string,mixed> $row
* @param list<array{key:string,label:string}> $columns
* @return list<?float>
*/
public static function rowValues(array $row, array $columns): array
{
return array_map(static function (array $column) use ($row): ?float {
$value = data_get($row, $column['key']);
return is_numeric($value) ? (float) $value : null;
}, $columns);
}
/**
* 只保留填了价格的省份行,并按当前结构裁剪列。
*
* @param array<int|string,mixed> $rows
* @param array<string,mixed> $structure
* @return list<array{region:string,flat:list<?float>,base:?float,rates:list<?float>}>
*/
public static function normalizeRows(array $rows, array $structure): array
{
$flatCount = in_array($structure['mode'] ?? '', ['flat_bands', 'flat_then'], true) ? count($structure['flat_bands'] ?? []) : 0;
$rateCount = self::usesRates($structure['mode'] ?? '') ? count($structure['rate_tiers'] ?? []) + 1 : 0;
$number = static fn ($value): ?float => is_numeric($value) && (float) $value >= 0 ? round((float) $value, 4) : null;
$result = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$region = mb_substr(trim((string) ($row['region'] ?? '')), 0, 50);
$flat = [];
for ($i = 0; $i < $flatCount; $i++) {
$flat[] = $number(data_get($row, "flat.{$i}"));
}
$rates = [];
for ($i = 0; $i < $rateCount; $i++) {
$rates[] = $number(data_get($row, "rates.{$i}"));
}
$base = $rateCount > 0 ? $number($row['base'] ?? null) : null;
$filled = array_filter([...$flat, $base, ...$rates], static fn ($value): bool => $value !== null);
if ($region === '' || $filled === []) {
continue;
}
$result[] = ['region' => $region, 'flat' => $flat, 'base' => $base, 'rates' => $rates];
}
return $result;
}
/**
* 提交前的结构和逐行完整性检查,返回 字段 => 错误信息。
*
* @param array<string,mixed> $structure
* @param list<array<string,mixed>> $rows
* @return array<string,string>
*/
public static function validate(array $structure, array $rows): array
{
$mode = $structure['mode'] ?? '';
if ($mode === 'other') {
return [];
}
$errors = [];
if (in_array($mode, ['flat_bands', 'flat_then'], true) && ($structure['flat_bands'] ?? []) === []) {
$errors['quote_flat_bands'] = '请至少设置一档一口价重量,如 0-3kg,或 0-1、1-2、2-3kg 三档。';
}
if (self::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! ((float) ($structure['first_weight'] ?? 0) > 0)) {
$errors['quote_first_weight'] = '请填写首重公斤数,如 3 或 5。';
}
if ($mode === 'flat_then' && ($structure['flat_bands'] ?? []) !== [] && ($structure['rate_tiers'] ?? []) !== []
&& min($structure['rate_tiers']) <= max($structure['flat_bands'])) {
$errors['quote_rate_tiers'] = '超出部分的费率分段应大于一口价的最后一段重量。';
}
if ($errors !== []) {
return $errors;
}
if ($rows === []) {
return ['quote_rows' => '请至少填写一个省份的报价,系统会据此生成报价确认单发到 e签宝签署。'];
}
$columns = self::columns($structure);
foreach ($rows as $row) {
$values = self::rowValues($row, $columns);
$missing = [];
foreach ($values as $index => $value) {
if ($value === null) {
$missing[] = $columns[$index]['label'];
}
}
if ($missing !== []) {
$errors['quote_rows'] = "{$row['region']}还有价格没填:" . implode('、', $missing) . '。不发的省份整行留空即可。';
break;
}
}
return $errors;
}
/**
* 计费方式的文字说明,用于报价矩阵、PDF 和后台展示。
*
* @param array<string,mixed> $structure
*/
public static function describe(array $structure): string
{
$mode = $structure['mode'] ?? 'waybill_rate';
$bands = $structure['flat_bands'] ?? [];
$tierNote = ($structure['rate_tiers'] ?? []) !== [] ? '(按总重量所在区间取费率)' : '';
$fw = self::kg((float) ($structure['first_weight'] ?? 0));
return match ($mode) {
'first_additional' => "首重{$fw}kg内按首重价,超出:首重价+(重量-{$fw})*续重{$tierNote}",
'flat_bands' => '按重量段一口价,' . self::kg((float) (end($bands) ?: 0)) . 'kg以上不在报价范围',
'flat_then' => self::kg((float) (end($bands) ?: 0)) . 'kg以内按段一口价,超出:'
. (($structure['then_mode'] ?? '') === 'first_additional'
? "首重{$fw}kg价+(重量-{$fw})*续重"
: '面单+重量*费率') . $tierNote,
'other' => '特殊计费,见附件报价表',
default => '面单+重量*费率' . $tierNote,
};
}
public static function usesRates(string $mode): bool
{
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
}
public static function usesFirstWeight(string $mode, string $thenMode = ''): bool
{
return $mode === 'first_additional' || ($mode === 'flat_then' && $thenMode === 'first_additional');
}
public static function kg(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}

View File

@ -0,0 +1,483 @@
<?php
namespace App\Support;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 已入驻快递的后台变价:我方在入驻同款报价页里改价格或计费方式,
* 提交成报价变更单 → 审核通过生成新版本 → 按入驻资料推送给快递 e签宝签署 → 签完回传归档。
* 同一家快递始终只有一条入驻记录,报价按版本往后接。
*/
final class CourierRequoteService
{
public const CHANGE_PRICE = '变价';
public const CHANGE_STRUCTURE = '重新报价';
/** 单项涨跌超过 30% 在审核页提醒。 */
public const LARGE_CHANGE = 0.3;
/** 报价管理里的报价类型:区分入驻首版和后续变价。 */
public static function quoteTypeFor(string $changeType): string
{
return $changeType === self::CHANGE_STRUCTURE ? '快递重新报价' : '快递变价报价';
}
public function __construct(private readonly CourierOnboardingService $onboardings)
{
}
/** 报价版本属于哪家已入驻快递(按系统快递名称对应)。 */
public function onboardingForCourier(?string $courierName): ?CourierOnboarding
{
if (blank($courierName)) {
return null;
}
return CourierOnboarding::query()
->where('courier_name', $courierName)
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
->latest('id')
->first();
}
/**
* 已入驻快递的系统名称 => 入驻 id,报价管理列表一次取出。
*
* @return array<string,int>
*/
public function onboardedCourierIds(): array
{
return CourierOnboarding::query()
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
->whereNotNull('courier_name')
->orderBy('id')
->pluck('id', 'courier_name')
->all();
}
/** 这家快递最新的报价版本(含待签署的)。 */
public function latestVersion(CourierOnboarding $onboarding): ?CourierQuoteVersion
{
return CourierQuoteVersion::query()
->with('signature')
->where('company_name', $onboarding->courier_name)
->where('status', 'approved')
->latest('id')
->first();
}
/** @return \Illuminate\Support\Collection<int,CourierQuoteVersion> */
public function versions(CourierOnboarding $onboarding): \Illuminate\Support\Collection
{
return CourierQuoteVersion::query()
->with('signature')
->where('company_name', $onboarding->courier_name)
->where('status', 'approved')
->latest('id')
->get();
}
/** 还没审核完的变价单(待审核或已退回),同一时间只允许一张。 */
public function openChange(CourierOnboarding $onboarding): ?CourierQuoteChange
{
return CourierQuoteChange::query()
->where('carrier_name', $onboarding->courier_name)
->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT])
->whereNotNull('quote_payload')
->latest('id')
->first();
}
/**
* 当前生效(最新)版本的报价结构,作为变价底稿。
*
* @return array<string,mixed>
*/
public function currentPayload(CourierOnboarding $onboarding): array
{
// 以最新的长期报价为底稿:临时变价到期就作废,不该成为下一次变价的起点。
$version = $this->versions($onboarding)->first(static fn (CourierQuoteVersion $item): bool => ! $item->isTemporary())
?? $this->latestVersion($onboarding);
$change = $version?->source_change_id ? CourierQuoteChange::query()->find($version->source_change_id) : null;
return $change?->hasQuotePayload() ? $change->quote_payload : $this->onboardings->quotePayload($onboarding);
}
/** 用报价结构生成一份临时的入驻对象,复用入驻的报价列、规则文字和报价矩阵。 */
public function draftModel(CourierOnboarding $onboarding, array $payload): CourierOnboarding
{
$model = $onboarding->replicate();
$model->id = $onboarding->id;
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
$model->setAttribute($field, $payload[$field] ?? null);
}
$model->setRelation('files', collect());
return $model;
}
/**
* 提交变价单(新建或更新已退回的那张),进入待审核。
*
* @param array<string,mixed> $data
*/
public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange
{
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']);
}
$attributes = $this->onboardings->quoteAttributes($data);
$structure = $attributes['quote_structure'];
if (($structure['mode'] ?? '') === 'other') {
throw ValidationException::withMessages(['quote_mode' => '后台变价请选择具体的计费方式。']);
}
$errors = CourierQuoteStructure::validate($structure, $attributes['quote_rows']);
if (blank($attributes['quote_effective_date'])) {
$errors['quote_effective_date'] = '请填写新报价的执行日期。';
}
// 结束日期选填:填了就是短期(临时)变价,到期后自动回到原来的长期报价。
$endDate = filled($data['quote_end_date'] ?? null) ? Carbon::parse((string) $data['quote_end_date'])->startOfDay() : null;
if ($endDate !== null && filled($attributes['quote_effective_date']) && $endDate->lt(Carbon::parse($attributes['quote_effective_date'])->startOfDay())) {
$errors['quote_end_date'] = '结束日期不能早于执行日期。';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
$payload = [];
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
$payload[$field] = $attributes[$field] ?? null;
}
$current = $this->currentPayload($onboarding);
if ($this->comparable($payload) === $this->comparable($current)) {
throw ValidationException::withMessages(['quote_rows' => '报价和当前版本完全一样,没有需要变更的内容。']);
}
$open = $this->openChange($onboarding);
if ($open !== null && $open->status === CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。"]);
}
$version = $this->latestVersion($onboarding);
$effectiveDate = Carbon::parse($payload['quote_effective_date'])->startOfDay();
$draft = $this->draftModel($onboarding, $payload);
$values = $this->onboardings->quoteMatrix($draft, $draft->quoteRowList(), $effectiveDate);
$type = $this->sameStructure($payload, $current) ? self::CHANGE_PRICE : self::CHANGE_STRUCTURE;
return DB::transaction(function () use ($onboarding, $data, $userId, $payload, $values, $version, $effectiveDate, $endDate, $type, $open): CourierQuoteChange {
$attributes = [
'source_quote_id' => $version?->version_code ?? ('RZ-' . $onboarding->id),
'source_sheet_name' => $version?->sheet_name ?? ($onboarding->courier_name . '报价'),
'carrier_name' => $onboarding->courier_name,
'quote_type' => $version?->quote_type ?? '快递报价',
'change_type' => $type,
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
'effective_date' => $effectiveDate,
'end_date' => $endDate,
'is_retrospective' => $effectiveDate->lt(today()),
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => trim((string) $data['summary']),
'submitted_by' => $userId,
'submitted_at' => now(),
'quote_payload' => $payload,
'proposed_values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
if ($open !== null) {
$from = $open->status;
$open->update($attributes);
$change = $open;
} else {
$from = null;
$change = CourierQuoteChange::create([
'company_id' => company_id(),
'number' => $this->nextChangeNumber(),
'created_by' => $userId,
...$attributes,
]);
}
CourierQuoteChangeEvent::create([
'company_id' => company_id(),
'quote_change_id' => $change->id,
'event_type' => 'submitted',
'from_status' => $from,
'to_status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
'note' => "后台{$type}:{$onboarding->courier_name},自 {$effectiveDate->toDateString()} 起执行。",
'created_by' => $userId,
]);
return $change->fresh();
});
}
/**
* 新旧报价对比:同一计费方式时逐格列出变动;计费方式变了则说明结构变化。
*
* @return array{same_structure:bool,old_mode:string,new_mode:string,old_columns:list<string>,new_columns:list<string>,cells:list<array{region:string,label:string,old:?float,new:?float}>,added:list<string>,removed:list<string>,rules:list<array{old:string,new:string}>}
*/
public function diff(CourierOnboarding $onboarding, array $old, array $new): array
{
$oldModel = $this->draftModel($onboarding, $old);
$newModel = $this->draftModel($onboarding, $new);
$oldColumns = $oldModel->quoteColumns();
$newColumns = $newModel->quoteColumns();
$oldRows = collect($oldModel->quoteRowList())->keyBy('region');
$newRows = collect($newModel->quoteRowList())->keyBy('region');
$same = $this->sameStructure($new, $old);
$cells = [];
if ($same) {
foreach ($newRows as $region => $row) {
if (! $oldRows->has($region)) {
continue;
}
$before = CourierQuoteStructure::rowValues($oldRows->get($region), $oldColumns);
$after = CourierQuoteStructure::rowValues($row, $newColumns);
foreach ($newColumns as $index => $column) {
if (round((float) ($before[$index] ?? 0), 4) !== round((float) ($after[$index] ?? 0), 4) || ($before[$index] === null) !== ($after[$index] === null)) {
$cells[] = ['region' => (string) $region, 'label' => $column['label'], 'old' => $before[$index], 'new' => $after[$index]];
}
}
}
}
$oldRules = $oldModel->quoteRuleLines();
$newRules = $newModel->quoteRuleLines();
$rules = [];
foreach (array_unique([...array_keys($oldRules), ...array_keys($newRules)]) as $index) {
if (($oldRules[$index] ?? '') !== ($newRules[$index] ?? '')) {
$rules[] = ['old' => (string) ($oldRules[$index] ?? '—'), 'new' => (string) ($newRules[$index] ?? '—')];
}
}
if (($old['quote_note'] ?? null) !== ($new['quote_note'] ?? null)) {
$rules[] = ['old' => '备注:' . ($old['quote_note'] ?: '无'), 'new' => '备注:' . ($new['quote_note'] ?: '无')];
}
return [
'same_structure' => $same,
'old_mode' => CourierQuoteStructure::describe($oldModel->quoteStructure()),
'new_mode' => CourierQuoteStructure::describe($newModel->quoteStructure()),
'old_columns' => array_column($oldColumns, 'label'),
'new_columns' => array_column($newColumns, 'label'),
'cells' => $cells,
'added' => $newRows->keys()->diff($oldRows->keys())->values()->all(),
'removed' => $oldRows->keys()->diff($newRows->keys())->values()->all(),
'rules' => $rules,
];
}
/** 变价单对应的底稿(变价前的版本)报价结构。 */
public function basePayload(CourierOnboarding $onboarding, CourierQuoteChange $change): array
{
$source = CourierQuoteVersion::query()->where('version_code', $change->source_quote_id)->first();
$sourceChange = $source?->source_change_id ? CourierQuoteChange::query()->find($source->source_change_id) : null;
return $sourceChange?->hasQuotePayload() ? $sourceChange->quote_payload : $this->onboardings->quotePayload($onboarding);
}
/**
* 作废发错的报价版本。还没签完的同时撤回 e签宝 签署任务;已签完的只做内部作废(原件、回签单保留),
* 需另发一版正确报价让快递重新签。
*
* @return string 给操作人看的结果说明
*/
public function voidVersion(CourierQuoteVersion $version, string $reason, int $userId): string
{
if ($version->isVoided()) {
throw ValidationException::withMessages(['void_reason' => "{$version->version_code} 已经作废。"]);
}
$signature = $version->signature()->first();
$signed = $signature !== null && in_array($signature->status, [
\App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED,
\App\Models\Business\CourierQuoteSignature::STATUS_COMPLETED,
], true);
$note = '';
if ($signature !== null && ! $signed && filled($signature->esign_sign_flow_id)
&& $signature->status === \App\Models\Business\CourierQuoteSignature::STATUS_SIGNING) {
try {
app(\App\Services\Esign\EsignClient::class)->revokeSignFlow((string) $signature->esign_sign_flow_id, '报价发错作废:' . $reason);
} catch (\Throwable $exception) {
throw ValidationException::withMessages(['void_reason' => '撤回 e签宝 签署任务失败:' . mb_substr($exception->getMessage(), 0, 200) . '。可先点「同步签署状态」确认快递是否已签。']);
}
$signature->update(['status' => \App\Models\Business\CourierQuoteSignature::STATUS_REVOKED, 'updated_by' => $userId]);
$note = '已撤回快递 e签宝 里的待签任务。';
} elseif ($signed) {
$note = '快递已签署,回签单保留;请另发一版正确报价让快递重新签署。';
}
DB::transaction(function () use ($version, $reason, $userId, $signature, $signed, $note): void {
$version->update([
'status' => CourierQuoteVersion::STATUS_VOIDED,
'signature_status' => $signed ? $version->signature_status : ($signature !== null ? '已撤销' : $version->signature_status),
'void_reason' => $reason,
'voided_at' => now(),
'voided_by' => $userId,
]);
if ($version->source_change_id) {
CourierQuoteChangeEvent::create([
'company_id' => $version->company_id,
'quote_change_id' => $version->source_change_id,
'event_type' => 'voided',
'from_status' => null,
'to_status' => null,
'note' => "报价版本 {$version->version_code} 已作废:{$reason}。{$note}",
'created_by' => $userId,
]);
}
});
return "{$version->version_code} 已作废,不再参与生效。" . $note;
}
/** 这家快递当前生效的版本:已签署、未作废、执行日期已到中最新的一版。 */
/**
* 某一天(默认今天)执行的报价版本:已签署、未作废、执行期间覆盖这一天,取开始日期最晚的一版。
* 短期变价期间用临时价,到期后自动回到原来的长期报价。
*/
public function effectiveVersion(string $courierName, ?\DateTimeInterface $date = null): ?CourierQuoteVersion
{
$versions = CourierQuoteVersion::query()
->with('signature')
->where('company_name', $courierName)
->where('status', CourierQuoteVersion::STATUS_APPROVED)
->get();
return self::resolveAt($versions, $date ?? today());
}
/**
* @param \Illuminate\Support\Collection<int,CourierQuoteVersion> $versions 同一家快递的版本
* @param bool $includeTemporary false 时只看长期报价(算「临时价到期后回到哪一版」)
*/
public static function resolveAt(\Illuminate\Support\Collection $versions, \DateTimeInterface $date, bool $includeTemporary = true): ?CourierQuoteVersion
{
return $versions
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
&& self::isSignedVersion($version)
&& ($includeTemporary || ! $version->isTemporary())
&& $version->coversDate($date))
->sortBy([
static fn ($a, $b) => $b->effective_date <=> $a->effective_date,
static fn ($a, $b) => $b->id <=> $a->id,
])
->first();
}
public static function isSignedVersion(CourierQuoteVersion $version): bool
{
return $version->signature_status === '已签署并归档'
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
/**
* 审核提醒:倒签、价格为 0、涨跌幅过大。
*
* @param array<string,mixed>|null $diff
* @return list<string>
*/
public function reviewWarnings(CourierQuoteChange $change, ?array $diff): array
{
$warnings = [];
$current = $this->effectiveVersion((string) $change->carrier_name);
if ($current !== null && $change->effective_date->lt($current->effective_date)) {
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于当前生效版本 {$current->version_code}({$current->effective_date->toDateString()} 起),属于倒签,会改动已发生运单的运费。";
} elseif ($change->effective_date->lt(today())) {
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于今天,属于倒签,会改动已发生运单的运费。";
}
if ($change->end_date !== null) {
$others = CourierQuoteVersion::query()
->where('company_name', (string) $change->carrier_name)
->where('status', CourierQuoteVersion::STATUS_APPROVED)
->where(static fn ($query) => $query->whereNull('source_change_id')->orWhere('source_change_id', '!=', $change->id))
->get();
$base = self::resolveAt($others, $change->end_date->copy()->addDay(), false);
$warnings[] = $base !== null
? "这是短期变价:{$change->effective_date->toDateString()} 至 {$change->end_date->toDateString()} 执行,{$change->end_date->copy()->addDay()->toDateString()} 起自动恢复 {$base->version_code}({$base->periodLabel()})。"
: "这是短期变价,但到期后({$change->end_date->copy()->addDay()->toDateString()} 起)没有可以恢复的已签署长期报价,到期后这家快递将没有生效报价。";
$overlap = $others->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $version->isTemporary()
&& $version->effective_date->lte($change->end_date) && $version->end_date->gte($change->effective_date));
if ($overlap->isNotEmpty()) {
$warnings[] = '和已有的短期变价执行期间重叠:' . $overlap->map(static fn (CourierQuoteVersion $version): string => $version->version_code . '(' . $version->periodLabel() . ')')->implode('、') . ',重叠的日期以开始日期较晚的一份为准。';
}
}
$big = [];
foreach ($diff['cells'] ?? [] as $cell) {
$old = $cell['old'];
$new = $cell['new'];
if ($old !== null && $new !== null && (float) $old > 0 && abs((float) $new - (float) $old) / (float) $old > self::LARGE_CHANGE) {
$big[] = $cell['region'] . ' ' . $cell['label'] . ' ' . CourierQuoteStructure::kg((float) $old) . '→' . CourierQuoteStructure::kg((float) $new);
}
}
if ($big !== []) {
$warnings[] = '涨跌超过 ' . (int) (self::LARGE_CHANGE * 100) . '%:' . implode(';', array_slice($big, 0, 8)) . (count($big) > 8 ? ' 等 ' . count($big) . ' 处' : '') . ',请核对是否多打或少打了位数。';
}
$zeros = [];
$table = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable(json_decode((string) $change->proposed_values_json, true) ?: []);
foreach (array_slice($table, 1) as $row) {
foreach (array_slice($row, 1, null, true) as $index => $value) {
if (is_numeric($value) && (float) $value === 0.0) {
$zeros[] = $row[0] . ' ' . ($table[0][$index] ?? '');
}
}
}
if ($zeros !== []) {
$warnings[] = '价格为 0:' . implode('、', array_slice($zeros, 0, 8)) . (count($zeros) > 8 ? ' 等 ' . count($zeros) . ' 处' : '') . '。';
}
return $warnings;
}
private function sameStructure(array $a, array $b): bool
{
$shape = static fn (array $payload): array => CourierQuoteStructure::columns(
is_array($payload['quote_structure'] ?? null) ? $payload['quote_structure'] : []
);
return $shape($a) === $shape($b);
}
private function comparable(array $payload): string
{
$rows = array_map(static fn (array $row): array => [
'region' => $row['region'] ?? '',
'flat' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['flat'] ?? [])),
'base' => isset($row['base']) ? round((float) $row['base'], 4) : null,
'rates' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['rates'] ?? [])),
], array_values(array_filter((array) ($payload['quote_rows'] ?? []), 'is_array')));
$copy = $payload;
$copy['quote_rows'] = $rows;
unset($copy['quote_effective_date']);
foreach (['min_weight', 'per_ticket_surcharge'] as $key) {
$copy[$key] = isset($copy[$key]) ? round((float) $copy[$key], 4) : null;
}
$copy['volumetric_divisor'] = isset($copy['volumetric_divisor']) ? (int) $copy['volumetric_divisor'] : null;
ksort($copy);
return json_encode($copy, JSON_UNESCAPED_UNICODE);
}
private function nextChangeNumber(): string
{
$prefix = 'BG-' . now()->format('Ymd') . '-';
$last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number');
$sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1;
return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Support;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Facades\Schema;
use JsonException;
@ -17,24 +18,32 @@ final class QuoteManagementSnapshot
public function load(): array
{
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
$versions = $this->versionQuotes();
$payload = null;
$failure = null;
if (! is_file($path)) {
return $this->emptySnapshot('missing', '尚未导入本机报价文件。');
$failure = $this->emptySnapshot('missing', '尚未导入本机报价文件。');
} else {
try {
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$failure = $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。');
}
if ($failure === null && (! is_array($payload) || ! is_array($payload['sheets'] ?? null))) {
$failure = $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。');
}
}
try {
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。');
}
if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) {
return $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。');
// 没有 Excel 快照时,系统内生成的报价版本(变价、快递入驻)照常显示。
if ($failure !== null && $versions === []) {
return $failure;
}
$quotes = [];
foreach (array_values($payload['sheets']) as $position => $sheet) {
foreach (array_values($failure === null ? $payload['sheets'] : []) as $position => $sheet) {
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
continue;
}
@ -42,17 +51,13 @@ final class QuoteManagementSnapshot
$quotes[] = $this->normalizeSheet($sheet, $position);
}
if (Schema::hasTable('business_courier_quote_versions')) {
foreach (CourierQuoteVersion::query()->orderBy('id')->get() as $version) {
$quotes[] = $this->normalizeVersion($version);
}
}
array_push($quotes, ...$versions);
return [
'available' => true,
'state' => 'ready',
'message' => null,
'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '报价单合并.xlsx'))),
'source_file' => basename(str_replace('\\', '/', (string) (($payload['sourcePath'] ?? null) ?: '报价单合并.xlsx'))),
'quotes' => $quotes,
'summary' => [
'quote_count' => count($quotes),
@ -63,6 +68,44 @@ final class QuoteManagementSnapshot
];
}
/** @return list<array<string,mixed>> */
private function versionQuotes(): array
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return [];
}
// 已入驻快递的版本,「变价」直接进后台变价页(不经过 …/create,避免被套餐限制中间件拦到用户页)。
$onboarded = Schema::hasTable('business_courier_onboardings')
? app(CourierRequoteService::class)->onboardedCourierIds()
: [];
$versions = CourierQuoteVersion::query()->with('signature')->orderBy('id')->get();
$superseded = $this->supersededVersions($versions);
$changeTypes = \App\Models\Business\CourierQuoteChange::query()
->whereIn('id', $versions->pluck('source_change_id')->filter()->unique()->all())
->pluck('change_type', 'id')
->all();
return $versions
->map(function (CourierQuoteVersion $version) use ($onboarded, $changeTypes, $superseded): array {
$quote = $this->normalizeVersion($version);
$quote['void_reason'] = $version->isVoided() ? $version->void_reason : null;
if (isset($superseded[$version->id])) {
$quote = [...$quote, ...$superseded[$version->id]];
}
$changeType = $changeTypes[$version->source_change_id] ?? null;
if (in_array($changeType, [CourierRequoteService::CHANGE_PRICE, CourierRequoteService::CHANGE_STRUCTURE], true)) {
$quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType);
}
$onboardingId = $onboarded[$version->company_name] ?? null;
$quote['requote_url'] = $onboardingId ? route('business.courier-onboardings.requote', $onboardingId) : null;
return $quote;
})
->all();
}
private function normalizeSheet(array $sheet, int $position): array
{
$name = trim((string) $sheet['name']);
@ -84,6 +127,8 @@ final class QuoteManagementSnapshot
'formula_count' => $this->formulaCount($formulas),
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
'signature_status' => 'Excel 未提供',
'signed_files' => [],
'signed_at' => null,
'values' => $values,
'formulas' => $formulas,
];
@ -110,6 +155,8 @@ final class QuoteManagementSnapshot
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'end_date' => $version->end_date?->toDateString(),
'period_label' => $version->isTemporary() ? $version->periodLabel() : null,
'source_range' => $version->source_range ?? '',
'row_count' => count($values),
'column_count' => $columnCount,
@ -119,9 +166,35 @@ final class QuoteManagementSnapshot
'values' => $values,
'formulas' => $formulas,
'source_change_id' => $version->source_change_id,
'signed_files' => $this->signedFiles($version),
'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i')
?? $version->signature?->archived_at?->format('Y-m-d H:i'),
];
}
/**
* 已签署归档的回签单电子档下载地址(签署 PDF、签署完成证书、批量导出 zip)。
*
* @return array<string,string>
*/
private function signedFiles(CourierQuoteVersion $version): array
{
$signature = $version->signature;
if ($signature === null || $signature->status !== CourierQuoteSignature::STATUS_ARCHIVED || blank($signature->signed_pdf_path)) {
return [];
}
$files = [
'signed' => route('business.courier-quote-signatures.download', [$signature, 'signed']),
'export' => route('business.courier-quote-signatures.sheet', $version),
];
if (filled($signature->certificate_path)) {
$files['certificate'] = route('business.courier-quote-signatures.download', [$signature, 'certificate']);
}
return $files;
}
private function decodeMatrix(?string $json): array
{
if ($json === null || trim($json) === '') {
@ -137,11 +210,63 @@ final class QuoteManagementSnapshot
private function versionLifecycleStatus(CourierQuoteVersion $version): string
{
if ($version->signature_status !== '已签署并归档') {
if ($version->isVoided()) {
return '已作废';
}
if (! $this->isSigned($version)) {
return '待生效(待签章)';
}
return $version->effective_date?->isFuture() ? '待生效' : '生效中';
if ($version->effective_date?->isFuture()) {
return $version->isTemporary() ? '待生效(临时)' : '待生效';
}
return $version->isTemporary() ? '生效中(临时)' : '生效中';
}
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
private function isSigned(CourierQuoteVersion $version): bool
{
return $version->signature_status === '已签署并归档'
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
/**
* 同一家快递有多个已签署版本时,今天执行的那一版是「生效中」;
* 短期变价期间,原来的长期报价显示「暂停中」,到期后自动恢复;过了结束日期的短期变价显示「已到期」;
* 其余更早的版本标为「已失效」并注明被哪一版替代。
*
* @param \Illuminate\Support\Collection<int,CourierQuoteVersion> $versions
* @return array<int,array{status:string,superseded_by?:string,status_note?:string}> 版本 id => 状态覆盖
*/
private function supersededVersions(\Illuminate\Support\Collection $versions): array
{
$result = [];
$today = today();
foreach ($versions->groupBy('company_name') as $group) {
$current = CourierRequoteService::resolveAt($group, $today);
$base = CourierRequoteService::resolveAt($group, $today, false);
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version)
&& $version->effective_date !== null && $version->effective_date->lte($today));
foreach ($started as $version) {
if ($current !== null && $version->id === $current->id) {
if ($version->isTemporary() && $base !== null) {
$result[$version->id] = ['status' => '生效中(临时)', 'status_note' => $version->end_date->copy()->addDay()->toDateString() . ' 起恢复 ' . $base->version_code];
}
continue;
}
if ($version->isTemporary() && $version->end_date->lt($today)) {
$result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $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) {
$result[$version->id] = ['status' => '已失效', 'superseded_by' => $current->version_code];
}
}
}
return $result;
}
private function normalizeMatrix(mixed $matrix): array

View File

@ -149,25 +149,30 @@ class SidebarPermissions
}
unset($section);
// 「财务经营风险管控」新增「流程统计」子页面:按页面键保存过精确权限的账号只会
// 有 store-violation-alert,会看不到新页面;这里按「能看店铺违规预警 就能看流程统计」
// 兼容旧配置(两页同属财务经营风险管控权限)。
// 「财务经营风险管控」新增「预览统计 / 流程统计」子页面:按页面键保存过精确权限的
// 账号只会有一条或两条,会看不到新页面;这里按「能看店铺违规预警 就能看新增页」
// 兼容旧配置(同属财务经营风险管控权限)。
foreach ($navigation as &$riskSection) {
if (($riskSection['slug'] ?? null) !== 'financial-risk-control') {
continue;
}
$pageSlugs = array_column($riskSection['pages'] ?? [], 'slug');
if (! in_array('store-violation-alert', $pageSlugs, true) || in_array('process-statistics', $pageSlugs, true)) {
if (! in_array('store-violation-alert', $pageSlugs, true)) {
continue;
}
$configuredPages = collect(BusinessNavigation::all())
->firstWhere('slug', 'financial-risk-control')['pages'] ?? [];
$statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics');
if ($statisticsPage) {
$riskSection['pages'][] = $statisticsPage;
foreach ($configuredPages as $configuredPage) {
$configuredSlug = (string) ($configuredPage['slug'] ?? '');
if (! in_array($configuredSlug, ['preview-statistics', 'process-statistics'], true)) {
continue;
}
if (! in_array($configuredSlug, $pageSlugs, true)) {
$riskSection['pages'][] = $configuredPage;
}
}
}
unset($riskSection);

View File

@ -714,7 +714,6 @@ $navigation = [
'title' => '附加费报价维护',
'tabs' => [
['slug' => 'quotes', 'title' => '附加费报价'],
['slug' => 'settlement', 'title' => '附加费结算'],
],
],
],
@ -723,7 +722,7 @@ $navigation = [
['slug' => 'shipping-data', 'title' => '发货数据'],
['slug' => 'return-pickup-management', 'title' => '退货取件管理'],
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
['slug' => 'warehouse-freight', 'title' => '仓储运费'],
['slug' => 'warehouse-freight', 'title' => '仓储费'],
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
['slug' => 'compensation-details', 'title' => '赔偿明细'],
['slug' => 'advance-details', 'title' => '借支明细'],
@ -808,7 +807,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -823,8 +822,8 @@ $navigation = [
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
['name' => 'match_status', 'label' => '匹配状态'],
['name' => 'remark', 'label' => '差额异常备注'],
['name' => 'match_status', 'label' => '匹配状态'],
['name' => 'unmatched_reason', 'label' => '未匹配原因'],
],
],
@ -873,6 +872,7 @@ $navigation = [
'icon' => 'security',
'pages' => [
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
['slug' => 'preview-statistics', 'title' => '预览统计'],
[
'slug' => 'process-statistics',
'title' => '流程统计',

View File

@ -0,0 +1,10 @@
<?php
return [
// 二维码 / 链接里给快递方打开的站点地址。留空时用当前访问地址;
// 生产填公网地址(如 https://caiwu.jiansuntuzhi.cn:8442),保证内网生成的二维码手机也能打开。
'public_url' => env('COURIER_ONBOARDING_PUBLIC_URL'),
// 是否允许初审人自己做财务复审。默认不允许(两人把关);测试/预览环境可打开方便一个人走完流程。
'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false),
];

View File

@ -12,17 +12,26 @@
return [
// 统一表头(顺序即页面、导出、导入模板的列顺序)
// auto=自动计算列(导入忽略、导出可见);virtual=查询时计算;system=系统回写(导入忽略)。
'columns' => [
['key' => 'shop_name', 'label' => '店铺名'],
['key' => 'alert_level', 'label' => '预警级别', 'auto' => true],
['key' => 'violation_type', 'label' => '违规类型'],
['key' => 'violation_category', 'label' => '违规分类'],
['key' => 'violation_reason', 'label' => '违规原因'],
['key' => 'disposition', 'label' => '处置方式'],
['key' => 'violation_no', 'label' => '违规单号'],
['key' => 'violation_date', 'label' => '违规日期'],
['key' => 'points_deducted', 'label' => '扣分'],
['key' => 'owner_name', 'label' => '责任人', 'auto' => true],
['key' => 'status', 'label' => '状态'],
['key' => 'penalty_date', 'label' => '处罚日期'],
['key' => 'appeal_deadline', 'label' => '申诉截止日期'],
['key' => 'handling_deadline', 'label' => '处理截止', 'auto' => true],
['key' => 'is_overdue', 'label' => '是否超时', 'virtual' => true],
['key' => 'push_status', 'label' => '推送状态', 'system' => true],
['key' => 'handling_status', 'label' => '处理状态'],
['key' => 'ignore_reason', 'label' => '忽略原因'],
],
// 页面平台页签;source_values 为上游/导入文件中平台字段的取值
@ -36,14 +45,51 @@ return [
],
// 处理状态(本地维护)
'handling_statuses' => ['未处理', '处理中', '已处理', '无需处理'],
'handling_statuses' => ['未处理', '处理中', '处理完成', '已忽略'],
'default_handling_status' => '未处理',
// 历史取值兼容:导入时归一化到新四档
'handling_status_legacy' => ['已处理' => '处理完成', '无需处理' => '已忽略'],
// 预警级别(页面标签颜色)与处理时效默认(违规日期起算的天数,当天 23:59:59 到期)
'alert_levels' => [
'P0' => ['label' => 'P0-严重', 'tone' => 'is-danger'],
'P1' => ['label' => 'P1-较重', 'tone' => 'is-warning'],
'P2' => ['label' => 'P2-提醒', 'tone' => 'is-success'],
],
'alert_default_days' => ['P0' => 1, 'P1' => 2, 'P2' => 3],
// 推送状态
'push_statuses' => ['未推送', '已推送', '推送失败'],
// 默认视图:只展示需提醒且未处理的记录,其余数据只入库不打扰
'default_view' => ['needs_alert' => true, 'handling_status' => '未处理'],
// 群机器人推送(先留接口,配置后启用;窗口从每天 09:00 开始)
'push' => [
'enabled' => false,
'channel' => 'dingtalk_robot',
'webhook' => '',
'secret' => '',
'window_start' => '09:00',
'window_end' => '21:00',
'instant_levels' => ['P0', 'P1'],
'digest_time' => '10:00',
'at_all' => false,
],
// 导入表头别名 → 统一列键(不区分大小写、忽略空格)
'header_aliases' => [
'shop_name' => ['店铺名', '店铺名称', '店铺', '商家名称', '商家', 'shop_name', 'shopname'],
'violation_type' => ['违规类型', '违规内容', '违规行为', '违规原因', '违规描述', 'violation_type'],
'violation_type' => ['违规类型', '违规内容', '违规行为', '违规描述', 'violation_type'],
'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'],
'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'],
'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'],
'alert_level' => ['预警级别', '预警等级', 'alert_level'],
'owner_name' => ['责任人', '店铺负责人', '运营负责人', 'owner_name'],
'handling_deadline' => ['处理截止', '处理截止时间', '处理时限', 'handling_deadline'],
'is_overdue' => ['是否超时', '是否逾期', 'is_overdue'],
'push_status' => ['推送状态', 'push_status'],
'ignore_reason' => ['忽略原因', '忽略理由', 'ignore_reason'],
'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'],
'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'],
'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'],

View File

@ -18,7 +18,9 @@ return new class extends Migration
$table->string('invite_label', 100)->nullable();
$table->timestamp('expires_at')->nullable();
// 快递方填写:主体与联系人
// 快递方填写:主体与联系人(company 公司 / person 个人)
$table->string('entity_type', 16)->default('company');
$table->text('id_number')->nullable();
$table->string('company_full_name')->nullable();
$table->string('unified_social_credit_code', 32)->nullable();
$table->string('legal_representative', 100)->nullable();
@ -31,6 +33,8 @@ return new class extends Migration
$table->string('finance_contact_name', 100)->nullable();
$table->string('finance_contact_phone', 32)->nullable();
$table->text('pickup_warehouses')->nullable();
$table->string('esign_operator_name', 100)->nullable();
$table->string('esign_account')->nullable();
// 快递方填写:结算与开票
$table->string('bank_account_name')->nullable();
@ -45,7 +49,11 @@ return new class extends Migration
$table->string('volumetric_rule', 32)->nullable();
$table->unsignedInteger('volumetric_divisor')->nullable();
$table->date('quote_effective_date')->nullable();
$table->longText('quote_structure')->nullable();
$table->longText('quote_rows')->nullable();
$table->decimal('min_weight', 8, 2)->nullable();
$table->string('weight_rounding', 16)->nullable();
$table->decimal('per_ticket_surcharge', 8, 2)->nullable();
$table->text('quote_note')->nullable();
$table->text('remark')->nullable();

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 快递入驻后的后台变价:变价单直接保存完整报价结构(计费方式、分档、各省价格、附加规则)
* 和审核通过后要写入新版本的报价矩阵,支持计费方式整体调整。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_changes')) {
return;
}
Schema::table('business_courier_quote_changes', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_changes', 'quote_payload')) {
$table->longText('quote_payload')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_changes', 'proposed_values_json')) {
$table->longText('proposed_values_json')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_changes')) {
return;
}
Schema::table('business_courier_quote_changes', function (Blueprint $table): void {
foreach (['quote_payload', 'proposed_values_json'] as $column) {
if (Schema::hasColumn('business_courier_quote_changes', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本作废:发错的版本标记为 voided,记录原因、时间和操作人,不再参与生效判断。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'void_reason')) {
$table->text('void_reason')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'voided_at')) {
$table->timestamp('voided_at')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'voided_by')) {
$table->unsignedInteger('voided_by')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
foreach (['void_reason', 'voided_at', 'voided_by'] as $column) {
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* dewu_settlement_bill_rows 增加 (online_order_no, bill_item) 索引:
* 业绩明细「订单实付未减联合营销费」备注的证据子查询按这两个字段逐单关联,
* 此前没有可用索引会退化为全表扫描(7 月 288 单 × 3 处 ≈ 69 秒),补索引后为毫秒级。
*/
return new class extends Migration
{
private const INDEX = 'dewu_bill_order_item_idx';
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('dewu_settlement_bill_rows') || $this->hasIndex()) {
return;
}
$schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void {
$table->index(['online_order_no', 'bill_item'], self::INDEX);
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('dewu_settlement_bill_rows') || ! $this->hasIndex()) {
return;
}
$schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void {
$table->dropIndex(self::INDEX);
});
}
private function hasIndex(): bool
{
return (bool) DB::connection($this->connection())->selectOne(
"SELECT COUNT(*) AS c FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?",
['dewu_settlement_bill_rows', self::INDEX],
)->c;
}
};

View File

@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_violation_records')) {
return;
}
$missing = [];
foreach (['violation_reason', 'disposition'] as $column) {
if (! $schema->hasColumn('store_violation_records', $column)) {
$missing[] = $column;
}
}
if ($missing === []) {
return;
}
$schema->table('store_violation_records', function (Blueprint $table) use ($missing): void {
if (in_array('violation_reason', $missing, true)) {
$table->string('violation_reason', 500)->nullable()->after('violation_category');
}
if (in_array('disposition', $missing, true)) {
$table->string('disposition', 500)->nullable()->after('violation_reason');
}
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_violation_records')) {
return;
}
foreach (['disposition', 'violation_reason'] as $column) {
if ($schema->hasColumn('store_violation_records', $column)) {
$schema->table('store_violation_records', function (Blueprint $table) use ($column): void {
$table->dropColumn($column);
});
}
}
}
};

View File

@ -0,0 +1,104 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:预警工作流字段(级别/责任人/处理截止/忽略/推送状态)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
$table = 'store_violation_records';
if (! $schema->hasTable($table)) {
return;
}
$columns = [
'owner_name' => fn (Blueprint $t) => $t->string('owner_name', 64)->nullable()->after('shop_name'),
'alert_level' => fn (Blueprint $t) => $t->string('alert_level', 8)->nullable()->after('violation_category'),
'needs_alert' => fn (Blueprint $t) => $t->boolean('needs_alert')->default(false)->after('alert_level'),
'alert_rule_id' => fn (Blueprint $t) => $t->unsignedBigInteger('alert_rule_id')->nullable()->after('needs_alert'),
'alert_action' => fn (Blueprint $t) => $t->string('alert_action', 500)->nullable(),
'alert_channel' => fn (Blueprint $t) => $t->string('alert_channel', 64)->nullable(),
'handling_hours' => fn (Blueprint $t) => $t->unsignedInteger('handling_hours')->nullable(),
'handling_deadline' => fn (Blueprint $t) => $t->dateTime('handling_deadline')->nullable()->after('appeal_deadline'),
'ignore_reason' => fn (Blueprint $t) => $t->string('ignore_reason', 191)->nullable()->after('handling_status'),
'push_status' => fn (Blueprint $t) => $t->string('push_status', 16)->default('未推送'),
'last_pushed_at' => fn (Blueprint $t) => $t->dateTime('last_pushed_at')->nullable(),
];
$missing = [];
foreach ($columns as $column => $definition) {
if (! $schema->hasColumn($table, $column)) {
$missing[$column] = $definition;
}
}
if ($missing !== []) {
$schema->table($table, function (Blueprint $blueprint) use ($missing): void {
foreach ($missing as $definition) {
$definition($blueprint);
}
});
}
$indexes = [
'store_violations_platform_alert_status_index' => ['platform', 'needs_alert', 'handling_status'],
'store_violations_alert_deadline_index' => ['needs_alert', 'handling_status', 'handling_deadline'],
'store_violations_platform_owner_index' => ['platform', 'owner_name'],
'store_violations_push_level_index' => ['push_status', 'alert_level'],
];
foreach ($indexes as $name => $indexColumns) {
if (! $schema->hasIndex($table, $name)) {
$schema->table($table, function (Blueprint $blueprint) use ($indexColumns, $name): void {
$blueprint->index($indexColumns, $name);
});
}
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
$table = 'store_violation_records';
if (! $schema->hasTable($table)) {
return;
}
foreach ([
'store_violations_platform_alert_status_index',
'store_violations_alert_deadline_index',
'store_violations_platform_owner_index',
'store_violations_push_level_index',
] as $name) {
if ($schema->hasIndex($table, $name)) {
$schema->table($table, function (Blueprint $blueprint) use ($name): void {
$blueprint->dropIndex($name);
});
}
}
foreach ([
'owner_name', 'alert_level', 'needs_alert', 'alert_rule_id', 'alert_action', 'alert_channel',
'handling_hours', 'handling_deadline', 'ignore_reason', 'push_status', 'last_pushed_at',
] as $column) {
if ($schema->hasColumn($table, $column)) {
$schema->table($table, function (Blueprint $blueprint) use ($column): void {
$blueprint->dropColumn($column);
});
}
}
}
};

View File

@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 快递入驻打款账户分对公 / 对私:原 bank_* 三列作为对公账户,新增 private_* 三列为对私账户(账号加密)。
* 个人入驻只有对私账户,已填在 bank_* 的个人数据挪到 private_*。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_onboardings', 'private_account_name')) {
$table->string('private_account_name')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'private_bank_name')) {
$table->string('private_bank_name')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'private_account_number')) {
$table->text('private_account_number')->nullable();
}
});
DB::table('business_courier_onboardings')
->where('entity_type', 'person')
->whereNull('private_account_name')
->update([
'private_account_name' => DB::raw('bank_account_name'),
'private_bank_name' => DB::raw('bank_name'),
'private_account_number' => DB::raw('bank_account_number'),
'bank_account_name' => null,
'bank_name' => null,
'bank_account_number' => null,
]);
}
public function down(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
foreach (['private_account_name', 'private_bank_name', 'private_account_number'] as $column) {
if (Schema::hasColumn('business_courier_onboardings', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:违规字典(预警级别与提醒规则,支持后台维护)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('store_violation_rules')) {
return;
}
$schema->create('store_violation_rules', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('platform', 32)->comment('平台 slug:taobao/alibaba/jd/jingxi/pinduoduo/douyin');
$table->string('violation_category', 64)->nullable()->comment('字典分类,命中后可用于校准记录分类');
$table->string('violation_type', 500)->nullable()->comment('匹配用类型:清洗后相等或前缀匹配');
$table->string('match_keywords', 500)->nullable()->comment('补充关键词,|、/、顿号分隔,包含任一即命中');
$table->string('alert_level', 8)->nullable()->comment('P0/P1/P2;为空时命中后按 P2 处理');
$table->boolean('needs_alert')->default(true)->comment('是否需要提醒;否=只入库不推送');
$table->string('suggested_action', 500)->nullable()->comment('建议处理动作');
$table->unsignedInteger('handling_hours')->nullable()->comment('处理时效(小时);为空时按级别默认');
$table->string('remind_channel', 191)->nullable()->comment('提醒方式');
$table->unsignedInteger('sort')->default(0)->comment('匹配顺序,越小越先匹配');
$table->boolean('enabled')->default(true);
$table->string('remark', 191)->nullable();
$table->timestamps();
$table->index(['platform', 'enabled', 'sort'], 'store_violation_rules_match_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('store_violation_rules');
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:推送日志(即时提醒与每日汇总)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('store_violation_push_logs')) {
return;
}
$schema->create('store_violation_push_logs', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('push_no', 32)->unique('store_violation_push_logs_no_unique');
$table->dateTime('pushed_at')->nullable();
$table->string('channel', 32)->default('dingtalk_robot');
$table->string('message_type', 16)->default('instant')->comment('instant/daily');
$table->string('platform', 32)->nullable();
$table->string('violation_no', 100)->nullable();
$table->string('message_digest', 500)->nullable();
$table->string('mentioned', 191)->nullable();
$table->string('result', 16)->default('success')->comment('success/failed');
$table->string('error', 500)->nullable();
$table->timestamps();
$table->index(['violation_no', 'message_type'], 'store_violation_push_logs_no_type_index');
$table->index('pushed_at', 'store_violation_push_logs_pushed_at_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('store_violation_push_logs');
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 入驻开通后的「ERP 开通」信息:ERP 专员在钉钉「新建快递」流程里填的快递代码、ERP账户ID 等,
* 流程完结后同步回来(或手动补录),快递资料据此把入驻和工作平台老数据合成一行。
*/
return new class extends Migration
{
private const COLUMNS = ['workplatform_expcode', 'erp_account_id', 'erp_opened_at', 'erp_opened_source', 'dingtalk_instance_id'];
public function up(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_onboardings', 'workplatform_expcode')) {
$table->string('workplatform_expcode', 64)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_account_id')) {
$table->string('erp_account_id', 64)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_at')) {
$table->timestamp('erp_opened_at')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_source')) {
$table->string('erp_opened_source', 32)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'dingtalk_instance_id')) {
$table->string('dingtalk_instance_id', 64)->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
foreach (self::COLUMNS as $column) {
if (Schema::hasColumn('business_courier_onboardings', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 快递资料在财务系统这一侧的维护数据(不回写工作平台):
* - business_courier_profiles:按快递公司代码保存「编辑」后的字段覆盖值;
* - business_courier_profile_events:快递资料操作日志(编辑、打款信息新增/变更);
* - business_courier_payment_accounts:打款信息新增/变更记录,推送收款账户证明/变更协议签署后生效。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_profiles')) {
Schema::create('business_courier_profiles', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('courier_name')->nullable();
$table->text('overrides')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'expcode']);
});
}
if (! Schema::hasTable('business_courier_profile_events')) {
Schema::create('business_courier_profile_events', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('action', 32);
$table->string('summary', 500);
$table->text('changes')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'expcode']);
});
}
if (! Schema::hasTable('business_courier_payment_accounts')) {
Schema::create('business_courier_payment_accounts', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('courier_name')->nullable();
$table->string('action', 16);
$table->string('account_type', 16);
$table->string('account_name');
$table->string('bank_name');
$table->text('account_number');
$table->date('effective_date')->nullable();
$table->string('old_account_name')->nullable();
$table->text('old_account_number')->nullable();
$table->date('old_stop_date')->nullable();
$table->date('change_date')->nullable();
$table->string('note', 500)->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'expcode']);
});
}
}
public function down(): void
{
Schema::dropIfExists('business_courier_payment_accounts');
Schema::dropIfExists('business_courier_profile_events');
Schema::dropIfExists('business_courier_profiles');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 短期(临时)变价:变价单和报价版本加「结束日期」。留空 = 长期;填了 = 只在开始~结束期间执行,
* 到期后自动回到原来的长期报价,不用再签一次。
*/
return new class extends Migration
{
private const TABLES = ['business_courier_quote_changes', 'business_courier_quote_versions'];
public function up(): void
{
foreach (self::TABLES as $table) {
if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'end_date')) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->date('end_date')->nullable()->after('effective_date');
});
}
}
}
public function down(): void
{
foreach (self::TABLES as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'end_date')) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->dropColumn('end_date');
});
}
}
}
};

View File

@ -197,6 +197,11 @@ CRON_TZ=Asia/Shanghai
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1
0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
@ -204,6 +209,10 @@ CRON_TZ=Asia/Shanghai
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1
# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次,
# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。
30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1
40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -201,6 +201,11 @@ CRON_TZ=Asia/Shanghai
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1
0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
@ -208,6 +213,10 @@ CRON_TZ=Asia/Shanghai
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1
# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次,
# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。
30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1
40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -230,6 +230,39 @@ class CourierInformationMirrorRepository
];
}
/**
* 快递总览用:工作平台全部快递 + 按快递代码归组的打款账户。镜像未就绪返回 null。
*
* @return array{express:list<object>,accounts:array<string,list<object>>}|null
*/
public function overviewSource(): ?array
{
if (! $this->available() || ! $this->ready()) {
return null;
}
$express = $this->connection->table($this->dataset()->targetTable())
->select(['Id', 'expcode', 'expname', 'status', 'linker', 'linkmobile', 'paymethod', 'is_delete', 'modified'])
->orderBy('Id')
->get()
->filter(static fn ($row): bool => trim((string) ($row->expname ?? '')) !== '' && (string) ($row->is_delete ?? '0') !== '1')
->values()
->all();
$accounts = [];
$accountDataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY);
if ($this->mirror->ready($accountDataset)) {
foreach ($this->connection->table($accountDataset->targetTable())
->select(['expcode', 'expname', 'accountType', 'account', 'accountName', 'accountkhh', 'erpAccountId'])
->orderBy('Id')
->get() as $row) {
$accounts[(string) $row->expcode][] = $row;
}
}
return ['express' => $express, 'accounts' => $accounts];
}
/**
* 返回指定快递名称对应的电子签章经办人账号。
*

View File

@ -98,6 +98,57 @@ class DingPayableWorkflowMirrorRepository
];
}
/**
* 校验某个审批实例里是否存在该附件,并返回附件元数据(下载接口使用,
* 防止拿任意 fileId 越权取钉盘文件)。
*
* @return array{fileName:string,fileType:string,fileSize:int}|null
*/
public function attachment(string $processInstanceId, string $fileId): ?array
{
if ($processInstanceId === '' || $fileId === '') {
return null;
}
$record = $this->connection->table('ding_payable_workflow_mirror')
->where('process_instance_id', $processInstanceId)
->first();
if ($record === null) {
return null;
}
$scheme = $this->decode((string) $record->scheme_payload, []);
$forms = is_array($scheme) ? $this->formFields($scheme) : [];
$files = [];
foreach (['附件', '未签字版合同附件或其他明细附件'] as $key) {
$value = $forms[$key] ?? null;
if (! is_array($value)) {
continue;
}
foreach ($value as $file) {
if (is_array($file)) {
$files[] = $file;
}
}
}
foreach ($files as $file) {
if ((string) ($file['fileId'] ?? '') !== $fileId) {
continue;
}
return [
'fileName' => trim((string) ($file['fileName'] ?? '')) ?: $fileId,
'fileType' => trim((string) ($file['fileType'] ?? '')),
'fileSize' => (int) ($file['fileSize'] ?? 0),
];
}
return null;
}
/** @param array<string,mixed> $workflow @param array<string,mixed> $filters */
private function matchesWorkflowFilters(array $workflow, array $filters): bool
{

View File

@ -0,0 +1,128 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Connection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* 店铺违规信息预警:推送取数、状态回写与推送日志(business_mirror)。
*/
class StoreViolationPushRepository
{
public const LOG_TABLE = 'store_violation_push_logs';
private Connection $connection;
public function __construct()
{
$this->connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
/**
* 即时推送候选:需提醒 + 未处理 + 未推送 + 指定级别。
*
* @param list<string> $levels
* @return Collection<int,object>
*/
public function instantCandidates(array $levels, bool $retryFailed, int $limit): Collection
{
return $this->connection->table(StoreViolationRecordRepository::TABLE)
->where('needs_alert', true)
->where('handling_status', (string) config('store_violations.default_handling_status', '未处理'))
->whereIn('alert_level', $levels)
->where(function ($query) use ($retryFailed): void {
$query->where('push_status', '未推送');
if ($retryFailed) {
$query->orWhere('push_status', '推送失败');
}
})
->orderByRaw("CASE alert_level WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 ELSE 2 END")
->orderBy('id')
->limit(max($limit, 1))
->get();
}
/**
* 每日汇总取数:各级别待处理数量与超时未认领记录。
*
* @return array{pending:array<string,int>,overdue:Collection<int,object>,p2_pending:Collection<int,object>}
*/
public function digestData(int $overdueLimit = 20): array
{
$defaultStatus = (string) config('store_violations.default_handling_status', '未处理');
$pending = [];
foreach ($this->connection->table(StoreViolationRecordRepository::TABLE)
->selectRaw('alert_level, COUNT(*) AS aggregate')
->where('needs_alert', true)
->where('handling_status', $defaultStatus)
->groupBy('alert_level')
->get() as $row) {
$pending[(string) $row->alert_level] = (int) $row->aggregate;
}
$overdue = $this->connection->table(StoreViolationRecordRepository::TABLE)
->whereIn('alert_level', array_keys((array) config('store_violations.alert_levels', [])))
->whereNotIn('handling_status', StoreViolationRecordRepository::FINAL_HANDLING_STATUSES)
->whereNotNull('handling_deadline')
->where('handling_deadline', '<', Carbon::now())
->where('needs_alert', true)
->orderBy('handling_deadline')
->limit(max($overdueLimit, 1))
->get(['id', 'platform', 'shop_name', 'owner_name', 'alert_level', 'violation_type', 'handling_deadline']);
$p2 = $this->connection->table(StoreViolationRecordRepository::TABLE)
->where('needs_alert', true)
->where('alert_level', 'P2')
->where('handling_status', $defaultStatus)
->where('push_status', '未推送')
->orderBy('id')
->get(['id']);
return ['pending' => $pending, 'overdue' => $overdue, 'p2_pending' => $p2];
}
/** @param list<int> $ids */
public function markPushed(array $ids, Carbon $at): void
{
if ($ids === []) {
return;
}
foreach (array_chunk($ids, 500) as $chunk) {
$this->connection->table(StoreViolationRecordRepository::TABLE)
->whereIn('id', $chunk)
->update(['push_status' => '已推送', 'last_pushed_at' => $at, 'updated_at' => $at]);
}
}
public function markPushFailed(int $id, Carbon $at): void
{
$this->connection->table(StoreViolationRecordRepository::TABLE)
->where('id', $id)
->update(['push_status' => '推送失败', 'last_pushed_at' => $at, 'updated_at' => $at]);
}
/** @param array<string,mixed> $entry */
public function log(array $entry): void
{
if (! $this->available()) {
return;
}
$now = now();
$this->connection->table(self::LOG_TABLE)->insert($entry + [
'created_at' => $now,
'updated_at' => $now,
]);
}
public function available(): bool
{
return \Illuminate\Support\Facades\Schema::connection($this->connection->getName())
->hasTable(self::LOG_TABLE);
}
}

View File

@ -36,6 +36,21 @@ class StoreViolationRecordRepository
'updated_at',
];
/** 预警派生字段(同步/导入/重算时自动计算;本地工作流字段不受影响) */
public const ALERT_DERIVED_COLUMNS = [
'needs_alert',
'alert_level',
'alert_rule_id',
'alert_action',
'alert_channel',
'handling_hours',
'owner_name',
'handling_deadline',
];
/** 视为已终结的处理状态(不再计入超时) */
public const FINAL_HANDLING_STATUSES = ['处理完成', '已忽略', '已处理', '无需处理'];
private Connection $connection;
public function __construct()
@ -68,6 +83,25 @@ class StoreViolationRecordRepository
return $this->available() ? (int) $this->connection->table(self::TABLE)->count() : 0;
}
public function findById(int $id): ?object
{
if (! $this->available() || $id <= 0) {
return null;
}
return $this->connection->table(self::TABLE)->where('id', $id)->first();
}
/** @param array<string,mixed> $fields */
public function updateFields(int $id, array $fields): void
{
if (! $this->available() || $fields === []) {
return;
}
$this->connection->table(self::TABLE)->where('id', $id)->update($fields);
}
/** @return array{code:string,last_succeeded_at:?string,row_count:int,last_error:?string} */
public function syncStatus(): array
{
@ -152,14 +186,42 @@ class StoreViolationRecordRepository
$query->whereDate('violation_date', '<=', $filters['date_to']);
}
if (($filters['needs_alert'] ?? null) !== null && $filters['needs_alert'] !== '') {
$query->where('needs_alert', (bool) $filters['needs_alert']);
}
$level = trim((string) ($filters['level'] ?? ''));
if ($level !== '') {
$query->where('alert_level', $level);
}
$owner = trim((string) ($filters['owner'] ?? ''));
if ($owner !== '') {
$query->where('owner_name', $owner);
}
if (! empty($filters['overdue'])) {
$query->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES)
->where('needs_alert', true)
->whereNotNull('handling_deadline')
->where('handling_deadline', '<', now());
}
return $query;
}
/** @return array{statuses:list<string>,handling_statuses:list<string>,categories:list<string>,shops:list<string>} */
/** @return array{statuses:list<string>,handling_statuses:list<string>,categories:list<string>,shops:list<string>,owners:list<string>,levels:list<string>} */
public function filterOptions(string $platform): array
{
if (! $this->available()) {
return ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []];
return [
'statuses' => [],
'handling_statuses' => [],
'categories' => [],
'shops' => [],
'owners' => [],
'levels' => [],
];
}
$distinct = fn (string $column): array => $this->connection->table(self::TABLE)
@ -176,6 +238,17 @@ class StoreViolationRecordRepository
'statuses' => $distinct('status'),
'handling_statuses' => $distinct('handling_status'),
'categories' => $distinct('violation_category'),
'owners' => $this->connection->table(self::TABLE)
->where('platform', $platform)
->whereNotNull('owner_name')
->where('owner_name', '<>', '')
->orderBy('owner_name')
->distinct()
->limit(500)
->pluck('owner_name')
->map(static fn (mixed $value): string => (string) $value)
->all(),
'levels' => array_keys((array) config('store_violations.alert_levels', [])),
'shops' => $this->connection->table(self::TABLE)
->where('platform', $platform)
->where('shop_name', '<>', '')
@ -188,6 +261,71 @@ class StoreViolationRecordRepository
];
}
/**
* 预览统计:各平台 4 个指标(今日新增 / 待处理 / P0未处理 / 已超时)。
*
* @return array<string,array{new_today:int,pending:int,p0_pending:int,overdue:int}>
*/
public function statsByPlatform(): array
{
$result = [];
if (! $this->available()) {
return $result;
}
$ensure = static function (array &$result, string $platform): void {
$result[$platform] ??= ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0];
};
$grouped = $this->connection->table(self::TABLE)
->selectRaw('platform, COUNT(*) AS aggregate')
->where('created_at', '>=', now()->startOfDay())
->groupBy('platform')
->get();
foreach ($grouped as $row) {
$ensure($result, (string) $row->platform);
$result[$row->platform]['new_today'] = (int) $row->aggregate;
}
$grouped = $this->connection->table(self::TABLE)
->selectRaw('platform, COUNT(*) AS aggregate')
->where('needs_alert', true)
->where('handling_status', (string) config('store_violations.default_handling_status', '未处理'))
->groupBy('platform')
->get();
foreach ($grouped as $row) {
$ensure($result, (string) $row->platform);
$result[$row->platform]['pending'] = (int) $row->aggregate;
}
$grouped = $this->connection->table(self::TABLE)
->selectRaw('platform, COUNT(*) AS aggregate')
->where('needs_alert', true)
->where('alert_level', 'P0')
->where('handling_status', (string) config('store_violations.default_handling_status', '未处理'))
->groupBy('platform')
->get();
foreach ($grouped as $row) {
$ensure($result, (string) $row->platform);
$result[$row->platform]['p0_pending'] = (int) $row->aggregate;
}
$grouped = $this->connection->table(self::TABLE)
->selectRaw('platform, COUNT(*) AS aggregate')
->where('needs_alert', true)
->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES)
->whereNotNull('handling_deadline')
->where('handling_deadline', '<', now())
->groupBy('platform')
->get();
foreach ($grouped as $row) {
$ensure($result, (string) $row->platform);
$result[$row->platform]['overdue'] = (int) $row->aggregate;
}
return $result;
}
/**
* 统一表头导出用的行数据(流式读取,避免一次性载入)。
*

View File

@ -0,0 +1,58 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:违规字典(store_violation_rules,business_mirror)。
*/
class StoreViolationRuleRepository
{
public const TABLE = 'store_violation_rules';
private Connection $connection;
public function __construct()
{
$this->connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
public function available(): bool
{
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
}
/** @return list<array<string,mixed>> 启用中的规则,按 sort、id 升序 */
public function activeRules(): array
{
if (! $this->available()) {
return [];
}
return $this->connection->table(self::TABLE)
->where('enabled', true)
->orderBy('sort')
->orderBy('id')
->get()
->map(static fn (object $row): array => (array) $row)
->all();
}
/** @return list<array<string,mixed>> 全部规则(后台维护用),按 sort、id 升序 */
public function all(): array
{
if (! $this->available()) {
return [];
}
return $this->connection->table(self::TABLE)
->orderBy('sort')
->orderBy('id')
->get()
->map(static fn (object $row): array => (array) $row)
->all();
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* 通过钉钉「下载审批附件」接口把审批表单里的附件(spaceId/fileId)换成
* 钉盘临时下载地址。应用凭证复用登录用的企业内部应用
* (config('dingtalk.client_id' / 'client_secret'))。
*/
class DingTalkApprovalAttachmentDownloader
{
private const GET_TOKEN_URL = 'https://oapi.dingtalk.com/gettoken';
private const GRANT_DOWNLOAD_URL = 'https://api.dingtalk.com/v1.0/workflow/processInstances/spaces/files/urls/download';
private const TOKEN_CACHE_KEY = 'dingtalk:app_access_token';
public function downloadUri(string $processInstanceId, string $fileId): ?string
{
$token = $this->accessToken();
if ($token === null) {
return null;
}
$response = Http::withHeaders(['x-acs-dingtalk-access-token' => $token])
->timeout(20)
->post(self::GRANT_DOWNLOAD_URL, [
'processInstanceId' => $processInstanceId,
'fileId' => $fileId,
]);
if (! $response->successful()) {
report(new \RuntimeException(
'dingtalk approval attachment grant failed: HTTP ' . $response->status()
));
return null;
}
$uri = (string) $response->json('result.downloadUri');
return $uri !== '' ? $uri : null;
}
private function accessToken(): ?string
{
$clientId = (string) config('dingtalk.client_id');
$secret = (string) config('dingtalk.client_secret');
if ($clientId === '' || $secret === '') {
return null;
}
$token = Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(100), function () use ($clientId, $secret): ?string {
$response = Http::timeout(15)->get(self::GET_TOKEN_URL, [
'appkey' => $clientId,
'appsecret' => $secret,
]);
$token = (string) $response->json('access_token');
return $token !== '' ? $token : null;
});
return is_string($token) && $token !== '' ? $token : null;
}
}

View File

@ -43,6 +43,10 @@ class ShippingReplicaSynchronizer
'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no',
];
private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone'];
private const REFRESHED_PACKAGE_FIELDS = [
'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time',
'receiver_state', 'receiver_city', 'receiver_district',
];
private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier'];
private const VIEW = 'rpt.Jst_SendData_View_extension';
@ -68,6 +72,7 @@ class ShippingReplicaSynchronizer
private int $packagesRetainedForMissingItems = 0;
private int $packagesRetainedForUnmatchedAmount = 0;
private int $staleMirrorPackages = 0;
private int $supersededRemoved = 0;
/** @var array<int,true> */
private array $seenSourceIds = [];
@ -135,6 +140,12 @@ class ShippingReplicaSynchronizer
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */
private array $ioShop = [];
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图快递名称 expressName(工作平台显示的快递名称) */
private array $ioExpress = [];
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图发货仓 wareHoursName(工作平台显示的仓库) */
private array $ioWare = [];
/** @var array<string,array{l_id:mixed,send_date:mixed,status:?string,online:bool}> 已查询订单元数据缓存 o_id=>字段 */
private array $orderMetadata = [];
@ -168,6 +179,7 @@ class ShippingReplicaSynchronizer
$this->packagesRetainedForMissingItems = 0;
$this->packagesRetainedForUnmatchedAmount = 0;
$this->staleMirrorPackages = 0;
$this->supersededRemoved = 0;
$this->seenSourceIds = [];
$this->expressOrderIds = [];
$this->dwsFailedDays = [];
@ -255,6 +267,7 @@ class ShippingReplicaSynchronizer
$result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount;
$result['dws_failed_days'] = $this->dwsFailedDays;
$result['stale_mirror_packages'] = $this->staleMirrorPackages;
$result['superseded_packages_removed'] = $this->supersededRemoved;
$result['tracking_pairing_skipped_pages'] = $this->pairingSkipped;
}
if ($skipFreight) {
@ -489,6 +502,7 @@ class ShippingReplicaSynchronizer
?? $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null);
$pkg = $this->pkgFromExpress($r, $tracking, $shipTime);
$pkg = $this->withOutboundCarrier($io, $r, $pkg, $pkgId);
$pkg = $this->withViewCarrier($io, $pkg, $pkgId);
$pkg['platform'] = $this->platformOf($io, $pkg['platform']);
$pkg = $this->withViewAddr($io, $pkg);
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now);
@ -705,6 +719,7 @@ class ShippingReplicaSynchronizer
$pkg['platform'] = $this->platformOf($io, $pkg['platform']);
$pkg = $this->withViewAddr($io, $pkg);
$pkgId = self::AUTOSHIP_OFFSET + (int) $oid;
$pkg = $this->withViewCarrier($io, $pkg, $pkgId);
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true);
}
unset($rows, $byOid, $bySo, $dws, $skus, $freight);
@ -900,8 +915,11 @@ class ShippingReplicaSynchronizer
}
$pkg = $this->pkgFromOutbound($r, $tracking !== '' ? $tracking : null, $this->ts($r['io_date'] ?? null), $h, $so);
$pkg = $this->withViewAddr($io, $pkg);
$pkg = $this->withViewCarrier($io, $pkg, self::OUTBOUND_OFFSET + (int) $io);
if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) {
$pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE;
if (empty($this->ioWare[$io])) {
$pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE;
}
$virtualEmitted++;
}
$total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking !== '' ? $tracking : null, $now, true);
@ -1030,7 +1048,7 @@ class ShippingReplicaSynchronizer
private function loadViewHeads(array $ioIds): array
{
$out = [];
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) {
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) {
if ($chunk === []) {
continue;
}
@ -1260,6 +1278,26 @@ class ShippingReplicaSynchronizer
return $pkg;
}
/**
* 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致):
* 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。
*/
private function withViewCarrier(string $io, array $pkg, int $pkgId): array
{
if ($io === '') {
return $pkg;
}
if (! empty($this->ioExpress[$io])) {
$pkg['express_name1'] = $this->ioExpress[$io];
}
if (! empty($this->ioWare[$io])) {
$pkg['warehouse_name'] = $this->ioWare[$io];
unset($this->pendingWarehouse[$pkgId]);
}
return $pkg;
}
private function wmsName(string $wms): ?string
{
if (empty($this->wmsNames[$wms])) {
@ -1723,12 +1761,15 @@ class ShippingReplicaSynchronizer
$this->ioSite = [];
$this->ioAddr = [];
$this->ioShop = [];
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) {
$this->ioExpress = [];
$this->ioWare = [];
// 发货数据视图是计算视图,IN 列表过长时副本库容易超时:按 300 个出库单一批
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM '
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, expressName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM '
. self::VIEW . " WHERE io_id IN ({$ph})";
foreach ($this->selectWithReconnect($sql, $chunk) as $r) {
$io = (string) ($r['io_id'] ?? '');
@ -1738,6 +1779,12 @@ class ShippingReplicaSynchronizer
if ($io !== '' && ! isset($this->ioShop[$io]) && trim((string) ($r['shop_name'] ?? '')) !== '') {
$this->ioShop[$io] = trim((string) $r['shop_name']);
}
if ($io !== '' && ! isset($this->ioExpress[$io]) && trim((string) ($r['expressName'] ?? '')) !== '') {
$this->ioExpress[$io] = trim((string) $r['expressName']);
}
if ($io !== '' && ! isset($this->ioWare[$io]) && trim((string) ($r['wareHoursName'] ?? '')) !== '') {
$this->ioWare[$io] = trim((string) $r['wareHoursName']);
}
if ($io !== '') {
foreach (['st' => 'receiver_state', 'ci' => 'receiver_city', 'di' => 'receiver_district'] as $ak => $ac) {
$av = trim((string) ($r[$ac] ?? ''));
@ -2137,9 +2184,11 @@ class ShippingReplicaSynchronizer
$rows = [];
$replaceIds = [];
$refresh = [];
foreach ($packages as $id => $packageRows) {
if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) {
$this->packagesRetainedForMissingItems++;
$refresh[$id] = $packageRows[0] ?? [];
continue;
}
$unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? [];
@ -2176,6 +2225,7 @@ class ShippingReplicaSynchronizer
}
if ($unsafeAmount) {
$this->packagesRetainedForUnmatchedAmount++;
$refresh[$id] = $packageRows[0] ?? [];
continue;
}
$replaceIds[] = $id;
@ -2205,6 +2255,7 @@ class ShippingReplicaSynchronizer
}
array_push($rows, ...$packageRows);
}
$this->refreshRetainedPackages($refresh);
if ($replaceIds === []) {
return;
}
@ -2307,21 +2358,55 @@ class ShippingReplicaSynchronizer
return $out;
}
/**
* 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录
* (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白;
* 其余仍保留并计数,需按源键核对。
*/
private function countUnseenMirrorPackages(string $fromS, string $toS): int
{
$unseen = 0;
$ids = $this->db->table('jst_oms_shipping')
$superseded = [];
$rows = $this->db->table('jst_oms_shipping')
->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS)
->distinct()->pluck('source_id');
foreach ($ids as $id) {
if (! isset($this->seenSourceIds[(int) $id])) {
$unseen++;
->selectRaw('source_id, MAX(express_no) AS express_no')->groupBy('source_id')->get();
foreach ($rows as $row) {
$id = (int) $row->source_id;
if (isset($this->seenSourceIds[$id])) {
continue;
}
$tk = ltrim(trim((string) ($row->express_no ?? '')), '@');
if ($tk !== '' && isset($this->emittedTrack[$tk])) {
$superseded[] = $id;
continue;
}
$unseen++;
}
foreach (array_chunk($superseded, 500) as $chunk) {
$this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete();
}
$this->supersededRemoved = count($superseded);
return $unseen;
}
/** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */
private function refreshRetainedPackages(array $refresh): void
{
foreach ($refresh as $id => $new) {
$set = [];
foreach (self::REFRESHED_PACKAGE_FIELDS as $field) {
$v = $new[$field] ?? null;
if ($v !== null && trim((string) $v) !== '') {
$set[$field] = $v;
}
}
if ($set !== []) {
$this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set);
}
}
}
private function withPreservedFreight(array $payload, array $previous): array
{
$payload = $this->withPreservedCharges($payload, $previous);

View File

@ -17,6 +17,7 @@
@php($agreementType = trim((string) request('agreement_type', '')))
@php($agreementScope = trim((string) request('agreement_scope', $agreementStatus === 'archived' ? 'archived' : 'active')))
@php($agreementScope = in_array($agreementScope, ['active', 'archived', 'all'], true) ? $agreementScope : 'active')
@php($agreementPaymentPrefill = \App\Business\CourierProfileData::agreementPrefill(request('payment_account')))
@php($agreementFiltersActive = $agreementSearch !== '' || $agreementStatus !== '' || $agreementType !== '')
<section class="agreement-workspace" aria-label="协议签署" data-agreement-signing>
@ -1575,6 +1576,12 @@
</header>
<div class="agreement-request-body">
@if ($agreementPaymentPrefill)
<div class="agreement-template-note" role="status" data-payment-prefill-note style="border-color:#E3C98F;background:#FFF8E8">
<span class="material-icons-outlined" aria-hidden="true">campaign</span>
<div><strong>来自快递资料 · {{ $agreementPaymentPrefill['action'] }}:{{ $agreementPaymentPrefill['courier_name'] }}({{ $agreementPaymentPrefill['expcode'] }})</strong><p>已按打款信息预填「{{ $agreementPaymentPrefill['document_type'] === 'account-change' ? '收款账户变更' : '收款账户证明' }}」。请补齐 E 签宝账号、签署人等变更资料后推送给快递签署,签署完成后快递资料里这条打款信息显示为「已生效」。</p></div>
</div>
@endif
<section class="agreement-form-section" aria-labelledby="agreement-base-heading">
<h3 class="agreement-section-heading" id="agreement-base-heading">基础信息</h3>
<div class="agreement-form-grid">
@ -3951,6 +3958,46 @@
syncEsignAccount();
refreshPreview();
// 快递资料「打款信息新增 / 变更」跳过来:预填快递、文件类型和账户信息,提示补齐后推送。
var paymentPrefill = @json($agreementPaymentPrefill);
if (paymentPrefill) {
showTab('create', { updateUrl: false });
var courierPartner = root.querySelector('[data-partner-type][value="courier"]');
if (courierPartner && !courierPartner.checked) {
courierPartner.checked = true;
courierPartner.dispatchEvent(new Event('change', { bubbles: true }));
}
if (courierSelect && paymentPrefill.courier_name) {
var prefillOption = Array.prototype.find.call(courierSelect.options, function (option) { return option.value === paymentPrefill.courier_name; });
if (!prefillOption) {
prefillOption = new Option(paymentPrefill.courier_name, paymentPrefill.courier_name);
courierSelect.appendChild(prefillOption);
courierOptions.push({ value: paymentPrefill.courier_name, account: paymentPrefill.esign_account || '' });
}
if (paymentPrefill.esign_account && !prefillOption.dataset.esignAccount) {
prefillOption.dataset.esignAccount = paymentPrefill.esign_account;
}
courierSelect.value = paymentPrefill.courier_name;
courierSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
documentSelect.value = paymentPrefill.document_type;
documentSelect.dispatchEvent(new Event('change', { bubbles: true }));
var prefillSigner = form.querySelector('[name="counterparty_signer_type"][value="' + paymentPrefill.signer_type + '"]');
if (prefillSigner) {
prefillSigner.checked = true;
prefillSigner.dispatchEvent(new Event('change', { bubbles: true }));
}
Object.keys(paymentPrefill.fields || {}).forEach(function (name) {
var input = form.querySelector('[name="' + name + '"]');
if (input) {
input.value = paymentPrefill.fields[name];
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
refreshPreview();
if (announcer) announcer.textContent = '已按快递资料的打款信息预填协议,请补齐签署人信息后推送。';
}
if (initialUrl.searchParams.has('agreement_step')) {
updateUrl(initialView, true);
}

View File

@ -49,6 +49,21 @@
array_filter($filters, static fn ($value) => $value !== '' && $value !== null),
));
};
$displayRows = $courierDisplayRows ?? [];
$detail = $courierDetail ?? null;
$selectedCode = (string) ($detail['code'] ?? '');
$selectedRow = $detail['row'] ?? null;
$selectUrl = static function (string $code, array $extra = []) use ($filters): string {
$page = (int) request('page', 1);
return route('business.pages.show', array_merge(
['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'express'],
array_filter($filters, static fn ($value) => $value !== '' && $value !== null),
$page > 1 ? ['page' => $page] : [],
['selected' => $code],
$extra,
));
};
@endphp
<style>
@ -295,13 +310,41 @@
@media (max-width: 1000px) {
.courier-information-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.courier-information-table th.is-onboarding { background: #EEF3F8; color: #3C5A75; }
.courier-information-table td.is-onboarding { background: #F8FAFC; }
.courier-information-table tr.is-selectable { cursor: pointer; }
.courier-information-table tr.is-selected td { background: #F7EFDD !important; }
.courier-information-table tr.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--vip-content-accent, #b9954f); }
.courier-information-table td.is-overridden { background: #FFF8E8; }
.courier-information-table tr.is-finance-row td { background: #FBFAF6; }
.courier-information-button[disabled] { opacity: .5; cursor: not-allowed; }
.courier-information-link { margin-left: 6px; color: var(--vip-content-accent-dark, #856632); text-decoration: underline; }
.courier-detail { margin-top: 20px; padding: 16px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; scroll-margin-top: 80px; }
.courier-detail__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.courier-detail__title { margin: 0 0 4px; font-size: 1rem; font-weight: 600; }
.courier-detail__actions, .courier-detail__toolbar, .courier-detail__form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.courier-detail__toolbar { margin-bottom: 12px; }
.courier-detail__tabs { display: flex; gap: 20px; margin: 4px 0 12px; border-bottom: 1px solid var(--vip-border, #d8d2c7); }
.courier-detail__tab { position: relative; min-height: 40px; padding: 0; border: 0; background: none; color: var(--vip-content-muted, #595959); font: inherit; cursor: pointer; }
.courier-detail__tab.is-active { color: var(--vip-content-accent-dark, #856632); }
.courier-detail__tab.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 3px; background: var(--vip-content-accent, #b9954f); content: ''; }
.courier-detail__form { margin-bottom: 14px; padding: 14px; border: 1px solid #E3C98F; border-radius: 10px; background: #FFFCF4; }
.courier-detail__form h4 { margin: 0 0 12px; font-size: .875rem; font-weight: 600; }
.courier-detail__form-actions { margin-top: 12px; }
@media (max-width: 560px) { .courier-detail__head { flex-direction: column; } }
.courier-overview-sub { color: #8A8478; font-size: 12px; }
.courier-overview-tag { display: inline-block; padding: 1px 8px; border-radius: 6px; font-size: 12px; background: #F1EEE6; color: #6F6A60; white-space: nowrap; }
.courier-overview-tag.is-ok { background: #EEF6EA; color: #2F6B2A; }
.courier-overview-tag.is-warn { background: #FFF6E5; color: #935F07; }
.courier-overview-tag.is-bad { background: #FDEDEC; color: #A1281F; }
.courier-overview-tag.is-muted { background: #F1EEE6; color: #7A7468; }
@media (max-width: 560px) {
.courier-information-filters { grid-template-columns: 1fr; }
.courier-information-status { align-items: flex-start; flex-direction: column; }
}
</style>
<div class="courier-information-page">
<div class="courier-information-page" data-courier-information>
<div class="courier-information-status" role="status">
<div class="courier-information-status__main">
<span class="courier-information-status__dot {{ $statusClass }}" aria-hidden="true"></span>
@ -329,7 +372,6 @@
'networks' => ['icon' => 'storefront', 'label' => '网点'],
'accounts' => ['icon' => 'account_balance', 'label' => '打款信息'],
'logs' => ['icon' => 'history', 'label' => '操作日志'],
'onboarded' => ['icon' => 'how_to_reg', 'label' => '入驻快递'],
] as $key => $item)
<a class="courier-information-tab {{ $tab === $key ? 'is-active' : '' }}" href="{{ $tabUrl($key) }}" @if ($tab === $key) aria-current="page" @endif>
<span class="material-icons-outlined" aria-hidden="true">{{ $item['icon'] }}</span>
@ -342,11 +384,11 @@
<form method="GET" class="courier-information-filters" aria-label="快递资料筛选">
<input type="hidden" name="courier_tab" value="express">
<div class="courier-information-field">
<label for="courier-expcode">快递代码</label>
<label for="courier-expcode">快递公司代码</label>
<input id="courier-expcode" name="expcode" type="text" value="{{ $filters['expcode'] ?? '' }}" placeholder="精确匹配">
</div>
<div class="courier-information-field">
<label for="courier-expname">快递名称</label>
<label for="courier-expname">快递公司名称</label>
<input id="courier-expname" name="expname" type="text" value="{{ $filters['expname'] ?? '' }}" placeholder="名称关键词">
</div>
<div class="courier-information-field">
@ -381,29 +423,23 @@
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="courier-information-button" href="{{ route('business.courier-payables.courier-information.export', array_filter($filters, static fn ($value) => $value !== '' && $value !== null)) }}"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
<a class="courier-information-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
@if ($selectedRow)
<a class="courier-information-button" href="{{ $selectUrl($selectedCode, ['edit' => 1]) }}#courier-detail" data-courier-edit-open><span class="material-icons-outlined" aria-hidden="true">edit</span>编辑</a>
@else
<button type="button" class="courier-information-button" disabled title="先在表格里点选一家快递,再编辑"><span class="material-icons-outlined" aria-hidden="true">edit</span>编辑</button>
@endif
</div>
</form>
@elseif ($tab === 'onboarded')
<form method="GET" class="courier-information-filters" aria-label="入驻快递筛选">
<input type="hidden" name="courier_tab" value="onboarded">
<div class="courier-information-field">
<label for="courier-onboarded-search">快递 / 公司 / 对接人</label>
<input id="courier-onboarded-search" name="search" type="text" value="{{ request('search') }}" placeholder="名称关键词">
</div>
<div class="courier-information-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="courier-information-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding']) }}"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>快递入驻</a>
</div>
</form>
@elseif (in_array($tab, ['networks', 'accounts'], true))
<form method="GET" class="courier-information-filters" aria-label="{{ $tab === 'networks' ? '网点' : '打款信息' }}筛选">
@else
<form method="GET" class="courier-information-filters" aria-label="{{ ['networks' => '网点', 'accounts' => '打款信息', 'logs' => '操作日志'][$tab] }}筛选">
<input type="hidden" name="courier_tab" value="{{ $tab }}">
<div class="courier-information-field">
<label for="courier-related-expcode">快递代码</label>
<label for="courier-related-expcode">快递公司代码</label>
<input id="courier-related-expcode" name="expcode" type="text" value="{{ $filters['expcode'] ?? '' }}" placeholder="留空显示全部">
</div>
<div class="courier-information-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="courier-information-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => $tab]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@endif
@ -420,25 +456,7 @@
</div>
@endif
@if ($tab === 'logs')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 760px" aria-label="快递资料操作日志">
<thead><tr><th>ID</th><th>快递代码</th><th>操作时间</th><th>创建时间</th><th>修改时间</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->Id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->doTime ?? '-' }}</td><td>{{ $datetime($row->createTime ?? null) }}</td><td>{{ $datetime($row->modifyTime ?? null) }}</td></tr>
@empty
<tr><td colspan="5" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有操作日志。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="操作日志分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@elseif ($tab === 'express')
@if ($tab === 'express')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" aria-label="快递资料">
@ -448,37 +466,64 @@
<th>是否启用</th><th>可达服务</th><th>是否快运</th><th>自动寻号</th><th>配货单号寻号</th><th>数量拆单</th><th>重量0寻号</th>
<th>停运时间</th><th>合同开始时间</th><th>合同结束时间</th><th>联系人</th><th>联系电话</th><th>自定义1</th><th>自定义2</th><th>自定义3</th>
<th>体积0寻号</th><th>手工单号</th><th>解密</th><th>电子签章经办人</th><th>最后修改</th>
<th class="is-onboarding">入驻单号</th><th class="is-onboarding">入驻状态</th><th class="is-onboarding">ERP 开通</th><th class="is-onboarding">入驻主体</th>
<th class="is-onboarding">证照</th><th class="is-onboarding">生效报价</th>
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
@php $enabled = $bool($row->status ?? null) === '是'; @endphp
<tr>
@forelse ($displayRows as $row)
@php
$code = (string) ($row->expcode ?? '');
$onlyOnboarding = ! empty($row->_onboarding_only);
$overridden = $row->_overridden ?? [];
$cell = static fn (string $field): string => in_array($field, $overridden, true) ? 'is-overridden' : '';
$enabled = $bool($row->status ?? null) === '是';
$isSelected = $code !== '' && strcasecmp($code, $selectedCode) === 0;
@endphp
<tr class="{{ $code !== '' ? 'is-selectable' : '' }} {{ $isSelected ? 'is-selected' : '' }} {{ $onlyOnboarding ? 'is-onboarding-only' : '' }}" @if ($code !== '') data-select-url="{{ $selectUrl($code) }}#courier-detail" @endif>
<td class="is-code">{{ $row->Id ?? '-' }}</td>
<td class="is-code">{{ $row->expcode ?? '-' }}</td>
<td class="is-clamped" title="{{ $row->expname ?? '' }}">{{ $row->expname ?? '-' }}</td>
<td class="is-code">
@if ($code !== '')
<a href="{{ $selectUrl($code) }}#courier-detail" title="点开看网点 / 打款信息 / 操作日志">{{ $code }}</a>
@else
<span class="courier-overview-sub" title="入驻时没填快递公司代码,到入驻详情的「ERP 开通」里补上">未填代码</span>
@endif
</td>
<td class="is-clamped {{ $cell('expname') }}" title="{{ $row->expname ?? '' }}"><strong>{{ $row->expname ?? '-' }}</strong></td>
<td class="is-code">{{ $row->outcode ?? '-' }}</td>
<td class="is-code">{{ $row->scexpcode ?? '-' }}</td>
<td>{{ $row->paymethod ?? '-' }}</td>
<td><span class="courier-information-badge {{ $enabled ? 'is-enabled' : 'is-disabled' }}">{{ $enabled ? '是' : '否' }}</span></td>
<td>{{ $bool($row->addvalidsgn ?? null) }}</td><td>{{ $bool($row->kuaiyunsgn ?? null) }}</td><td>{{ $bool($row->autonosgn ?? null) }}</td>
<td>{{ $bool($row->packnosgn ?? null) }}</td><td>{{ $bool($row->skucountsplitsgn ?? null) }}</td><td>{{ $bool($row->weight0sgn ?? null) }}</td>
<td>{{ $date($row->stopdate ?? null) }}</td><td>{{ $date($row->tradebtime ?? null) }}</td><td>{{ $date($row->tradeetime ?? null) }}</td>
<td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkmobile ?? '-' }}</td>
<td>{{ $row->undefined1 ?? '-' }}</td><td>{{ $row->undefined2 ?? '-' }}</td><td>{{ $row->undefined3 ?? '-' }}</td>
<td>{{ $bool($row->volume0sgn ?? null) }}</td><td>{{ $bool($row->handnosgn ?? null) }}</td><td>{{ $bool($row->decryptsgn ?? null) }}</td>
<td class="is-code">{{ $row->dzqzjbr ?? '-' }}</td>
<td class="{{ $cell('paymethod') }}">{{ $row->paymethod ?? '-' }}</td>
<td class="{{ $cell('status') }}"><span class="courier-information-badge {{ $enabled ? 'is-enabled' : 'is-disabled' }}">{{ $enabled ? '是' : '否' }}</span></td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->addvalidsgn ?? null) }}</td>
<td class="{{ $cell('kuaiyunsgn') }}">{{ $bool($row->kuaiyunsgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->autonosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->packnosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->skucountsplitsgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->weight0sgn ?? null) }}</td>
<td class="{{ $cell('stopdate') }}">{{ $date($row->stopdate ?? null) }}</td>
<td class="{{ $cell('tradebtime') }}" @if ($onlyOnboarding) title="协议签署 · 合作合同(已签署)" @endif>{{ $date($row->tradebtime ?? null) }}</td>
<td class="{{ $cell('tradeetime') }}" @if ($onlyOnboarding) title="协议签署 · 合作合同(已签署)" @endif>{{ $date($row->tradeetime ?? null) }}</td>
<td class="{{ $cell('linker') }}">{{ $row->linker ?? '-' }}</td>
<td class="{{ $cell('linkmobile') }}">{{ $row->linkmobile ?? '-' }}</td>
<td class="{{ $cell('undefined1') }}">{{ $row->undefined1 ?? '-' }}</td>
<td class="{{ $cell('undefined2') }}">{{ $row->undefined2 ?? '-' }}</td>
<td class="{{ $cell('undefined3') }}">{{ $row->undefined3 ?? '-' }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->volume0sgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->handnosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->decryptsgn ?? null) }}</td>
<td class="is-code {{ $cell('dzqzjbr') }}">{{ $row->dzqzjbr ?? '-' }}</td>
<td class="is-code">{{ $datetime($row->modified ?? null) }}</td>
@include('business.pages.courier-payables.partials.courier-onboarding-cells', ['info' => $row->_info ?? null, 'platformName' => $row->expname ?? null, 'platformCode' => $row->expcode ?? null])
</tr>
@empty
<tr><td colspan="26" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}</td></tr>
<tr><td colspan="32" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer">
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span>
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条 · 点一行快递在下方看网点 / 打款信息(含打款信息新增、变更)/ 操作日志;浅色底的格子是财务系统改过的值</span>
<nav class="courier-information-pagination" aria-label="快递资料分页">
<a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@ -486,69 +531,135 @@
</div>
@endif
</div>
@elseif ($tab === 'onboarded')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 1500px" aria-label="入驻快递">
<thead><tr><th>入驻单号</th><th>系统快递名称</th><th>公司全称</th><th>网点</th><th>品牌</th><th>业务类型</th><th>对接人</th><th>对接电话</th><th>对账人</th><th>收款户名</th><th>开户行</th><th>结算周期</th><th>开票</th><th>计泡</th><th>开通时间</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
<td class="is-code"><a href="{{ route('business.courier-onboardings.show', $row) }}">{{ $row->number }}</a></td>
<td>{{ $row->courier_name ?: '-' }}</td>
<td class="is-clamped" title="{{ $row->company_full_name }}">{{ $row->company_full_name ?: '-' }}</td>
<td>{{ $row->network_name ?: '-' }}</td><td>{{ $row->carrier_brand ?: '-' }}</td><td>{{ $row->service_type ?: '-' }}</td>
<td>{{ $row->contact_name ?: '-' }}</td><td class="is-code">{{ $row->contact_phone ?: '-' }}</td><td>{{ $row->reconciler ?: '-' }}</td>
<td>{{ $row->bank_account_name ?: '-' }}</td><td>{{ $row->bank_name ?: '-' }}</td><td>{{ $row->settlement_cycle ?: '-' }}</td>
<td>{{ $row->invoice_type ?: '-' }}{{ $row->tax_rate !== null ? ' ' . rtrim(rtrim(number_format($row->tax_rate, 2), '0'), '.') . '%' : '' }}</td>
<td>{{ $row->volumetric_rule === '计泡' ? '计泡 /' . $row->volumetric_divisor : ($row->volumetric_rule ?: '-') }}</td>
<td class="is-code">{{ $row->activated_at?->format('Y-m-d H:i') ?? '-' }}</td>
</tr>
@empty
<tr><td colspan="15" class="courier-information-empty">还没有通过扫码入驻开通的快递。</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="入驻快递分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@if ($detail)
@include('business.pages.courier-payables.partials.courier-information-detail', ['detail' => $detail])
@endif
@elseif ($tab === 'networks')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 1560px" aria-label="快递网点">
<thead><tr><th>ID</th><th>快递代码</th><th>网点代码</th><th>网点名称</th><th>网点号</th><th>省</th><th>市</th><th>区</th><th>乡镇</th><th>地址</th><th>联系人</th><th>联系电话</th><th>是否启用</th><th>接口平台</th><th>创建时间</th><th>剩余单号</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td class="is-code">{{ $row->netcode ?? '-' }}</td><td>{{ $row->netname ?? '-' }}</td><td>{{ $row->netno ?? '-' }}</td><td>{{ $row->province ?? '-' }}</td><td>{{ $row->city ?? '-' }}</td><td>{{ $row->area ?? '-' }}</td><td>{{ $row->town ?? '-' }}</td><td class="is-clamped" title="{{ $row->address ?? '' }}">{{ $row->address ?? '-' }}</td><td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkermobile ?? '-' }}</td><td>{{ $bool($row->status ?? null) }}</td><td>{{ $row->apipltmname ?? ($row->apitype ?? '-') }}</td><td>{{ $datetime($row->created ?? null) }}</td><td>{{ $row->expnum ?? '-' }}</td></tr>
@empty
<tr><td colspan="16" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。' }}</td></tr>
@endforelse
</tbody>
</table>
@include('business.pages.courier-payables.partials.courier-network-table', ['networkRows' => $rows ?? [], 'emptyText' => $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="网点分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@elseif ($tab === 'accounts')
@php
$tabOldAccounts = collect($courierFinanceAccounts ?? [])
->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']])
->merge(($filters['expcode'] ?? '') !== '' && $rows ? collect($rows->items())->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台']) : [])
->filter(static fn (array $account): bool => filled($account['number']) && ($filters['expcode'] ?? '') !== '')
->unique('number')
->values();
@endphp
<div class="courier-detail" style="margin: 0 0 16px">
@if ($errors->any() && in_array((string) old('form'), ['add', 'change'], true))
<div class="courier-information-notice is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $errors->first() }}</span></div>
@endif
@include('business.pages.courier-payables.partials.courier-payment-account-forms', ['code' => null, 'prefillCode' => $filters['expcode'] ?? '', 'oldAccounts' => $tabOldAccounts])
</div>
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-account-table', ['financeAccounts' => $courierFinanceAccounts ?? [], 'platformAccounts' => $rows ?? [], 'showCourier' => true, 'rowActions' => true, 'emptyText' => '当前筛选条件下没有打款信息。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页 · 工作平台账户共 {{ number_format($rows->total()) }} 条;财务系统的打款信息(入驻对公/对私、新增/变更)显示在第一页最前面</span><nav class="courier-information-pagination" aria-label="打款信息分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@else
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 1120px" aria-label="打款信息">
<thead><tr><th>ID</th><th>快递代码</th><th>快递名称</th><th>账户类型</th><th>账户</th><th>账户名称</th><th>开户行</th><th>创建时间</th><th>发票信息</th><th>ERP账户ID</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->Id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->expname ?? '-' }}</td><td>{{ $row->accountType ?? '-' }}</td><td class="is-code">{{ $row->account ?? '-' }}</td><td>{{ $row->accountName ?? '-' }}</td><td>{{ $row->accountkhh ?? '-' }}</td><td>{{ $datetime($row->createTime ?? null) }}</td><td>{{ $row->invoice ?? '-' }}</td><td class="is-code">{{ $row->erpAccountId ?? '-' }}</td></tr>
@empty
<tr><td colspan="10" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有打款账户。' }}</td></tr>
@endforelse
</tbody>
</table>
@include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $courierFinanceEvents ?? collect(), 'platformLogs' => $rows ?? [], 'showCourier' => true, 'emptyText' => '当前筛选条件下没有操作日志。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="打款信息分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页 · 工作平台日志共 {{ number_format($rows->total()) }} 条;财务系统的操作(编辑、打款信息新增/变更)显示在第一页最前面</span><nav class="courier-information-pagination" aria-label="操作日志分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@endif
</div>
<script>
(function () {
var page = document.querySelector('[data-courier-information]');
if (!page || page.dataset.ready === 'true') return;
page.dataset.ready = 'true';
// 点表格行(不是点链接/按钮时)= 点开这家快递的明细。
page.addEventListener('click', function (event) {
var row = event.target.closest('tr[data-select-url]');
if (!row || event.target.closest('a, button, input, select, label')) return;
window.location.href = row.dataset.selectUrl;
});
// 明细里的网点 / 打款信息 / 操作日志:局部切换。
page.querySelectorAll('[data-detail-tab]').forEach(function (button) {
button.addEventListener('click', function () {
var name = button.dataset.detailTab;
page.querySelectorAll('[data-detail-tab]').forEach(function (item) {
var active = item.dataset.detailTab === name;
item.classList.toggle('is-active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
});
page.querySelectorAll('[data-detail-panel]').forEach(function (panel) {
panel.hidden = panel.dataset.detailPanel !== name;
});
try {
var url = new URL(window.location.href);
url.searchParams.set('detail_tab', name);
window.history.replaceState(null, '', url.toString());
} catch (error) {}
});
});
// 打款信息新增 / 变更表单开关。
page.querySelectorAll('[data-account-form-open]').forEach(function (button) {
button.addEventListener('click', function () {
var target = button.dataset.accountFormOpen;
page.querySelectorAll('[data-account-form]').forEach(function (form) {
form.hidden = form.dataset.accountForm !== target ? true : !form.hidden;
});
var opened = page.querySelector('[data-account-form="' + target + '"]');
if (opened && !opened.hidden) {
var first = opened.querySelector('select, input:not([type="hidden"])');
if (first) first.focus();
}
});
});
page.querySelectorAll('[data-account-form-close]').forEach(function (button) {
button.addEventListener('click', function () {
button.closest('[data-account-form]').hidden = true;
});
});
// 打款信息表里某个账户点「变更」:打开变更表单,带出快递公司代码和原账户。
page.querySelectorAll('[data-account-change-from]').forEach(function (button) {
button.addEventListener('click', function () {
var form = page.querySelector('[data-account-form="change"]');
if (!form) return;
page.querySelectorAll('[data-account-form]').forEach(function (item) { item.hidden = item !== form; });
form.hidden = false;
var code = form.querySelector('[data-account-expcode]');
if (code) code.value = button.dataset.code || '';
form.querySelector('[name="old_account_name"]').value = button.dataset.name || '';
form.querySelector('[name="old_account_number"]').value = button.dataset.number || '';
if (button.dataset.type) form.querySelector('[name="account_type"]').value = button.dataset.type;
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
var focus = form.querySelector('[name="account_name"]');
if (focus) focus.focus({ preventScroll: true });
});
});
// 变更:选原账户后带出原开户名称、原账号、账户类型。
page.querySelectorAll('[data-old-account-select]').forEach(function (select) {
select.addEventListener('change', function () {
var option = select.options[select.selectedIndex];
var form = select.closest('form');
if (!option || !option.value) return;
form.querySelector('[name="old_account_name"]').value = option.dataset.name || '';
form.querySelector('[name="old_account_number"]').value = option.dataset.number || '';
if (option.dataset.type) form.querySelector('[name="account_type"]').value = option.dataset.type;
});
});
})();
</script>

View File

@ -11,14 +11,9 @@
};
$show = static fn ($value): string => filled($value) ? (string) $value : '—';
$num = static fn ($v): string => $v === null || $v === '' ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.');
$modeHeaders = match ($onboarding->quote_mode) {
'first_additional' => ['首重(kg)', '首重价(元)', '续重(元/kg)'],
'flat' => ['重量上限(kg)', '一口价(元)', '超出续重(元/kg)'],
default => ['首重(kg)', '面单(元)', '费率(元/kg)'],
};
$quoteColumns = $onboarding->quoteColumns();
$quoteRows = $onboarding->quoteRowList();
$suggestedName = $onboarding->courier_name ?: trim(($onboarding->network_name ?? '') !== '' ? $onboarding->network_name : ($onboarding->company_full_name ?? ''));
$agreementUrl = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']);
$quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last();
$quoteUrl = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
@endphp
<x-layouts.admin>
@ -117,17 +112,22 @@
@endif
<section class="cod__panel" aria-labelledby="cod-company">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-company"><span class="material-icons-outlined" aria-hidden="true">apartment</span>公司主体与联系人</h2></div>
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-company"><span class="material-icons-outlined" aria-hidden="true">{{ $onboarding->isPerson() ? 'person' : 'apartment' }}</span>入驻主体与联系人</h2><span class="cod__badge {{ $onboarding->isPerson() ? 'is-warning' : 'is-info' }}">{{ $onboarding->entityLabel() }}入驻</span></div>
<div class="cod__panel-body">
<dl class="cod__meta">
<div class="is-wide"><dt>公司全称</dt><dd>{{ $show($onboarding->company_full_name) }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ $show($onboarding->unified_social_credit_code) }}</dd></div>
<div><dt>法定代表人</dt><dd>{{ $show($onboarding->legal_representative) }}</dd></div>
<div class="is-wide"><dt>{{ $onboarding->subjectLabel() }}</dt><dd>{{ $show($onboarding->company_full_name) }}</dd></div>
@if ($onboarding->isPerson())
<div><dt>身份证号</dt><dd>{{ $show($onboarding->maskedIdNumber()) }}</dd></div>
@else
<div><dt>统一社会信用代码</dt><dd>{{ $show($onboarding->unified_social_credit_code) }}</dd></div>
<div><dt>法定代表人</dt><dd>{{ $show($onboarding->legal_representative) }}</dd></div>
@endif
<div><dt>快递品牌 / 业务类型</dt><dd>{{ $show($onboarding->carrier_brand) }} · {{ $show($onboarding->service_type) }}</dd></div>
<div><dt>网点名称</dt><dd>{{ $show($onboarding->network_name) }}</dd></div>
<div class="is-wide" style="grid-column:span 2"><dt>经营地址</dt><dd>{{ $show($onboarding->business_address) }}</dd></div>
<div><dt>业务对接人</dt><dd>{{ $show($onboarding->contact_name) }} {{ $onboarding->contact_phone }}</dd></div>
<div><dt>财务对账人</dt><dd>{{ $show($onboarding->finance_contact_name) }} {{ $onboarding->finance_contact_phone }}</dd></div>
<div><dt>e签宝签署人</dt><dd>{{ $show($onboarding->esign_operator_name) }} {{ $onboarding->esign_account }}</dd></div>
<div><dt>提交时间</dt><dd>{{ $onboarding->submitted_at?->format('Y-m-d H:i') ?? '—' }}</dd></div>
<div class="is-wide"><dt>可揽收仓库 / 区域</dt><dd>{{ $show($onboarding->pickup_warehouses) }}</dd></div>
</dl>
@ -137,48 +137,57 @@
<section class="cod__panel" aria-labelledby="cod-settle">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-settle"><span class="material-icons-outlined" aria-hidden="true">account_balance</span>结算与开票</h2></div>
<div class="cod__panel-body">
@php
$accountChecks = $onboarding->accountChecks();
$checkBadge = static fn (?bool $ok, string $okText, string $badText): string => $ok === null ? '' : '<span style="margin-left:6px;font-size:12px;color:' . ($ok ? '#2F6B2A' : '#A1281F') . '">' . e($ok ? '✓ ' . $okText : '✗ ' . $badText) . '</span>';
@endphp
<dl class="cod__meta">
<div><dt>收款户名</dt><dd>{{ $show($onboarding->bank_account_name) }}</dd></div>
<div><dt>开户行</dt><dd>{{ $show($onboarding->bank_name) }}</dd></div>
<div><dt>银行账号</dt><dd>{{ $show($onboarding->bank_account_number) }}</dd></div>
<div><dt>对公户名</dt><dd>{{ $show($onboarding->bank_account_name) }}{!! $checkBadge($accountChecks['corporate'], '与营业执照公司全称一致', '与公司全称不一致') !!}</dd></div>
<div><dt>对公开户行</dt><dd>{{ $show($onboarding->bank_name) }}</dd></div>
<div><dt>对公账号</dt><dd>{{ $show($onboarding->bank_account_number) }}</dd></div>
<div><dt>对私收款人</dt><dd>{{ $show($onboarding->private_account_name) }}{!! $checkBadge($accountChecks['private'], $onboarding->isPerson() ? '与本人姓名一致' : '与法定代表人一致', $onboarding->isPerson() ? '与本人姓名不一致' : '与法定代表人不一致') !!}</dd></div>
<div><dt>对私开户行</dt><dd>{{ $show($onboarding->private_bank_name) }}</dd></div>
<div><dt>对私卡号</dt><dd>{{ $show($onboarding->private_account_number) }}</dd></div>
<div><dt>开票类型</dt><dd>{{ $show($onboarding->invoice_type) }}</dd></div>
<div><dt>税率</dt><dd>{{ $onboarding->tax_rate !== null ? $num($onboarding->tax_rate) . '%' : '—' }}</dd></div>
<div><dt>结算周期</dt><dd>{{ $show($onboarding->settlement_cycle) }}</dd></div>
</dl>
@if ($onboarding->bank_account_name && $onboarding->company_full_name && $onboarding->bank_account_name !== $onboarding->company_full_name)
<p class="cod__help" style="margin:12px 0 0;color:#935f07">收款户名与公司全称不一致,请核对是否为对私账户或代收账户。</p>
@endif
<p class="cod__help" style="margin:12px 0 0">核对依据:快递填写的营业执照公司全称 / 法定代表人{{ $onboarding->isPerson() ? '(个人入驻为本人姓名)' : '' }}。请对照下方营业执照、身份证和银行卡照片确认填写无误。</p>
</div>
</section>
<section class="cod__panel" aria-labelledby="cod-quote">
<div class="cod__panel-head">
<h2 class="cod__panel-title" id="cod-quote"><span class="material-icons-outlined" aria-hidden="true">request_quote</span>报价</h2>
<h2 class="cod__panel-title" id="cod-quote"><span class="material-icons-outlined" aria-hidden="true">request_quote</span>{{ $quoteVersions->count() > 1 ? "入驻首版报价" : "报价" }}</h2>
@if ($onboarding->quoteVersion)
<a class="cod__button" href="{{ $quoteUrl }}"><span class="material-icons-outlined" aria-hidden="true">open_in_new</span>报价管理 {{ $onboarding->quoteVersion->version_code }}</a>
@endif
</div>
<div class="cod__panel-body">
<dl class="cod__meta" style="margin-bottom:14px">
<div><dt>计费方式</dt><dd>{{ $show($onboarding->quoteModeLabel()) }}</dd></div>
<div><dt>是否计泡</dt><dd>{{ $onboarding->volumetric_rule === '计泡' ? '计泡(长×宽×高÷' . $onboarding->volumetric_divisor . ')' : $show($onboarding->volumetric_rule) }}</dd></div>
<div><dt>计费方式</dt><dd><strong>{{ $show($onboarding->quoteModeLabel()) }}</strong></dd></div>
<div><dt>执行日期</dt><dd>{{ $onboarding->quote_effective_date?->format('Y-m-d') ?? '—' }}</dd></div>
<div><dt>重量取整</dt><dd>{{ \App\Support\CourierQuoteStructure::ROUNDING[$onboarding->weight_rounding ?? 'none'] ?? '不取整' }}</dd></div>
<div class="is-wide"><dt>计费规则</dt><dd>@foreach ($onboarding->quoteRuleLines() as $rule)<div>{{ $rule }}</div>@endforeach</dd></div>
<div class="is-wide"><dt>报价备注</dt><dd>{{ $show($onboarding->quote_note) }}</dd></div>
</dl>
@if ($quoteRows === [])
<p class="cod__help">快递方没有在线填写报价明细,请以附件中的盖章报价单为准。</p>
@if ($onboarding->quote_mode === 'other')
<div class="cod__alert" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>特殊计费,快递上传了报价表{{ $quoteSheet ? ':' . $quoteSheet->original_name : '' }}。复审通过后请在「报价管理」里按报价表录入首版报价,再发起报价确认。</span></div>
@if ($quoteSheet)<p style="margin:10px 0 0"><a class="cod__button" href="{{ route('business.courier-onboardings.file', [$onboarding, $quoteSheet]) }}?download=1"><span class="material-icons-outlined" aria-hidden="true">download</span>下载报价表</a></p>@endif
@elseif ($quoteRows === [])
<p class="cod__help">快递方还没有填写报价明细。</p>
@else
<div style="overflow-x:auto">
<table aria-label="报价明细">
<thead><tr><th>省份 / 区域</th>@foreach ($modeHeaders as $header)<th class="is-number">{{ $header }}</th>@endforeach</tr></thead>
<thead><tr><th>省份 / 区域</th>@foreach ($quoteColumns as $column)<th class="is-number">{{ $column['label'] }}</th>@endforeach</tr></thead>
<tbody>
@foreach ($quoteRows as $row)
<tr><td>{{ $row['region'] }}</td><td class="is-number">{{ $num($row['first_weight'] ?? null) }}</td><td class="is-number">{{ $num($row['first_price'] ?? null) }}</td><td class="is-number">{{ $num($row['rate'] ?? null) }}</td></tr>
<tr><td>{{ $row['region'] }}</td>@foreach (\App\Support\CourierQuoteStructure::rowValues($row, $quoteColumns) as $cell)<td class="is-number">{{ $num($cell) }}</td>@endforeach</tr>
@endforeach
</tbody>
</table>
</div>
<p class="cod__help" style="margin:10px 0 0">共 {{ count($quoteRows) }} 行。复审时请与盖章报价单逐项核对;复审通过后这份报价会作为首版报价写进报价管理。</p>
<p class="cod__help" style="margin:10px 0 0">共 {{ count($quoteRows) }} 个地区。复审通过后这份报价会作为首版报价写进报价管理,并生成报价确认单推送 e签宝。</p>
@endif
</div>
</section>
@ -195,12 +204,12 @@
<a class="cod__file" href="{{ $fileUrl }}" target="_blank" rel="noopener">
<span class="cod__file-thumb">
@if ($file->isImage())
<img src="{{ $fileUrl }}" alt="{{ $file->kindLabel() }}" loading="lazy">
<img src="{{ $fileUrl }}" alt="{{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }}" loading="lazy">
@else
<span class="material-icons-outlined" aria-hidden="true" style="font-size:34px">description</span>
@endif
</span>
<span class="cod__file-kind">{{ $file->kindLabel() }}</span>
<span class="cod__file-kind">{{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }}</span>
<span class="cod__file-name" title="{{ $file->original_name }}">{{ $file->original_name }}</span>
<span class="cod__file-size">{{ $file->sizeLabel() }} · {{ $file->created_at?->format('m-d H:i') }}</span>
</a>
@ -267,17 +276,25 @@
@if ($canFinalReview)
<form method="POST" action="{{ route('business.courier-onboardings.final-approve', $onboarding) }}" class="cod__stack">
@csrf
<div class="cod__field">
<label for="cod-courier-name">系统快递名称 *</label>
<input id="cod-courier-name" name="courier_name" type="text" maxlength="100" required value="{{ old('courier_name', $suggestedName) }}">
<span class="cod__help">必须与报价名、发货数据里的「快递名称」完全一致,复审后不可再改。</span>
</div>
@if ($onboarding->courier_name)
<div class="cod__field">
<label>系统快递名称</label>
<div style="font-size:15px;font-weight:700">{{ $onboarding->courier_name }}</div>
<span class="cod__help">生成二维码时已定好;复审通过后锁定,首版报价和 e签宝报价确认单都用这个名称。如需更正,请先在下方「内部信息」修改。</span>
</div>
@else
<div class="cod__field">
<label for="cod-courier-name">系统快递名称 *</label>
<input id="cod-courier-name" name="courier_name" type="text" maxlength="100" required value="{{ old('courier_name') }}">
<span class="cod__help">必须与报价名、发货数据里的「快递名称」完全一致,复审后不可再改。</span>
</div>
@endif
<div class="cod__field"><label for="cod-reconciler">对账人</label><input id="cod-reconciler" name="reconciler" type="text" maxlength="100" value="{{ old('reconciler', $onboarding->reconciler) }}"></div>
<div class="cod__field"><label for="cod-final-note">复审意见(选填)</label><textarea id="cod-final-note" name="note" maxlength="1000"></textarea></div>
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">verified</span>复审通过,生成首版报价</button>
</form>
@elseif ($isAdmin)
<p class="cod__help" style="margin:0">你已做过初审,需要另一位管理员完成复审。</p>
<div class="cod__alert" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">info</span><span>这条申请的初审是你做的。为防止一人包办,复审必须换另一位管理员账号登录来做,复审按钮会出现在这里。</span></div>
@else
<p class="cod__help" style="margin:0">等待管理员(财务)复审。</p>
@endif
@ -285,30 +302,63 @@
</div>
</section>
@elseif ($status === 'approved')
@php
$quoteSigned = $quoteSignature?->status === 'archived';
$canPush = $isAdmin && $esignReady && $onboarding->quoteVersion && (! $quoteSignature || $quoteSignature->canSend());
@endphp
<section class="cod__panel" aria-labelledby="cod-sign">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-sign"><span class="material-icons-outlined" aria-hidden="true">draw</span>签署并开通</h2></div>
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-sign"><span class="material-icons-outlined" aria-hidden="true">draw</span>报价确认 · 开通</h2></div>
<div class="cod__panel-body cod__stack">
<p class="cod__help" style="margin:0">到「协议签署」发起合作协议(快递名称填 <strong>{{ $onboarding->courier_name }}</strong>,签署手机 {{ $onboarding->contact_phone }})@if ($onboarding->quoteVersion),报价确认单选择 <strong>{{ $onboarding->quoteVersion->version_code }}</strong>@endif。签署完成后回到这里开通。</p>
<a class="cod__button" href="{{ $agreementUrl }}" target="_blank" rel="noopener"><span class="material-icons-outlined" aria-hidden="true">open_in_new</span>去协议签署</a>
<div class="cod__divider"></div>
@if ($signatures->isEmpty())
<p class="cod__help" style="margin:0">还没有找到「{{ $onboarding->courier_name }}」的签署任务。</p>
@else
<table aria-label="签署任务">
<thead><tr><th>签署任务</th><th>状态</th></tr></thead>
<tbody>@foreach ($signatures as $signature)<tr><td>{{ $signature->business_no }}<br><span class="cod__help">{{ $signature->documentLabel() }}</span></td><td>{{ $signature->statusLabel() }}</td></tr>@endforeach</tbody>
</table>
<dl class="cod__meta" style="grid-template-columns:1fr 1fr">
<div><dt>报价确认单</dt><dd>{{ $onboarding->quoteVersion?->version_code ?? '—' }}</dd></div>
<div><dt>e签宝状态</dt><dd><strong>{{ $quoteSignature?->statusLabel() ?? '未推送' }}</strong></dd></div>
<div style="grid-column:span 2"><dt>签署方</dt><dd>{{ $show($onboarding->company_full_name) }}<br>{{ $show($onboarding->esign_operator_name) }} · {{ $show($onboarding->esign_account) }}</dd></div>
</dl>
@if ($quoteSignature?->last_error)
<div class="cod__alert is-error" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $quoteSignature->last_error }}</span></div>
@endif
@unless ($esignReady)
<p class="cod__help" style="margin:0">E 签宝尚未配置(本地预览环境正常现象),报价确认单无法推送;线下签署后填写说明即可开通。</p>
@endunless
<div class="cod__row">
@if ($canPush)
<form method="POST" action="{{ route('business.courier-onboardings.push-quote', $onboarding) }}">@csrf<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">send</span>{{ $quoteSignature ? '重新推送' : '推送到 e签宝' }}</button></form>
@endif
@if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned)
<form method="POST" action="{{ route('business.courier-onboardings.sync-quote', $onboarding) }}">@csrf<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">sync</span>同步签署状态</button></form>
<form method="POST" action="{{ route('business.courier-onboardings.sign-url', $onboarding) }}">@csrf<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">link</span>获取签署链接</button></form>
@endif
@if ($quoteSigned && $quoteSignature->signed_pdf_path)
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">task</span>回签单 PDF</a>
<a class="cod__button" href="{{ route('business.courier-quote-signatures.sheet', $onboarding->quoteVersion) }}"><span class="material-icons-outlined" aria-hidden="true">table_view</span>导出电子档</a>
@endif
@if ($quoteSignature?->source_pdf_path)
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'source']) }}"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span>报价确认单 PDF</a>
@endif
</div>
@if (session('quote_sign_url'))
<div class="cod__stack" style="gap:8px;padding:12px;border:1px dashed var(--cod-line, #d9cfb8);border-radius:10px">
<strong style="font-size:13px">签署链接(签署人 {{ $show($onboarding->esign_operator_name) }} 用手机扫码或打开)</strong>
<div style="width:150px;height:150px">{!! \App\Support\QrCode::svg(session('quote_sign_url'), 4, 2) !!}</div>
<input type="text" readonly value="{{ session('quote_sign_url') }}" onclick="this.select()" style="width:100%;font-size:13px;padding:6px 8px;border:1px solid #ddd;border-radius:6px">
<p class="cod__help" style="margin:0">可以直接把链接或二维码发给快递。打开后需先用 {{ $show($onboarding->esign_account) }} 登录 / 实名{{ $onboarding->isPerson() ? '' : ',并以「' . $onboarding->company_full_name . '」企业身份签署' }}。</p>
</div>
@endif
@if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned && ! $onboarding->isPerson())
<p class="cod__help" style="margin:0">企业签署的任务在 e签宝里归属「{{ $show($onboarding->company_full_name) }}」这家企业:签署人会收到短信;在 e签宝 App 里要切换到这家企业才能看到待签任务。企业名称必须和营业执照完全一致、且已在 e签宝完成企业认证(或由签署人当场认证),否则无法签署。</p>
@endif
<div class="cod__divider"></div>
@if ($isAdmin)
<form method="POST" action="{{ route('business.courier-onboardings.activate', $onboarding) }}" class="cod__stack">
@csrf
@unless ($signedAgreement)
<div class="cod__field"><label for="cod-offline">线下签署说明</label><textarea id="cod-offline" name="offline_note" maxlength="1000" placeholder="未走电子签时必填,如:纸质协议已签,原件在财务处">{{ old('offline_note') }}</textarea></div>
@unless ($quoteSigned || $signedAgreement)
<div class="cod__field"><label for="cod-offline">线下签署说明</label><textarea id="cod-offline" name="offline_note" maxlength="1000" placeholder="报价确认单未在 e签宝签完时必填,如:已线下盖章,原件在财务处">{{ old('offline_note') }}</textarea></div>
@endunless
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">how_to_reg</span>确认开通</button>
@if ($quoteSigned)<span class="cod__help">报价确认单已签署归档,可以直接开通。</span>@endif
</form>
@else
<p class="cod__help" style="margin:0">开通需要管理员账号。</p>
<p class="cod__help" style="margin:0">推送和开通需要管理员账号。</p>
@endif
</div>
</section>
@ -316,6 +366,13 @@
<section class="cod__panel"><div class="cod__panel-body cod__stack">
<div class="cod__alert is-success" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">how_to_reg</span><span>{{ $onboarding->activated_at?->format('Y-m-d H:i') }} 已开通,可在快递资料「入驻快递」查看。</span></div>
<a class="cod__button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'onboarded']) }}"><span class="material-icons-outlined" aria-hidden="true">open_in_new</span>快递资料 · 入驻快递</a>
<a class="cod__button" href="{{ $quoteUrl }}"><span class="material-icons-outlined" aria-hidden="true">price_change</span>报价管理 · 快递报价(变价)</a>
@if ($quoteSignature?->status === 'archived' && $quoteSignature->signed_pdf_path)
<div class="cod__row">
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">task</span>回签单 PDF</a>
<a class="cod__button" href="{{ route('business.courier-quote-signatures.sheet', $onboarding->quoteVersion) }}"><span class="material-icons-outlined" aria-hidden="true">table_view</span>导出电子档</a>
</div>
@endif
</div></section>
@endif
@ -337,6 +394,34 @@
@endif
</div>
</section>
@if ($status === 'activated')
<section class="cod__panel" aria-labelledby="cod-erp">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-erp"><span class="material-icons-outlined" aria-hidden="true">hub</span>ERP 开通</h2>
@if ($onboarding->erpPending())<span class="cod__pill" style="margin-left:auto;padding:2px 8px;border-radius:6px;font-size:12px;background:#FFF6E5;color:#935F07">待 ERP 开通</span>@else<span class="cod__pill" style="margin-left:auto;padding:2px 8px;border-radius:6px;font-size:12px;background:#EEF6EA;color:#2F6B2A">已开通</span>@endif
</div>
<div class="cod__panel-body cod__stack">
@if ($onboarding->erpPending())
<p class="cod__help" style="margin:0">下一步:ERP 专员在钉钉发起「新建快递」流程,开通发货系统账号。流程完结后,流程里填的快递代码、ERP账户ID 会自动补到这里和快递资料。</p>
@else
<dl class="cod__meta" style="grid-template-columns:1fr 1fr">
<div><dt>快递代码</dt><dd>{{ $onboarding->workplatform_expcode }}</dd></div>
<div><dt>ERP账户ID</dt><dd>{{ $onboarding->erp_account_id ?: '—' }}</dd></div>
<div><dt>开通时间</dt><dd>{{ $onboarding->erp_opened_at?->format('Y-m-d H:i') ?? '—' }}</dd></div>
<div><dt>来源</dt><dd>{{ $onboarding->erpSourceLabel() ?: '—' }}</dd></div>
</dl>
@endif
<details>
<summary class="cod__help" style="cursor:pointer">钉钉流程同步不到时手动补录</summary>
<form method="POST" action="{{ route('business.courier-onboardings.erp-opening', $onboarding) }}" class="cod__stack" style="margin-top:10px">
@csrf
<div class="cod__field"><label for="cod-erp-code">快递代码</label><input id="cod-erp-code" name="workplatform_expcode" type="text" maxlength="64" value="{{ $onboarding->workplatform_expcode }}" placeholder="ERP 开通后的快递代码"></div>
<div class="cod__field"><label for="cod-erp-account">ERP账户ID</label><input id="cod-erp-account" name="erp_account_id" type="text" maxlength="64" value="{{ $onboarding->erp_account_id }}"></div>
<button type="submit" class="cod__button">保存</button>
</form>
</details>
</div>
</section>
@endif
</aside>
</div>
</div>

View File

@ -41,6 +41,7 @@
.co-field { display:grid; gap:.3rem; min-width:0; } .co-field label { color:var(--co-muted); font-size:.72rem; font-weight:600; }
.co-field input, .co-field select { width:100%; height:2.2rem; padding:0 .6rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; }
.co-field input:focus, .co-field select:focus { border-color:var(--co-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); }
.co-help { color:var(--co-muted); font-size:.68rem; line-height:1.5; } .co-error { color:#b80000; font-size:.72rem; }
.co-invite__row { display:grid; grid-template-columns:minmax(0,1fr) 7rem; gap:.6rem; }
.co-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .8rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; }
.co-button .material-icons-outlined { font-size:1rem; } .co-button--primary { border-color:var(--co-accent); background:var(--co-accent); color:#fff; } .co-button--primary:hover { background:#6a5129; }
@ -91,12 +92,24 @@
<li><strong>签署并开通</strong><span>协议签署后开通,进快递资料「入驻快递」</span></li>
</ol>
</div>
<form class="co-card" method="POST" action="{{ route('business.courier-onboardings.store') }}">
<form class="co-card" id="invite" method="POST" action="{{ route('business.courier-onboardings.store') }}">
@csrf
<div class="co-card__head">
<h2 class="co-card__title"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>生成入驻二维码</h2>
</div>
<div class="co-invite">
<div class="co-field">
<label for="co-expcode">快递公司代码 *</label>
<input id="co-expcode" name="workplatform_expcode" type="text" maxlength="64" required value="{{ old('workplatform_expcode', request('expcode')) }}" placeholder="如:SQZTKY" autocomplete="off">
<span class="co-help">与快递资料「快递公司代码」一致,入驻信息按这个代码对齐到快递资料。</span>
@error('workplatform_expcode')<span class="co-error">{{ $message }}</span>@enderror
</div>
<div class="co-field">
<label for="co-courier-name">系统快递名称 *</label>
<input id="co-courier-name" name="courier_name" type="text" maxlength="100" required value="{{ old('courier_name', request('courier_name')) }}" placeholder="如:新北中通、小件宿迁圆通">
<span class="co-help">我方规定的名称,需与报价名、发货数据里的快递名称完全一致;快递方看不到。</span>
@error('courier_name')<span class="co-error">{{ $message }}</span>@enderror
</div>
<div class="co-invite__row">
<div class="co-field">
<label for="co-invite-label">备注(快递方也能看到)</label>
@ -134,7 +147,7 @@
<div class="co-table-wrap">
<table class="co-table" aria-label="入驻申请">
<thead>
<tr><th>入驻单号</th><th>状态</th><th>备注</th><th>公司全称</th><th>网点 / 品牌</th><th>对接人</th><th>系统快递名称</th><th>附件</th><th>提交时间</th><th>最近更新</th><th>操作</th></tr>
<tr><th>入驻单号</th><th>状态</th><th>备注</th><th>类型</th><th>公司 / 姓名</th><th>网点 / 品牌</th><th>对接人</th><th>系统快递名称</th><th>附件</th><th>提交时间</th><th>最近更新</th><th>操作</th></tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
@ -142,6 +155,7 @@
<td class="is-code"><a href="{{ route('business.courier-onboardings.show', $row) }}">{{ $row->number }}</a></td>
<td><span class="co-badge {{ $badge($row->status) }}">{{ $row->linkExpired() && in_array($row->status, ['invited', 'returned'], true) ? '链接已过期' : $row->statusLabel() }}</span></td>
<td class="is-clamp" title="{{ $row->invite_label }}">{{ $row->invite_label ?: '—' }}</td>
<td>{{ $row->company_full_name ? $row->entityLabel() : '—' }}</td>
<td class="is-clamp" title="{{ $row->company_full_name }}">{{ $row->company_full_name ?: '—' }}</td>
<td>{{ $row->network_name ?: '—' }}{{ $row->carrier_brand ? ' · ' . $row->carrier_brand : '' }}</td>
<td>{{ $row->contact_name ?: '—' }}{{ $row->contact_phone ? ' ' . $row->contact_phone : '' }}</td>
@ -152,7 +166,7 @@
<td><a href="{{ route('business.courier-onboardings.show', $row) }}">{{ in_array($row->status, ['submitted', 'first_approved', 'approved'], true) ? '去处理' : '查看' }}</a></td>
</tr>
@empty
<tr><td colspan="11" class="co-empty">{{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}</td></tr>
<tr><td colspan="12" class="co-empty">{{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}</td></tr>
@endforelse
</tbody>
</table>

View File

@ -60,7 +60,7 @@
--settlement-soft: var(--vip-content-accent-soft, #f7f0de);
--settlement-border: var(--vip-border, #d8d2c7);
display: grid;
gap: 1rem;
gap: .65rem;
min-width: 0;
color: var(--settlement-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
@ -97,36 +97,37 @@
}
.settlement-summary__intro,
.settlement-summary__metric { min-width: 0; padding: 1rem 1.15rem; }
.settlement-summary__intro { display: flex; align-items: center; gap: .75rem; }
.settlement-summary__icon { display: grid; flex: none; width: 2.4rem; height: 2.4rem; place-items: center; border-radius: .6rem; background: var(--settlement-soft); color: var(--settlement-accent); }
.settlement-summary__title { font-size: .95rem; font-weight: 700; }
.settlement-summary__subtitle { margin-top: .2rem; color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__metric { min-width: 0; padding: .55rem .9rem; }
.settlement-summary__intro { display: flex; align-items: center; gap: .6rem; }
.settlement-summary__icon { display: grid; flex: none; width: 1.9rem; height: 1.9rem; place-items: center; border-radius: .5rem; background: var(--settlement-soft); color: var(--settlement-accent); }
.settlement-summary__title { font-size: .875rem; font-weight: 700; }
.settlement-summary__subtitle { margin-top: .1rem; color: var(--settlement-muted); font-size: .68rem; }
.settlement-summary__metric { border-left: 1px solid var(--settlement-border); }
.settlement-summary__label { color: var(--settlement-muted); font-size: .72rem; }
.settlement-summary__value { margin-top: .25rem; font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.settlement-summary__label { color: var(--settlement-muted); font-size: .68rem; }
.settlement-summary__value { margin-top: .15rem; font-size: .95rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.settlement-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(10rem, 1fr));
gap: .7rem;
gap: .45rem .6rem;
align-items: end;
padding: 1rem 1.15rem;
padding: .55rem .9rem;
border: 1px solid var(--settlement-border);
border-radius: .75rem;
background: #fffdf8;
}
.settlement-field { display: grid; gap: .3rem; min-width: 0; }
.settlement-field label { color: var(--settlement-muted); font-size: .72rem; font-weight: 600; }
.settlement-field { display: grid; gap: .2rem; min-width: 0; }
.settlement-field[hidden] { display: none; }
.settlement-field label { color: var(--settlement-muted); font-size: .68rem; font-weight: 600; }
.settlement-field input,
.settlement-field select {
width: 100%;
height: 2.2rem;
height: 1.9rem;
min-width: 0;
padding: 0 .6rem;
padding: 0 .5rem;
border: 1px solid var(--settlement-border);
border-radius: .45rem;
border-radius: .4rem;
background: #fff;
color: var(--settlement-ink);
font: inherit;
@ -134,23 +135,26 @@
.settlement-field input:focus,
.settlement-field select:focus { border-color: var(--settlement-accent); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); }
.settlement-actions { display: flex; flex-wrap: wrap; gap: .45rem; align-items: end; }
.settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-height: 2.2rem; padding: 0 .75rem; border: 1px solid var(--settlement-border); border-radius: .45rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; }
.settlement-actions { display: flex; flex-wrap: wrap; gap: .4rem; align-items: end; }
.settlement-filter-bar .settlement-actions { grid-column: 1 / -1; }
.settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .3rem; min-height: 1.9rem; padding: 0 .65rem; border: 1px solid var(--settlement-border); border-radius: .4rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; }
.settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; }
.settlement-button .material-icons-outlined { font-size: 1rem; }
.settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem 1rem; margin-top: .75rem; padding: .85rem 1.15rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-import-bar .settlement-field { flex: 0 1 22rem; }
.settlement-import-bar input[type="file"] { width: 100%; height: 2.2rem; padding: .35rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .45rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; }
.settlement-import-bar .settlement-table-hint { align-self: center; }
.settlement-button .material-icons-outlined { font-size: .95rem; }
.settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .45rem .75rem; margin-top: .45rem; padding: .5rem .9rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-import-bar .settlement-field { flex: 0 1 18rem; }
.settlement-import-bar input[type="file"] { width: 100%; height: 1.9rem; padding: .25rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .4rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; }
.settlement-import-bar .settlement-table-hint { align-self: center; flex-basis: 100%; }
.settlement-import-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .15rem .9rem; margin-left: auto; align-self: center; color: var(--settlement-muted); font-size: .7rem; }
.settlement-import-meta strong { color: var(--settlement-ink); }
.settlement-table-surface { overflow: visible; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; }
.settlement-table-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .75rem 1rem; padding: .85rem 1.15rem; border-bottom: 1px solid var(--settlement-border); }
.settlement-table-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .4rem .9rem; padding: .5rem .9rem; border-bottom: 1px solid var(--settlement-border); }
.settlement-table-head .finance-table-controls { margin: 0; padding: 0; border: 0; background: transparent; }
.settlement-table-title { display: flex; align-items: center; gap: .45rem; font-size: .9rem; font-weight: 700; }
.settlement-table-title .material-icons-outlined { color: var(--settlement-accent); font-size: 1.1rem; }
.settlement-table-hint { color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem 1rem; padding: .65rem 1.15rem; border-bottom: 1px solid var(--settlement-border); background: #fffdf8; color: var(--settlement-muted); font-size: .72rem; }
.settlement-table-meta strong { color: var(--settlement-ink); }
.settlement-table-scroll { max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; }
.settlement-table-hint { color: var(--settlement-muted); font-size: .68rem; }
.settlement-table-scroll { overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; }
.surcharge-settlement-page .settlement-table-scroll { max-height: max(14rem, min(calc(100vh - 12rem), 46rem)); }
.settlement-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .75rem; }
.settlement-table th,
.settlement-table td { min-width: 7.5rem; padding: .62rem .7rem; border-right: 1px solid var(--settlement-border); border-bottom: 1px solid var(--settlement-border); text-align: left; vertical-align: top; white-space: nowrap; }
@ -161,6 +165,21 @@
.settlement-table td[data-finance-fixed="end"] { background: #fffdf8; }
.settlement-table td { overflow: hidden; text-overflow: ellipsis; }
.settlement-table .settlement-empty td { min-width: 22rem; padding: 3rem 1rem; text-align: center; color: var(--settlement-muted); white-space: normal; }
.settlement-table tbody tr.is-clickable { cursor: pointer; }
.settlement-table tbody tr.is-clickable:not(.is-active):hover td,
.settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="true"],
.settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="end"] { background: #fcfaf5; }
.settlement-table tbody tr.is-active td,
.settlement-table tbody tr.is-active td[data-finance-fixed="true"],
.settlement-table tbody tr.is-active td[data-finance-fixed="end"] { background: var(--settlement-soft); border-bottom-color: #e4d7ba; }
.settlement-table tbody tr.is-active td:first-child { box-shadow: inset 3px 0 0 var(--settlement-accent); }
.settlement-footer { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .35rem .15rem 0; color: var(--settlement-muted); font-size: .72rem; }
.settlement-pagination { display: flex; align-items: center; gap: .35rem; }
.settlement-page-link { display: inline-flex; align-items: center; justify-content: center; min-width: 1.7rem; height: 1.6rem; border: 1px solid var(--settlement-border); border-radius: .4rem; background: #fff; color: var(--settlement-ink); text-decoration: none; }
.settlement-page-link:hover { border-color: var(--settlement-accent); background: var(--settlement-soft); }
.settlement-page-link.is-disabled { opacity: .4; pointer-events: none; }
.settlement-page-link .material-icons-outlined { font-size: 1rem; }
.settlement-filter-picker .finance-table-field-picker__panel { right: auto; left: 0; max-width: min(28rem, calc(100vw - 1rem)); }
@media (max-width: 900px) {
.settlement-summary { grid-template-columns: 1fr 1fr; }
@ -204,19 +223,19 @@
</div>
<form class="settlement-filter-bar" method="GET" action="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}" aria-label="附加费结算筛选">
<div class="settlement-field"><label for="settlement-courier">快递名称</label><input id="settlement-courier" type="search" name="courier_name" value="{{ $filterValue('courier_name') }}" placeholder="输入快递名称"></div>
<div class="settlement-field"><label for="settlement-waybill">单号</label><input id="settlement-waybill" type="search" name="waybill" value="{{ $filterValue('waybill') }}" placeholder="输入单号"></div>
<div class="settlement-field"><label for="settlement-platform-product">平台商品ID</label><input id="settlement-platform-product" type="search" name="platform_product_id" value="{{ $filterValue('platform_product_id') }}" placeholder="输入平台商品ID"></div>
<div class="settlement-field"><label for="settlement-product-code">商品编码</label><input id="settlement-product-code" type="search" name="product_code" value="{{ $filterValue('product_code') }}" placeholder="输入商品编码"></div>
<div class="settlement-field"><label for="settlement-shop">店铺名称</label><input id="settlement-shop" type="search" name="shop_name" value="{{ $filterValue('shop_name') }}" placeholder="输入店铺名称"></div>
<div class="settlement-field"><label for="settlement-online-order">线上订单号</label><input id="settlement-online-order" type="search" name="online_order_no" value="{{ $filterValue('online_order_no') }}" placeholder="输入线上订单号"></div>
<div class="settlement-field"><label for="settlement-inspection">查件类型</label><input id="settlement-inspection" type="search" name="inspection_type" value="{{ $filterValue('inspection_type') }}" placeholder="一级选项"></div>
<div class="settlement-field"><label for="settlement-reason">查件原因</label><input id="settlement-reason" type="search" name="inspection_reason" value="{{ $filterValue('inspection_reason') }}" placeholder="二级选项"></div>
<div class="settlement-field"><label for="settlement-surcharge-type">附加费类型</label><select id="settlement-surcharge-type" name="surcharge_type"><option value="">全部附加费类型</option>@foreach (['退回费','拦截费','改地址','上楼费','送货费','超区费','安装费','保费','进仓费','超长费'] as $type)<option value="{{ $type }}" @selected($filterValue('surcharge_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="settlement-field"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option>@foreach (['未匹配报价','待核价','已计算'] as $status)<option value="{{ $status }}" @selected($filterValue('status') === $status)>{{ $status }}</option>@endforeach</select></div>
<div class="settlement-field"><label for="settlement-created-from">创建日期起</label><input id="settlement-created-from" type="date" name="created_from" value="{{ $filterValue('created_from') }}"></div>
<div class="settlement-field"><label for="settlement-created-to">创建日期止</label><input id="settlement-created-to" type="date" name="created_to" value="{{ $filterValue('created_to') }}"></div>
<div class="settlement-actions"><button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="settlement-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a><a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.export', array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null)) }}" title="按当前筛选导出附加费结算明细(XLSX,仅含已生成结算行的数据;未生成结算行的运单请用导入核验)"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a></div>
<div class="settlement-field" data-settlement-filter-field="courier_name"><label for="settlement-courier">快递名称</label><input id="settlement-courier" type="search" name="courier_name" value="{{ $filterValue('courier_name') }}" placeholder="输入快递名称"></div>
<div class="settlement-field" data-settlement-filter-field="waybill"><label for="settlement-waybill">单号</label><input id="settlement-waybill" type="search" name="waybill" value="{{ $filterValue('waybill') }}" placeholder="输入单号"></div>
<div class="settlement-field" data-settlement-filter-field="platform_product_id"><label for="settlement-platform-product">平台商品ID</label><input id="settlement-platform-product" type="search" name="platform_product_id" value="{{ $filterValue('platform_product_id') }}" placeholder="输入平台商品ID"></div>
<div class="settlement-field" data-settlement-filter-field="product_code"><label for="settlement-product-code">商品编码</label><input id="settlement-product-code" type="search" name="product_code" value="{{ $filterValue('product_code') }}" placeholder="输入商品编码"></div>
<div class="settlement-field" data-settlement-filter-field="shop_name"><label for="settlement-shop">店铺名称</label><input id="settlement-shop" type="search" name="shop_name" value="{{ $filterValue('shop_name') }}" placeholder="输入店铺名称"></div>
<div class="settlement-field" data-settlement-filter-field="online_order_no"><label for="settlement-online-order">线上订单号</label><input id="settlement-online-order" type="search" name="online_order_no" value="{{ $filterValue('online_order_no') }}" placeholder="输入线上订单号"></div>
<div class="settlement-field" data-settlement-filter-field="inspection_type"><label for="settlement-inspection">查件类型</label><input id="settlement-inspection" type="search" name="inspection_type" value="{{ $filterValue('inspection_type') }}" placeholder="一级选项"></div>
<div class="settlement-field" data-settlement-filter-field="inspection_reason"><label for="settlement-reason">查件原因</label><input id="settlement-reason" type="search" name="inspection_reason" value="{{ $filterValue('inspection_reason') }}" placeholder="二级选项"></div>
<div class="settlement-field" data-settlement-filter-field="surcharge_type"><label for="settlement-surcharge-type">附加费类型</label><select id="settlement-surcharge-type" name="surcharge_type"><option value="">全部附加费类型</option>@foreach (['退回费','拦截费','改地址','上楼费','送货费','超区费','安装费','保费','进仓费','超长费'] as $type)<option value="{{ $type }}" @selected($filterValue('surcharge_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="settlement-field" data-settlement-filter-field="status"><label for="settlement-status">结算状态</label><select id="settlement-status" name="status"><option value="">全部状态</option>@foreach (['未匹配报价','待核价','已计算'] as $status)<option value="{{ $status }}" @selected($filterValue('status') === $status)>{{ $status }}</option>@endforeach</select></div>
<div class="settlement-field" data-settlement-filter-field="created_from"><label for="settlement-created-from">创建日期起</label><input id="settlement-created-from" type="date" name="created_from" value="{{ $filterValue('created_from') }}"></div>
<div class="settlement-field" data-settlement-filter-field="created_to"><label for="settlement-created-to">创建日期止</label><input id="settlement-created-to" type="date" name="created_to" value="{{ $filterValue('created_to') }}"></div>
<div class="settlement-actions"><button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="settlement-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a><a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.export', array_filter($filters, static fn ($value): bool => $value !== '' && $value !== null)) }}" title="按当前筛选导出附加费结算明细(XLSX,仅含已生成结算行的数据;未生成结算行的运单请用导入核验)"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a><div class="finance-table-field-picker settlement-filter-picker" data-settlement-filter-picker data-user="{{ auth()->id() }}"><button type="button" class="settlement-button" aria-expanded="false" title="选择要显示的筛选字段,选择会自动保存"><span class="material-icons-outlined" aria-hidden="true">tune</span>筛选设置</button><div class="finance-table-field-picker__panel" role="group" aria-label="选择筛选字段" hidden>@foreach (['courier_name' => '快递名称', 'waybill' => '单号', 'platform_product_id' => '平台商品ID', 'product_code' => '商品编码', 'shop_name' => '店铺名称', 'online_order_no' => '线上订单号', 'inspection_type' => '查件类型', 'inspection_reason' => '查件原因', 'surcharge_type' => '附加费类型', 'status' => '结算状态', 'created_from' => '创建日期起', 'created_to' => '创建日期止'] as $fieldKey => $fieldLabel)<label class="finance-table-field-picker__option"><input type="checkbox" value="{{ $fieldKey }}" data-settlement-filter-check checked><span>{{ $fieldLabel }}</span></label>@endforeach<div class="finance-table-field-picker__actions"><button type="button" data-settlement-filters-all>全选</button><button type="button" data-settlement-filters-reset>恢复默认</button></div></div></div></div>
</form>
<form class="settlement-import-bar" method="POST" action="{{ route('business.courier-surcharge.settlement.import') }}" enctype="multipart/form-data" aria-label="附加费结算导入核验">
@ -228,13 +247,13 @@
<div class="settlement-actions">
<button class="settlement-button settlement-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">upload_file</span>导入并下载结果</button>
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。</span>
</div>
<div class="settlement-import-meta" role="status"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。</span>
</form>
<div class="settlement-table-surface" data-finance-table-surface>
<div class="settlement-table-head"><div><div class="settlement-table-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span>附加费结算明细</div><div class="settlement-table-hint">快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。</div></div><div class="finance-table-controls" data-finance-controls><span class="settlement-table-hint">列设置按当前用户和本页面保存</span></div></div>
<div class="settlement-table-meta"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
<div class="settlement-table-head"><div><div class="settlement-table-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span>附加费结算明细</div><div class="settlement-table-hint">快递名称和附加费类型固定显示,其余字段可在“列设置”中调整;点击行可高亮标记。</div></div><div class="finance-table-controls" data-finance-controls></div></div>
<div class="settlement-table-scroll finance-table-scroll-owner">
<table class="settlement-table" data-finance-table data-finance-table-name="courier-surcharge-settlement" data-finance-table-key="{{ auth()->id() }}:{{ company_id() }}:courier-payables:courier-surcharge:settlement:details" aria-label="附加费结算明细">
<thead><tr>
@ -244,7 +263,7 @@
</tr></thead>
<tbody>
@forelse ($rows as $row)
<tr>
<tr class="is-clickable" data-settlement-row>
@foreach ($columns as $column)
<td data-finance-column-key="{{ $column['key'] }}" data-export-value="{{ $displayValue(data_get($row, $column['key']), $column['key']) }}">{{ $displayValue(data_get($row, $column['key']), $column['key']) }}</td>
@endforeach
@ -257,7 +276,114 @@
</div>
</div>
@if (method_exists($rows, 'links') && $rows->hasPages())
<div class="settlement-pagination">{{ $rows->withQueryString()->links() }}</div>
@if (method_exists($rows, 'hasPages') && $rows->hasPages())
<div class="settlement-footer">
<span>第 {{ $rows->currentPage() }} / {{ $rows->lastPage() }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span>
<div class="settlement-pagination">
<a class="settlement-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->onFirstPage() ? '#' : $rows->url($rows->currentPage() - 1) }}" aria-label="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</a>
<a class="settlement-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->hasMorePages() ? $rows->url($rows->currentPage() + 1) : '#' }}" aria-label="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</a>
</div>
</div>
@endif
</section>
<script>
(() => {
const table = document.querySelector('table[data-finance-table-name="courier-surcharge-settlement"]');
if (!table) {
return;
}
const activate = (row) => {
table.querySelectorAll('tbody tr.is-active').forEach((item) => item.classList.remove('is-active'));
row.classList.add('is-active');
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element) || event.target.closest('a, button, input, label, select')) {
return;
}
const row = event.target.closest('tr[data-settlement-row]');
if (row && table.contains(row)) {
event.preventDefault();
activate(row);
}
});
})();
(() => {
const filterBar = document.querySelector('.settlement-filter-bar');
const filterPicker = document.querySelector('[data-settlement-filter-picker]');
if (filterBar && filterPicker) {
const toggle = filterPicker.querySelector('button[aria-expanded]');
const panel = filterPicker.querySelector('.finance-table-field-picker__panel');
const checks = [...panel.querySelectorAll('input[data-settlement-filter-check]')];
const fields = [...filterBar.querySelectorAll('[data-settlement-filter-field]')];
const storageKey = 'caiwu:courier-surcharge-settlement:filter-fields:' + (filterPicker.dataset.user || 'anonymous');
const defaultKeys = checks.map((check) => check.value);
const apply = (selected, save) => {
fields.forEach((field) => {
const visible = selected.has(field.dataset.settlementFilterField);
field.hidden = !visible;
field.querySelectorAll('input, select').forEach((control) => {
if (visible) {
control.removeAttribute('disabled');
} else {
control.setAttribute('disabled', 'disabled');
}
});
});
checks.forEach((check) => { check.checked = selected.has(check.value); });
if (save) {
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch (_) {}
}
};
let stored = null;
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (Array.isArray(parsed)) {
stored = new Set(parsed.filter((key) => defaultKeys.includes(key)));
}
} catch (_) {}
apply(stored ?? new Set(defaultKeys), false);
toggle.addEventListener('click', () => {
const open = panel.hidden;
panel.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (event) => {
if (!panel.hidden && !filterPicker.contains(event.target)) {
panel.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
});
checks.forEach((check) => check.addEventListener('change', () => {
apply(new Set(checks.filter((item) => item.checked).map((item) => item.value)), true);
}));
panel.querySelector('[data-settlement-filters-all]')?.addEventListener('click', () => {
apply(new Set(defaultKeys), true);
});
panel.querySelector('[data-settlement-filters-reset]')?.addEventListener('click', () => {
try { localStorage.removeItem(storageKey); } catch (_) {}
apply(new Set(defaultKeys), true);
});
}
})();
</script>

View File

@ -1,63 +1,5 @@
@php
$surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [
$tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]),
])->all();
@endphp
<div data-surcharge-workspace>
<div data-surcharge-panel="quotes" @if ($courierSurchargeActiveTab !== 'quotes') hidden @endif>
<div data-surcharge-panel="quotes">
@include('business.pages.courier-payables.courier-surcharge')
</div>
<div data-surcharge-panel="settlement" @if ($courierSurchargeActiveTab !== 'settlement') hidden @endif>
@include('business.pages.courier-payables.courier-surcharge-settlement')
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-surcharge-workspace]');
const content = root.closest('.business-content-section');
const navigation = content.parentElement.querySelector('.business-tab-bar');
const links = [...navigation.querySelectorAll('a')];
const panels = [...root.querySelectorAll('[data-surcharge-panel]')];
const title = document.querySelector('[data-page-title-first] h1');
const documentTitle = document.title;
const initialTitle = @json($courierSurchargeActiveTab === 'settlement' ? '附加费结算' : '附加费报价');
const urls = @json($surchargeTabUrls);
const keyFor = (link) => Object.keys(urls).find(key => new URL(urls[key]).pathname === new URL(link.href).pathname);
const savedUrls = { ...urls };
let active = @json($courierSurchargeActiveTab);
savedUrls[active] = location.href;
function select(key, push = true) {
if (!Object.hasOwn(urls, key)) return;
if (push) savedUrls[active] = location.href;
active = key;
panels.forEach(panel => { panel.hidden = panel.dataset.surchargePanel !== key; });
links.forEach(link => {
const selected = keyFor(link) === key;
link.classList.toggle('is-active', selected);
if (selected) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
const label = key === 'settlement' ? '附加费结算' : '附加费报价';
if (title) title.textContent = label;
content.setAttribute('aria-label', label);
document.title = documentTitle.replace(initialTitle, label);
if (push) history.pushState({}, '', savedUrls[key]);
window.dispatchEvent(new Event('resize'));
}
links.forEach(link => link.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const key = keyFor(link);
if (!key) return;
event.preventDefault();
if (key !== active) select(key);
}));
window.addEventListener('popstate', () => {
const key = Object.keys(urls).find(key => new URL(urls[key]).pathname === location.pathname) || 'quotes';
select(key, false);
savedUrls[key] = location.href;
});
})();
</script>

View File

@ -83,7 +83,8 @@
}
// Preserve each page's own scripts, forms and routes in its document.
const style = doc.createElement('style');
style.textContent = '.js-menu,body>.container,#header [data-page-title-first]{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}';
// 子页面在 iframe 里不再显示外层那一整排菜单和标签栏,只保留页面本身。
style.textContent = '.js-menu,body>.container,#header [data-page-title-first],.open-tabs{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}';
doc.head.appendChild(style);
frame.style.visibility = 'visible';
delete frame.dataset.failed;

View File

@ -0,0 +1,66 @@
{{-- 打款信息(完整显示):财务系统(入驻对公/对私、新增/变更)在前,工作平台账户在后。 --}}
@php
$showCourier = $showCourier ?? false;
$rowActions = $rowActions ?? false;
$colspan = ($showCourier ? 12 : 10) + ($rowActions ? 1 : 0);
$changeButton = static fn (?string $code, ?string $type, ?string $name, ?string $number): string => filled($code) && filled($number)
? '<button type="button" class="courier-information-link" style="border:0;background:none;padding:0;cursor:pointer" data-account-change-from data-code="' . e($code) . '" data-type="' . e(in_array($type, ['对公', '对私'], true) ? $type : '') . '" data-name="' . e($name) . '" data-number="' . e($number) . '">变更</button>'
: '<span class="courier-overview-sub" title="' . (filled($code) ? '没有账号' : '这家快递还没有快递公司代码,先到入驻详情「ERP 开通」里补上') . '">-</span>';
@endphp
<table class="courier-information-table" style="min-width: {{ $showCourier ? 1700 : 1460 }}px" aria-label="打款信息">
<thead>
<tr>
<th>来源</th>
@if ($showCourier)<th>快递公司代码</th><th>快递公司名称</th>@endif
<th>账户类型</th><th>开户名称</th><th>开户银行</th><th>银行账号</th><th>生效日期</th><th>原账户(变更前)</th><th>状态</th><th>ERP账户ID</th><th>创建时间</th>@if ($rowActions)<th>操作</th>@endif
</tr>
</thead>
<tbody>
@foreach ($financeAccounts as $account)
<tr class="is-finance-row">
<td>
@if (! empty($account['onboarding']))
<a href="{{ route('business.courier-onboardings.show', $account['onboarding']) }}">{{ $account['source'] }}</a>
@else
{{ $account['source'] }}
@endif
</td>
@if ($showCourier)<td class="is-code">{{ $account['expcode'] ?: '-' }}</td><td>{{ $account['courier_name'] ?: '-' }}</td>@endif
<td>{{ $account['type'] }}</td>
<td>{{ $account['name'] ?: '-' }}</td>
<td>{{ $account['bank'] ?: '-' }}</td>
<td class="is-code">{{ $account['number'] ?: '-' }}</td>
<td class="is-code">{{ $account['effective_date'] ?: '-' }}</td>
<td>{{ $account['old'] ?: '-' }}</td>
<td>
<span class="courier-overview-tag {{ $account['tone'] }}">{{ $account['status'] }}</span>
@if (($account['status'] ?? '') === '待推送协议' && ! empty($account['record']))
<a class="courier-information-link" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $account['record']->id]) }}">去推送</a>
@endif
</td>
<td class="is-code">{{ $account['erp_account_id'] ?: '-' }}</td>
<td class="is-code">{{ $account['created_at'] ? substr($account['created_at'], 0, 16) : '-' }}</td>
@if ($rowActions)<td>{!! $changeButton($account['expcode'] ?? null, $account['type'] ?? null, $account['name'] ?? null, $account['number'] ?? null) !!}</td>@endif
</tr>
@endforeach
@foreach ($platformAccounts as $row)
<tr>
<td>工作平台{{ filled($row->invoice ?? null) ? ' · 发票 ' . $row->invoice : '' }}</td>
@if ($showCourier)<td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->expname ?? '-' }}</td>@endif
<td>{{ $row->accountType ?? '-' }}</td>
<td>{{ $row->accountName ?? '-' }}</td>
<td>{{ $row->accountkhh ?? '-' }}</td>
<td class="is-code">{{ $row->account ?? '-' }}</td>
<td>-</td>
<td>-</td>
<td><span class="courier-overview-tag is-muted">工作平台登记</span></td>
<td class="is-code">{{ $row->erpAccountId ?? '-' }}</td>
<td class="is-code">{{ filled($row->createTime ?? null) ? substr((string) $row->createTime, 0, 16) : '-' }}</td>
@if ($rowActions)<td>{!! $changeButton($row->expcode ?? null, $row->accountType ?? null, $row->accountName ?? null, $row->account ?? null) !!}</td>@endif
</tr>
@endforeach
@if (count($financeAccounts) === 0 && count($platformAccounts) === 0)
<tr><td colspan="{{ $colspan }}" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endif
</tbody>
</table>

View File

@ -0,0 +1,101 @@
{{-- 点开快递资料主信息后的明细:网点 / 打款信息 / 操作日志,和编辑、打款信息新增/变更。 --}}
@php
$row = $detail['row'];
$onb = $detail['onboarding'] ?? null;
$code = $detail['code'];
$formFailed = (string) old('form', '');
$editing = ($detail['edit'] ?? false) || $formFailed === 'edit';
$activeTab = in_array($formFailed, ['add', 'change'], true) ? 'accounts' : ($detail['detail_tab'] ?? 'accounts');
$value = static function (string $field) use ($row): string {
$current = old($field, $row->{$field} ?? '');
if (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true)) {
return $current ? substr((string) $current, 0, 10) : '';
}
if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true)) {
return in_array(strtolower((string) $current), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
}
return (string) $current;
};
$oldAccounts = collect($detail['finance_accounts'] ?? [])
->filter(static fn (array $account): bool => filled($account['number'] ?? null))
->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']])
->merge(collect($detail['platform_accounts'] ?? [])->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台']))
->unique('number')
->values();
$today = now()->toDateString();
@endphp
<section class="courier-detail" id="courier-detail" aria-label="快递明细">
@if ($row === null)
<div class="courier-information-notice" role="status"><span class="material-icons-outlined" aria-hidden="true">info</span><span>没有找到快递公司代码为「{{ $code }}」的快递资料。</span></div>
@else
<header class="courier-detail__head">
<div>
<h3 class="courier-detail__title">{{ $row->expname ?: '-' }} <span class="courier-overview-sub is-code">{{ $code }}</span></h3>
<p class="courier-overview-sub">
{{ ! empty($row->_onboarding_only) ? '财务系统入驻' : '工作平台 + 财务系统' }}
@if ($onb) · 入驻 <a href="{{ route('business.courier-onboardings.show', $onb) }}">{{ $onb->number }}</a>({{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '未填主体' }})@endif
</p>
</div>
<div class="courier-detail__actions">
<a class="courier-information-button" href="{{ route('business.pages.show', array_merge(request()->except(['selected', 'edit', 'detail_tab']), ['section' => 'courier-payables', 'page' => 'courier-information'])) }}"><span class="material-icons-outlined" aria-hidden="true">close</span>收起</a>
</div>
</header>
@if ($errors->any() && in_array($formFailed, ['edit', 'add', 'change'], true))
<div class="courier-information-notice is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $errors->first() }}</span></div>
@endif
@if ($editing)
<form method="POST" action="{{ route('business.courier-profiles.update', $code) }}" class="courier-detail__form" aria-label="编辑快递资料">
@csrf
<input type="hidden" name="form" value="edit">
<h4>编辑快递资料 <span class="courier-overview-sub">财务系统维护,不回写工作平台;改动记到操作日志</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@foreach (\App\Models\Business\CourierProfile::EDITABLE as $field => $label)
<div class="courier-information-field">
<label for="profile-{{ $field }}">{{ $label }}{{ $field === 'expname' ? ' *' : '' }}</label>
@if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true))
<select id="profile-{{ $field }}" name="{{ $field }}"><option value="1" @selected($value($field) === '1')>是</option><option value="0" @selected($value($field) === '0')>否</option></select>
@elseif (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true))
<input id="profile-{{ $field }}" name="{{ $field }}" type="date" value="{{ $value($field) }}">
@else
<input id="profile-{{ $field }}" name="{{ $field }}" type="text" value="{{ $value($field) }}" maxlength="{{ $field === 'expname' ? 100 : 255 }}" @if ($field === 'expname') required @endif>
@endif
</div>
@endforeach
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">save</span>保存</button>
<a class="courier-information-button" href="{{ route('business.pages.show', array_merge(request()->except(['edit']), ['section' => 'courier-payables', 'page' => 'courier-information'])) }}#courier-detail">取消</a>
</div>
</form>
@endif
<div class="courier-detail__tabs" role="tablist" aria-label="快递明细分类">
@foreach (['networks' => ['网点', count($detail['networks'])], 'accounts' => ['打款信息', count($detail['finance_accounts']) + count($detail['platform_accounts'])], 'logs' => ['操作日志', count($detail['events']) + count($detail['platform_logs'])]] as $key => [$label, $count])
<button type="button" role="tab" class="courier-detail__tab {{ $activeTab === $key ? 'is-active' : '' }}" aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}" data-detail-tab="{{ $key }}">{{ $label }} <span class="courier-overview-sub">{{ $count }}</span></button>
@endforeach
</div>
<div data-detail-panel="networks" @if ($activeTab !== 'networks') hidden @endif>
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-network-table', ['networkRows' => $detail['networks'], 'emptyText' => '工作平台没有这家快递的网点(或网点镜像未就绪)。'])
</div></div>
</div>
<div data-detail-panel="accounts" @if ($activeTab !== 'accounts') hidden @endif>
@include('business.pages.courier-payables.partials.courier-payment-account-forms', ['code' => $code, 'oldAccounts' => $oldAccounts])
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-account-table', ['financeAccounts' => $detail['finance_accounts'], 'platformAccounts' => $detail['platform_accounts'], 'showCourier' => false, 'emptyText' => '还没有这家快递的打款信息,可点「打款信息新增」。'])
</div></div>
</div>
<div data-detail-panel="logs" @if ($activeTab !== 'logs') hidden @endif>
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $detail['events'], 'platformLogs' => $detail['platform_logs'], 'showCourier' => false, 'emptyText' => '还没有这家快递的操作日志。'])
</div></div>
</div>
@endif
</section>

View File

@ -0,0 +1,35 @@
{{-- 操作日志:财务系统(编辑、打款信息新增/变更)在前,工作平台日志在后。 --}}
@php
$showCourier = $showCourier ?? false;
@endphp
<table class="courier-information-table" style="min-width: 1100px" aria-label="快递资料操作日志">
<thead><tr><th>来源</th>@if ($showCourier)<th>快递公司代码</th>@endif<th>操作</th><th>内容</th><th>操作人</th><th>操作时间</th></tr></thead>
<tbody>
@foreach ($financeEvents as $event)
@php
$details = collect((array) $event->changes)->map(static fn ($change): string => ($change['label'] ?? '') . ':' . ($change['old'] ?? '空') . ' → ' . ($change['new'] ?? '空'))->implode(';');
@endphp
<tr class="is-finance-row">
<td>财务系统</td>
@if ($showCourier)<td class="is-code">{{ $event->expcode }}</td>@endif
<td>{{ $event->actionLabel() }}</td>
<td class="is-clamped" style="max-width: 520px" title="{{ $details ?: $event->summary }}">{{ $event->summary }}@if ($details)<div class="courier-overview-sub">{{ $details }}</div>@endif</td>
<td>{{ $event->creator?->name ?? '-' }}</td>
<td class="is-code">{{ $event->created_at?->format('Y-m-d H:i') ?? '-' }}</td>
</tr>
@endforeach
@foreach ($platformLogs as $row)
<tr>
<td>工作平台</td>
@if ($showCourier)<td class="is-code">{{ $row->expcode ?? '-' }}</td>@endif
<td>-</td>
<td>{{ $row->doTime ?? '-' }}</td>
<td>-</td>
<td class="is-code">{{ filled($row->modifyTime ?? $row->createTime ?? null) ? substr((string) ($row->modifyTime ?? $row->createTime), 0, 16) : '-' }}</td>
</tr>
@endforeach
@if (count($financeEvents) === 0 && count($platformLogs) === 0)
<tr><td colspan="{{ $showCourier ? 6 : 5 }}" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endif
</tbody>
</table>

View File

@ -0,0 +1,14 @@
{{-- 快递网点表(工作平台 wp_express_expNet)。 --}}
@php
$yes = static fn ($value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
@endphp
<table class="courier-information-table" style="min-width: 1560px" aria-label="快递网点">
<thead><tr><th>ID</th><th>快递公司代码</th><th>网点代码</th><th>网点名称</th><th>网点号</th><th>省</th><th>市</th><th>区</th><th>乡镇</th><th>地址</th><th>联系人</th><th>联系电话</th><th>是否启用</th><th>接口平台</th><th>创建时间</th><th>剩余单号</th></tr></thead>
<tbody>
@forelse ($networkRows as $row)
<tr><td class="is-code">{{ $row->id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td class="is-code">{{ $row->netcode ?? '-' }}</td><td>{{ $row->netname ?? '-' }}</td><td>{{ $row->netno ?? '-' }}</td><td>{{ $row->province ?? '-' }}</td><td>{{ $row->city ?? '-' }}</td><td>{{ $row->area ?? '-' }}</td><td>{{ $row->town ?? '-' }}</td><td class="is-clamped" title="{{ $row->address ?? '' }}">{{ $row->address ?? '-' }}</td><td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkermobile ?? '-' }}</td><td>{{ $yes($row->status ?? null) }}</td><td>{{ $row->apipltmname ?? ($row->apitype ?? '-') }}</td><td class="is-code">{{ filled($row->created ?? null) ? substr((string) $row->created, 0, 16) : '-' }}</td><td>{{ $row->expnum ?? '-' }}</td></tr>
@empty
<tr><td colspan="16" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endforelse
</tbody>
</table>

View File

@ -0,0 +1,22 @@
{{-- 快递资料表格右侧:财务系统入驻信息($info 为空则留空)。对公/对私收款信息在下方「打款信息」里完整显示。 --}}
@php
$onb = $info['onboarding'] ?? null;
@endphp
@if ($onb)
<td class="is-onboarding is-code"><a href="{{ route('business.courier-onboardings.show', $onb) }}">{{ $onb->number }}</a></td>
<td class="is-onboarding">{{ $onb->statusLabel() }}</td>
<td class="is-onboarding">
@if (($info['erp_status'] ?? '') === 'pending')
<span class="courier-overview-tag is-warn" title="等 ERP 专员在钉钉发起「新建快递」流程,完结后自动补齐">待 ERP 开通</span>
@elseif (($info['erp_status'] ?? '') === 'opened')
<span class="courier-overview-tag is-ok">已开通</span>
@else
<span class="courier-overview-sub">-</span>
@endif
</td>
<td class="is-onboarding is-clamped" title="{{ $onb->company_full_name }}">{{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '-' }}</td>
<td class="is-onboarding">@if ($info['missing_files'] ?? [])<span class="courier-overview-tag is-warn" title="{{ implode('、', $info['missing_files']) }}">缺 {{ count($info['missing_files']) }} 项</span>@else<span class="courier-overview-tag is-ok">齐全</span>@endif</td>
<td class="is-onboarding is-code">{{ $info['effective_version'] ?? '-' }}</td>
@else
<td class="is-onboarding" colspan="6"><span class="courier-overview-sub">未入驻</span>@if (! empty($platformName)) · <a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding', 'courier_name' => $platformName, 'expcode' => $platformCode ?? null]) }}#invite" title="给这家快递发入驻二维码,补齐主体、账户、证照">发码补资料</a>@endif</td>
@endif

View File

@ -0,0 +1,72 @@
{{-- 打款信息新增 / 变更:按钮 + 两张表单。$code 为空时(打款信息页签)表单里自己填快递公司代码。 --}}
@php
$code = $code ?? null;
$oldAccounts = collect($oldAccounts ?? []);
$prefillCode = $prefillCode ?? '';
$formFailed = (string) old('form', '');
$today = now()->toDateString();
$formAction = $code !== null
? route('business.courier-profiles.payment-accounts.store', $code)
: route('business.courier-profiles.payment-accounts.store-any');
@endphp
<div class="courier-detail__toolbar">
<button type="button" class="courier-information-button is-primary" data-account-form-open="add"><span class="material-icons-outlined" aria-hidden="true">add_card</span>打款信息新增</button>
<button type="button" class="courier-information-button" data-account-form-open="change"><span class="material-icons-outlined" aria-hidden="true">swap_horiz</span>打款信息变更</button>
<span class="courier-overview-sub">{{ $code === null ? '先填快递公司代码;也可以点表格里某个账户的「变更」直接带出原账户。' : '' }}保存后自动跳到协议签署,推送「收款账户证明 / 收款账户变更」给快递签署,签完即生效。</span>
</div>
<form method="POST" action="{{ $formAction }}" class="courier-detail__form" data-account-form="add" @if ($formFailed !== 'add') hidden @endif aria-label="打款信息新增">
@csrf
<input type="hidden" name="form" value="add">
<input type="hidden" name="action" value="add">
<h4>打款信息新增 <span class="courier-overview-sub">→ 收款账户证明</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@if ($code === null)
<div class="courier-information-field"><label for="add-expcode">快递公司代码 *</label><input id="add-expcode" name="expcode" type="text" maxlength="64" required placeholder="如:SQZTKY" value="{{ $formFailed === 'add' ? old('expcode') : $prefillCode }}" data-account-expcode></div>
@endif
<div class="courier-information-field"><label for="add-type">账户类型 *</label><select id="add-type" name="account_type" required>@foreach (\App\Models\Business\CourierPaymentAccount::ACCOUNT_TYPES as $type)<option value="{{ $type }}" @selected(old('form') === 'add' && old('account_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="courier-information-field"><label for="add-name">开户名称(持卡人) *</label><input id="add-name" name="account_name" type="text" maxlength="255" required value="{{ $formFailed === 'add' ? old('account_name') : '' }}"></div>
<div class="courier-information-field"><label for="add-bank">开户银行 *</label><input id="add-bank" name="bank_name" type="text" maxlength="255" required placeholder="如:工商银行宿迁分行" value="{{ $formFailed === 'add' ? old('bank_name') : '' }}"></div>
<div class="courier-information-field"><label for="add-number">银行账号 *</label><input id="add-number" name="account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'add' ? old('account_number') : '' }}"></div>
<div class="courier-information-field"><label for="add-date">生效日期 *</label><input id="add-date" name="effective_date" type="date" required value="{{ $formFailed === 'add' ? old('effective_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="add-note">备注</label><input id="add-note" name="note" type="text" maxlength="500" value="{{ $formFailed === 'add' ? old('note') : '' }}"></div>
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">send</span>保存并去推送协议</button>
<button type="button" class="courier-information-button" data-account-form-close>取消</button>
</div>
</form>
<form method="POST" action="{{ $formAction }}" class="courier-detail__form" data-account-form="change" @if ($formFailed !== 'change') hidden @endif aria-label="打款信息变更">
@csrf
<input type="hidden" name="form" value="change">
<input type="hidden" name="action" value="change">
<h4>打款信息变更 <span class="courier-overview-sub">→ 收款账户变更</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@if ($code === null)
<div class="courier-information-field"><label for="change-expcode">快递公司代码 *</label><input id="change-expcode" name="expcode" type="text" maxlength="64" required placeholder="如:SQZTKY" value="{{ $formFailed === 'change' ? old('expcode') : $prefillCode }}" data-account-expcode></div>
@endif
<div class="courier-information-field"><label for="change-pick">选择原账户</label>
<select id="change-pick" data-old-account-select>
<option value="">{{ $oldAccounts->isEmpty() ? '没有登记的账户,手工填写' : '请选择要停用的账户' }}</option>
@foreach ($oldAccounts as $index => $account)
<option value="{{ $index + 1 }}" data-name="{{ $account['name'] }}" data-number="{{ $account['number'] }}" data-type="{{ $account['type'] }}">{{ $account['type'] ? $account['type'] . ' · ' : '' }}{{ $account['name'] }} {{ $account['number'] }}({{ $account['source'] }})</option>
@endforeach
</select>
</div>
<div class="courier-information-field"><label for="change-old-name">原开户名称 *</label><input id="change-old-name" name="old_account_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('old_account_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-old-number">原账号 *</label><input id="change-old-number" name="old_account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'change' ? old('old_account_number') : '' }}"></div>
<div class="courier-information-field"><label for="change-old-stop">原账户停止使用日期 *</label><input id="change-old-stop" name="old_stop_date" type="date" required value="{{ $formFailed === 'change' ? old('old_stop_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-type">新账户类型 *</label><select id="change-type" name="account_type" required>@foreach (\App\Models\Business\CourierPaymentAccount::ACCOUNT_TYPES as $type)<option value="{{ $type }}" @selected(old('form') === 'change' && old('account_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="courier-information-field"><label for="change-name">新开户名称 *</label><input id="change-name" name="account_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('account_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-bank">新开户银行 *</label><input id="change-bank" name="bank_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('bank_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-number">新银行账号 *</label><input id="change-number" name="account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'change' ? old('account_number') : '' }}"></div>
<div class="courier-information-field"><label for="change-effective">新账户启用日期 *</label><input id="change-effective" name="effective_date" type="date" required value="{{ $formFailed === 'change' ? old('effective_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-date">账户变更日期 *</label><input id="change-date" name="change_date" type="date" required value="{{ $formFailed === 'change' ? old('change_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-note">备注</label><input id="change-note" name="note" type="text" maxlength="500" value="{{ $formFailed === 'change' ? old('note') : '' }}"></div>
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">send</span>保存并去推送协议</button>
<button type="button" class="courier-information-button" data-account-form-close>取消</button>
</div>
</form>

View File

@ -15,7 +15,7 @@
<section class="warehouse-quote-settle" aria-labelledby="warehouse-settle-title">
<div class="warehouse-quote-form__head">
<div>
<h2 class="warehouse-quote-section-title" id="warehouse-settle-title">仓储运费</h2>
<h2 class="warehouse-quote-section-title" id="warehouse-settle-title">仓储费</h2>
<div class="warehouse-quote-section-note">选择结算月份,一键按当期生效报价核算并存档,可查看和导出每日商品仓储费</div>
</div>
</div>

View File

@ -4,9 +4,6 @@
$status = (string) ($dingPayableWorkflowStatus ?? 'unknown');
$syncedAt = $dingPayableWorkflowSyncedAt ?? null;
$error = $dingPayableWorkflowError ?? null;
$json = static fn (mixed $value): string => is_array($value)
? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-')
: (string) ($value ?? '-');
$statusLabel = static fn (array $workflow): string =>
\Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::label($workflow);
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions();
@ -19,30 +16,41 @@
<div class="ding-payable-page">
<style>
.ding-payable-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
.ding-payable-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.75rem; padding:1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.ding-payable-field { display:flex; flex-direction:column; gap:.35rem; min-width:16rem; }
.ding-payable-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
.ding-payable-field input,.ding-payable-field select { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
.ding-payable-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.45rem .75rem; padding:.55rem .9rem; margin-bottom:.6rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.ding-payable-field { display:flex; flex-direction:column; gap:.2rem; min-width:15rem; }
.ding-payable-field label { color:var(--vip-content-muted,#595959); font-size:.68rem; font-weight:600; }
.ding-payable-field input,.ding-payable-field select { height:1.9rem; padding:0 .5rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink,#171717); }
.ding-payable-field input:focus,.ding-payable-field select:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.ding-payable-date-range { display:flex; align-items:center; gap:.5rem; }
.ding-payable-date-range input { min-width:9rem; }
.ding-payable-date-range span { color:var(--vip-content-muted,#595959); }
.ding-payable-actions { display:flex; gap:.5rem; }
.ding-payable-button { display:inline-flex; align-items:center; gap:.35rem; height:2.25rem; padding:0 .85rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.ding-payable-button { display:inline-flex; align-items:center; gap:.3rem; height:1.9rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.ding-payable-button .material-icons-outlined { font-size:.95rem; }
.ding-payable-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.ding-payable-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.ding-payable-meta { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.ding-payable-meta { display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-bottom:.5rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
/* 表格区填满整块区域:提高高度上限,并回收本页外层容器的底部留白。 */
.my-6:has(.ding-payable-page) { margin-bottom: .4rem; }
.business-content-section:has(.ding-payable-page) { padding-bottom: .4rem; }
.container:has(.ding-payable-page) { margin-bottom: 0; padding-bottom: 0; }
.ding-payable-page .ding-payable-table-wrap { max-height:min(calc(100vh - 12rem), 46rem); }
.ding-payable-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
.ding-payable-table { width:100%; min-width:125rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.ding-payable-table th,.ding-payable-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; }
.ding-payable-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
.ding-payable-table tr:hover td { background:#fcfaf4; }
.ding-payable-table tbody tr.is-clickable { cursor:pointer; }
.ding-payable-table tbody tr.is-active td { background:var(--vip-content-accent-soft,#F7F0DE); border-bottom-color:#e4d7ba; }
.ding-payable-table tbody tr.is-active td:first-child { box-shadow:inset 3px 0 0 var(--vip-content-accent-dark,#856632); }
.ding-payable-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.ding-payable-detail { max-width:26rem; white-space:normal; }
.ding-payable-attachment { color:var(--vip-content-accent-dark,#856632); }
.ding-payable-attachment-size { margin-left:.35rem; color:var(--vip-content-muted,#595959); font-size:.68rem; }
.ding-payable-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); }
.ding-payable-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; }
.ding-payable-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.ding-payable-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.ding-payable-pagination { display:flex; justify-content:space-between; padding:.5rem 1rem; color:var(--vip-content-muted,#595959); }
.ding-payable-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
</style>
@ -90,7 +98,7 @@
<thead><tr>
<th>钉钉审批编号</th><th>标题</th><th>状态</th><th>申请人</th><th>申请部门</th><th>创建时间</th><th>完成时间</th>
<th>类型</th><th>用途</th><th class="num">付款金额</th><th>货款月份</th><th>快递</th><th>支付方式</th><th>收款人全称</th><th>开户银行</th><th>银行账号</th>
<th>全部表单字段</th><th>付款明细</th><th>附件</th><th>审批任务</th>
<th>附件</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
@ -99,7 +107,7 @@
@php($fields = $row['fields'] ?? [])
@php($approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '-'))
@php($originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '-')))
<tr>
<tr class="is-clickable" data-ding-payable-row>
<td>{{ $approvalNumber }}</td>
<td>{{ $workflow['title'] ?? '-' }}</td>
<td>{{ $statusLabel($workflow) }}</td>
@ -116,13 +124,22 @@
<td>{{ $value($detail, '收款人全称') }}</td>
<td>{{ $value($detail, '开户银行') }}</td>
<td>{{ $value($detail, '银行账号') }}</td>
<td class="ding-payable-detail"><details><summary>展开字段({{ count($fields) }} 项)</summary><pre>{{ $json($fields) }}</pre></details></td>
<td class="ding-payable-detail"><details><summary>展开付款明细</summary><pre>{{ $json($fields['付款明细'] ?? []) }}</pre></details></td>
<td class="ding-payable-detail"><details><summary>展开附件</summary><pre>{{ $json($fields['附件'] ?? ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}</pre></details></td>
<td class="ding-payable-detail"><details><summary>展开审批任务</summary><pre>{{ $json($row['tasks'] ?? []) }}</pre></details></td>
@php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : []))
<td class="ding-payable-detail">
@forelse ($attachmentFiles as $attachmentFile)
@if (is_array($attachmentFile) && ! empty($attachmentFile['fileId']))
<div>
<a class="ding-payable-attachment" href="{{ route('business.courier-payables.ding-payable-attachment.download', ['process_instance_id' => $row['process_instance_id'] ?? '', 'file_id' => $attachmentFile['fileId']]) }}" title="下载附件">{{ $attachmentFile['fileName'] ?? ('附件 ' . $attachmentFile['fileId']) }}</a>
@if (! empty($attachmentFile['fileSize']))<span class="ding-payable-attachment-size">{{ number_format(((int) $attachmentFile['fileSize']) / 1048576, 2) }} MB</span>@endif
</div>
@endif
@empty
<span>-</span>
@endforelse
</td>
</tr>
@empty
<tr><td colspan="20"><div class="ding-payable-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}</div></td></tr>
<tr><td colspan="17"><div class="ding-payable-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}</div></td></tr>
@endforelse
</tbody>
</table>
@ -135,3 +152,31 @@
</span>
</div>
</div>
<script>
(() => {
const table = document.querySelector('.ding-payable-table');
if (!table) {
return;
}
const activate = (row) => {
table.querySelectorAll('tbody tr.is-active').forEach((item) => item.classList.remove('is-active'));
row.classList.add('is-active');
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element) || event.target.closest('a, button, input, label, select')) {
return;
}
const row = event.target.closest('tr[data-ding-payable-row]');
if (row && table.contains(row)) {
event.preventDefault();
activate(row);
}
});
})();
</script>

View File

@ -62,14 +62,65 @@
<div class="quote-review__toolbar"><a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a><div class="quote-review__actions">@if($change->isEditable() && $change->created_by===user_id())<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a><form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>@endif @if($canReview && $change->status==='pending_review')<form method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>@endif</div></div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
@if($reviewWarnings)<div class="quote-review__alert" role="alert" style="border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">report</span><div><strong>审核提醒(请重点核对)</strong>@foreach($reviewWarnings as $warning)<br>· {{ $warning }}@endforeach</div></div>@endif
@if($change->isRetrospective())<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><div><strong>追溯变价 · {{ $change->recalculationStatusLabel() }}</strong><br>自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}</div></div>@endif
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>生效日期</dt><dd>{{ $change->effective_date->toDateString() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $change->items->count() }} 项</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>执行期间</dt><dd>{{ $change->periodLabel() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项' }}</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
@if($requoteDiff)
@php($fmt = static fn ($v) => $v === null ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'))
<section class="quote-review__panel" aria-labelledby="review-diff-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧报价对比 · {{ $change->change_type }}</span></div></header>
<div class="quote-review__body">
@if($requoteDiff['same_structure'])
<p style="margin:0 0 10px">计费方式不变:{{ $requoteDiff['new_mode'] }}。共 <strong>{{ count($requoteDiff['cells']) }}</strong> 处价格变动,未列出的价格不变。</p>
@else
<div class="quote-review__alert" role="status" style="margin:0 0 12px"><span class="material-icons-outlined" aria-hidden="true">swap_horiz</span><div><strong>计费方式调整</strong><br>原:{{ $requoteDiff['old_mode'] }}({{ implode(' / ', $requoteDiff['old_columns']) }})<br>新:{{ $requoteDiff['new_mode'] }}({{ implode(' / ', $requoteDiff['new_columns']) }})<br>新报价全表见下方「新报价」。</div></div>
@endif
@if($requoteDiff['added'] || $requoteDiff['removed'])
<p style="margin:0 0 10px">@if($requoteDiff['added'])新增省份:{{ implode('、', $requoteDiff['added']) }}。@endif @if($requoteDiff['removed'])不再报价:{{ implode('、', $requoteDiff['removed']) }}。@endif</p>
@endif
@foreach($requoteDiff['rules'] as $rule)
<p style="margin:0 0 6px">规则:{{ $rule['old'] }} → <strong>{{ $rule['new'] }}</strong></p>
@endforeach
</div>
@if($requoteDiff['same_structure'] && $requoteDiff['cells'])
<div class="quote-review__table-wrap"><table><thead><tr><th>省份</th><th>计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th></tr></thead><tbody>
@foreach($requoteDiff['cells'] as $cell)
@php($delta = (float) ($cell['new'] ?? 0) - (float) ($cell['old'] ?? 0))
<tr><td><strong>{{ $cell['region'] }}</strong></td><td>{{ $cell['label'] }}</td><td class="quote-review__number">{{ $fmt($cell['old']) }}</td><td class="quote-review__number"><strong>{{ $fmt($cell['new']) }}</strong></td><td class="quote-review__number quote-review__delta" style="color:{{ $delta > 0 ? '#A1281F' : '#2F6B2A' }}">{{ $delta > 0 ? '+' : '' }}{{ $fmt($delta) }}</td></tr>
@endforeach
</tbody></table></div>
@endif
</section>
@php($proposed = json_decode((string) $change->proposed_values_json, true) ?: [])
@php($proposedTable = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable($proposed))
@if($proposedTable)
<section class="quote-review__panel" aria-labelledby="review-new-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-new-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span><span>新报价({{ $change->end_date ? $change->effective_date->toDateString() . ' 至 ' . $change->end_date->toDateString() . ' 临时执行,到期恢复原报价' : $change->effective_date->toDateString() . ' 起执行' }})</span></div></header>
<div class="quote-review__table-wrap"><table>
<thead><tr>@foreach($proposedTable[0] as $i => $head)<th @class(['quote-review__number' => $i > 0])>{{ $head }}</th>@endforeach</tr></thead>
<tbody>@foreach(array_slice($proposedTable, 1) as $row)<tr>@foreach($row as $i => $value)<td @class(['quote-review__number' => $i > 0])>{{ $i === 0 ? $value : $fmt($value) }}</td>@endforeach</tr>@endforeach</tbody>
</table></div>
</section>
@endif
@else
<section class="quote-review__panel" aria-labelledby="review-diff-title"><header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧价格差异</span></div></header><div class="quote-review__table-wrap"><table><thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th><th>备注</th></tr></thead><tbody>@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)<tr><td><strong>{{ $item->source_cell }}</strong></td><td>{{ $item->region ?: '—' }}</td><td>{{ $item->weight_band ?: ($item->charge_item ?: '—') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->original_amount,'0'),'.') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->new_amount,'0'),'.') }}</td><td class="quote-review__number quote-review__delta">{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}</td><td>{{ $item->note ?: '—' }}</td></tr>@endforeach</tbody></table></div></section>
@endif
@if($change->version)
@php($quoteSignature=$change->version->signature)
<section class="quote-review__panel" aria-labelledby="review-signature-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-signature-title"><span class="material-icons-outlined" aria-hidden="true">draw</span><span>E 签宝签署与归档</span></div></header>
<div class="quote-review__body">
@if($change->version->isVoided())
<div class="quote-review__alert" role="status" style="margin:0 0 14px;border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">block</span><div><strong>{{ $change->version->version_code }} 已作废</strong><br>原因:{{ $change->version->void_reason }}<br>{{ $change->version->voided_at?->format('Y-m-d H:i') }}{{ $voidedBy ? ' · ' . $voidedBy : '' }}</div></div>
@endif
@if($onboarding && $canManageSignature && !$change->version->isVoided() && (!$quoteSignature || $quoteSignature->canSend()))
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-changes.push-courier',$change) }}" style="margin-bottom:14px">@csrf
<div class="quote-review__form-actions">
<span class="quote-review__hint">已入驻快递,按入驻资料签署:{{ $onboarding->isPerson() ? '个人 ' : $onboarding->company_full_name . ' · ' }}{{ $onboarding->esign_operator_name }} {{ $onboarding->esign_account }}</span>
<button class="quote-review__button quote-review__button--primary" type="submit" @disabled(!$esignConfigured)><span class="material-icons-outlined" aria-hidden="true">send</span><span>{{ $quoteSignature && $quoteSignature->esign_sign_flow_id ? '重新推送到快递 e签宝' : '推送到快递 e签宝' }}</span></button>
</div>
</form>
@endif
@if(!$quoteSignature)
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
@if($canManageSignature)<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}">@csrf<div class="quote-review__form-grid"><div class="quote-review__field"><label for="prepare_signature_date">签署日期</label><input class="quote-review__control" id="prepare_signature_date" name="signature_date" type="date" value="{{ old('signature_date',now()->toDateString()) }}" required></div></div><div class="quote-review__form-actions"><span class="quote-review__hint">日期将直接写入待签原文。</span><button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></div></form>@endif
@ -91,7 +142,7 @@
@if($quoteSignature->certificate_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'certificate']) }}"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span><span>完成证书</span></a>@endif
@if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')<form method="POST" action="{{ route('business.courier-quote-signatures.synchronize',$quoteSignature) }}">@csrf<button class="quote-review__button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync</span><span>同步签署状态</span></button></form>@endif
</div>
@if($canManageSignature && $quoteSignature->canSend())
@if($canManageSignature && $quoteSignature->canSend() && !$onboarding && !$change->version->isVoided())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.send',$quoteSignature) }}">@csrf
<div class="quote-review__form-grid">
<div class="quote-review__field"><label for="organization_name">签署企业全称</label><input class="quote-review__control" id="organization_name" name="organization_name" value="{{ old('organization_name',$quoteSignature->organization_name) }}" maxlength="255" required></div>
@ -108,6 +159,12 @@
</form>
@endif
@endif
@if($canManageSignature && !$change->version->isVoided())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-changes.void',$change) }}" style="margin-top:16px;padding-top:14px;border-top:1px dashed #E6DFD0" onsubmit="return confirm('确认作废 {{ $change->version->version_code }}?{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署,作废后需另发一版正确报价让快递重新签。' : '会同时撤回快递 e签宝 里的待签任务。' }}')">@csrf
<div class="quote-review__field"><label for="void_reason">发错了?作废此版本(填写原因)</label><input class="quote-review__control" id="void_reason" name="void_reason" maxlength="500" placeholder="如:江苏面单多打了一位;执行日期填错" value="{{ old('void_reason') }}" required></div>
<div class="quote-review__form-actions"><span class="quote-review__hint">{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署:作废只在我方停用此版本,回签单保留,之后请发起一版正确的变价让快递重新签。' : '未签完:作废会撤回快递 e签宝 里的待签任务,此版本不再生效。' }}</span><button class="quote-review__button" type="submit" style="color:#A1281F;border-color:#E6B7B7"><span class="material-icons-outlined" aria-hidden="true">block</span><span>作废此版本</span></button></div>
</form>
@endif
</div>
</section>
@endif

View File

@ -1,4 +1,9 @@
@php
try {
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
$quoteSnapshot = app(\App\Support\QuoteManagementSnapshot::class)->load();
$quotes = $quoteSnapshot['quotes'];
$quoteCompanies = collect($quotes)
@ -360,12 +365,45 @@
color: #53793E;
}
.quote-badge--muted {
border-color: #DDD8CE;
background: #F4F2EE;
color: #8A8478;
}
.quote-badge--attention {
border-color: #D7B77D;
background: #FFF8E9;
color: #935F07;
}
.quote-signed {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem 0;
padding: 0.6rem 0.8rem;
border: 1px solid #CFE3C8;
border-radius: 0.5rem;
background: #F1F8EE;
color: #2F6B2A;
}
.quote-modal__panel > .quote-detail-table-wrap {
grid-row: 4;
}
.quote-signed[hidden],
.quote-signed [hidden] {
display: none;
}
.quote-signed__text {
margin-right: auto;
font-weight: 600;
}
.quote-action {
min-width: 3.2rem;
border: 0;
@ -452,7 +490,7 @@
.quote-modal__panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-rows: auto auto auto minmax(0, 1fr);
width: min(78rem, calc(100vw - 3rem));
height: 100%;
background: #FFFFFF;
@ -727,6 +765,12 @@
<button class="quote-icon-button" type="button" title="重置筛选" aria-label="重置筛选" data-quote-reset>
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
</button>
@if ($quoteSnapshot['available'])
<a class="quote-button" href="{{ route('business.courier-quote-signatures.export') }}" title="把当前筛选结果里已签署的回签单 PDF 和签署完成证书打包下载" data-quote-export>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span data-quote-export-label>导出回签单</span>
</a>
@endif
<a class="quote-button" href="{{ route('business.courier-quote-changes.index') }}" title="查看报价变更草稿、审核状态和新版本">
<span class="material-icons-outlined" aria-hidden="true">history</span>
<span>报价历史</span>
@ -818,6 +862,13 @@
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
</dl>
<div class="quote-signed" data-quote-signed hidden>
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
<span class="quote-signed__text" data-quote-signed-text>已签署回传</span>
<a class="quote-button" data-quote-signed-link="signed"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>回签单 PDF</span></a>
<a class="quote-button" data-quote-signed-link="certificate"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>签署完成证书</span></a>
<a class="quote-button" data-quote-signed-link="export" title="导出报价表(xlsx,WPS / Excel 可打开),只含省份价格"><span class="material-icons-outlined" aria-hidden="true">table_view</span><span>导出电子档</span></a>
</div>
<div class="quote-detail-table-wrap">
<table class="quote-detail-table" data-quote-detail-table></table>
</div>
@ -843,6 +894,15 @@
var tableWrap = root.querySelector('.quote-table-wrap');
var pagination = root.querySelector('[data-quote-pagination]');
var modal = root.querySelector('[data-quote-modal]');
var exportBase = @json(route('business.courier-quote-signatures.export'));
var exportAnchor = root.querySelector('[data-quote-export]');
if (exportAnchor) {
exportAnchor.addEventListener('click', function (event) {
if (exportAnchor.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
}
});
}
var lastFocusedElement = null;
var fields = {
keyword: root.querySelector('[data-quote-filter="keyword"]'),
@ -934,15 +994,32 @@
appendCell(row, quote.quote_type);
appendCell(row, text(quote.source_range, '-'));
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
appendCell(row, text(quote.effective_date, '-'));
appendCell(row, quote.signature_status, 'quote-table__secondary');
appendCell(row, text(quote.period_label || quote.effective_date, '-'));
var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary');
if (quote.signed_files && quote.signed_files.signed) {
signCell.appendChild(document.createElement('br'));
var signedLink = create('a', 'quote-action', '回签单');
signedLink.href = quote.signed_files.signed;
signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id);
signCell.appendChild(signedLink);
}
var statusCell = appendCell(row, '');
statusCell.appendChild(create(
'span',
'quote-badge ' + (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention'),
'quote-badge ' + (['已失效', '已作废', '已到期', '暂停中'].indexOf(quote.status) !== -1 ? 'quote-badge--muted' : (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention')),
quote.status
));
if (quote.void_reason) {
statusCell.appendChild(create('div', 'quote-table__secondary', '作废:' + quote.void_reason));
}
if (quote.superseded_by) {
statusCell.appendChild(create('div', 'quote-table__secondary', '被 ' + quote.superseded_by + ' 替代'));
}
if (quote.status_note) {
statusCell.appendChild(create('div', 'quote-table__secondary', quote.status_note));
}
var actionCell = appendCell(row, '');
var action = create('button', 'quote-action', '查看');
@ -951,10 +1028,12 @@
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
actionCell.appendChild(action);
var changeAction = create('a', 'quote-action', '变价');
changeAction.href = @json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id);
changeAction.href = quote.requote_url || (@json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id));
changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿';
changeAction.setAttribute('aria-label', '变价 ' + quote.sheet_name);
actionCell.appendChild(changeAction);
if (quote.status !== '已作废') {
actionCell.appendChild(changeAction);
}
return row;
}
@ -969,6 +1048,15 @@
});
root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本';
var exportLink = root.querySelector('[data-quote-export]');
if (exportLink) {
var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; })
.map(function (quote) { return quote.id; });
exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : '');
exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true');
exportLink.style.opacity = signedCodes.length ? '' : '0.48';
exportLink.querySelector('[data-quote-export-label]').textContent = '导出回签单' + (signedCodes.length ? '(' + signedCodes.length + ')' : '');
}
root.querySelector('[data-quote-page-label]').textContent = filteredQuotes.length
? '共 ' + filteredQuotes.length + ' 条 · 共 ' + pageCount + ' 页 · 当前第 ' + currentPage + ' 页'
: '共 0 条 · 共 0 页';
@ -1025,8 +1113,17 @@
setDetail('carrier', quote.company_name);
setDetail('type', quote.quote_type);
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.effective_date, 'Excel 未标注'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
var signedBox = root.querySelector('[data-quote-signed]');
var files = quote.signed_files || {};
signedBox.hidden = !files.signed;
root.querySelector('[data-quote-signed-text]').textContent = '已签署回传' + (quote.signed_at ? ' · ' + quote.signed_at : '');
signedBox.querySelectorAll('[data-quote-signed-link]').forEach(function (link) {
var url = files[link.getAttribute('data-quote-signed-link')];
link.hidden = !url;
if (url) { link.href = url; } else { link.removeAttribute('href'); }
});
var table = root.querySelector('[data-quote-detail-table]');
var head = create('thead');

View File

@ -66,7 +66,7 @@
color: var(--vip-content-ink, #171717);
font-family: JetBrains Mono, Microsoft YaHei, PingFang SC, monospace;
font-size: 0.8125rem;
margin: 1.5rem 0;
margin: .5rem 0 .4rem;
}
.return-pickup-status {
@ -74,7 +74,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
padding: 6px 12px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 12px;
background: #fff;
@ -101,17 +101,17 @@
.return-pickup-status__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
gap: 3px 12px;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-size: 0.7rem;
}
.return-pickup-filters {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px 14px;
margin-top: 14px;
padding: 14px;
gap: 6px 10px;
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 12px;
background: #fff;
@ -121,21 +121,21 @@
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
gap: 3px;
}
.return-pickup-field label {
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-size: 0.7rem;
}
.return-pickup-field input,
.return-pickup-field select {
min-height: 34px;
min-height: 28px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
border-radius: 6px;
background: #fff;
padding: 0 10px;
padding: 0 8px;
color: var(--vip-content-ink, #171717);
font: inherit;
outline: none;
@ -144,18 +144,18 @@
.return-pickup-actions {
display: flex;
align-items: flex-end;
gap: 8px;
gap: 6px;
}
.return-pickup-button {
display: inline-flex;
min-height: 34px;
min-height: 28px;
align-items: center;
gap: 6px;
gap: 5px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
border-radius: 6px;
background: #fff;
padding: 0 12px;
padding: 0 9px;
color: var(--vip-content-ink, #171717);
font: inherit;
cursor: pointer;
@ -210,11 +210,16 @@
}
.return-pickup-table-wrap--scroll {
max-height: max(16rem, min(56vh, 40rem));
max-height: max(14rem, min(calc(100vh - 19.5rem), 44rem));
overflow-y: auto;
scrollbar-gutter: stable;
}
/* 列表区填满底部:回收本页外层容器留白(仅本页生效)。 */
.my-6:has(.return-pickup-page) { margin-bottom: .4rem; }
.business-content-section:has(.return-pickup-page) { padding-bottom: .4rem; }
.container:has(.return-pickup-page) { margin-bottom: 0; padding-bottom: 0; }
.return-pickup-table-wrap--scroll thead th {
position: sticky;
top: 0;
@ -296,7 +301,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
padding: 5px 12px;
border-top: 1px solid var(--vip-border, #d8d2c7);
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;

View File

@ -3,20 +3,29 @@
$activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms);
$panels = $storeViolationPanels ?? [];
$filters = $storeViolationFilters ?? [];
$filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []];
$filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []];
$platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []);
$sync = $storeViolationSync ?? ['code' => 'pending', 'last_succeeded_at' => null, 'row_count' => 0, 'last_error' => null];
$error = $storeViolationError ?? null;
$preview = $storeViolationPreview ?? null;
$columns = config('store_violations.columns', []);
$handlingStatuses = config('store_violations.handling_statuses', []);
$hasFilters = collect($filters)->contains(static fn ($value): bool => is_string($value) && $value !== '');
$alertLevels = config('store_violations.alert_levels', []);
$defaultView = ($filters['view'] ?? 'default') !== 'all';
$hasFilters = ($filters['has_explicit'] ?? '') === '1';
$handlingTone = static fn (string $status): string => match ($status) {
'已处理' => 'is-success',
'处理完成', '已处理' => 'is-success',
'处理中' => 'is-info',
'无需处理' => 'is-muted',
'已忽略', '无需处理' => 'is-muted',
default => 'is-warning',
};
$levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted');
$levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒'));
$pushTone = static fn (?string $status): string => match ($status) {
'已推送' => 'is-success',
'推送失败' => 'is-danger',
default => 'is-muted',
};
$previewTone = static fn (string $status): string => match ($status) {
'new' => 'is-success',
'update' => 'is-info',
@ -334,7 +343,84 @@
}
.financial-risk-control__table {
min-width: 78rem;
min-width: 108rem;
}
.financial-risk-control__viewbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
font-size: 0.75rem;
color: var(--vip-content-muted, #595959);
}
.financial-risk-control__viewbar-label {
font-weight: 600;
}
.financial-risk-control__viewbar .upstream-data-button.is-active {
border-color: var(--vip-content-accent, #B9954F);
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.financial-risk-control__row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
}
.financial-risk-control__row-actions button {
padding: 0.125rem 0.5rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.375rem;
background: #FFFFFF;
color: var(--vip-content-ink, #171717);
cursor: pointer;
font-size: 0.75rem;
white-space: nowrap;
}
.financial-risk-control__row-actions button:hover {
border-color: var(--vip-content-accent, #B9954F);
color: var(--vip-content-accent-dark, #856632);
}
.financial-risk-control__ignore summary {
padding: 0.125rem 0.5rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.75rem;
list-style: none;
white-space: nowrap;
}
.financial-risk-control__ignore summary::-webkit-details-marker {
display: none;
}
.financial-risk-control__ignore-body {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
}
.financial-risk-control__ignore-body input {
width: 8rem;
padding: 0.125rem 0.375rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.375rem;
font-size: 0.75rem;
}
.financial-risk-control__muted-note {
margin-top: 0.125rem;
color: var(--vip-content-muted, #595959);
font-size: 0.6875rem;
}
.financial-risk-control__table td.is-number {
@ -444,8 +530,15 @@
当前筛选 {{ number_format($total) }} 条{{ $hasFilters ? '(已启用筛选条件)' : '' }}。
</p>
<div class="financial-risk-control__viewbar">
<span class="financial-risk-control__viewbar-label">数据范围</span>
<a class="upstream-data-button {{ $defaultView ? 'is-active' : '' }}" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'default']) }}">默认视图(需提醒 · 未处理)</a>
<a class="upstream-data-button {{ $defaultView ? '' : 'is-active' }}" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'all']) }}">全部数据</a>
</div>
<form class="upstream-data-toolbar" method="get" action="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert']) }}" aria-label="{{ $label }}店铺违规信息筛选">
<input type="hidden" name="platform" value="{{ $slug }}">
<input type="hidden" name="view" value="{{ $defaultView ? 'default' : 'all' }}">
<div class="upstream-data-field">
<label for="store-violation-keyword-{{ $slug }}">关键词</label>
<input id="store-violation-keyword-{{ $slug }}" name="keyword" type="text" value="{{ $filters['keyword'] ?? '' }}" placeholder="店铺名 / 违规单号 / 违规类型">
@ -473,6 +566,32 @@
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-level-{{ $slug }}">预警级别</label>
<select id="store-violation-level-{{ $slug }}" name="level">
<option value="">全部级别</option>
@foreach (($filterOptions['levels'] ?? []) as $levelOption)
<option value="{{ $levelOption }}" @selected(($filters['level'] ?? '') === $levelOption)>{{ $alertLevels[$levelOption]['label'] ?? $levelOption }}</option>
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-owner-{{ $slug }}">责任人</label>
<select id="store-violation-owner-{{ $slug }}" name="owner">
<option value="">全部责任人</option>
<option value="me" @selected(($filters['owner_is_me'] ?? '') === '1')>我的待办</option>
@foreach (($filterOptions['owners'] ?? []) as $ownerOption)
<option value="{{ $ownerOption }}" @selected(($filters['owner_is_me'] ?? '') !== '1' && ($filters['owner'] ?? '') === $ownerOption)>{{ $ownerOption }}</option>
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-overdue-{{ $slug }}">是否超时</label>
<select id="store-violation-overdue-{{ $slug }}" name="overdue">
<option value="">全部</option>
<option value="1" @selected(($filters['overdue'] ?? '') === '1')>只看已超时</option>
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-category-{{ $slug }}">违规分类</label>
<select id="store-violation-category-{{ $slug }}" name="category">
@ -541,39 +660,89 @@
<tr>
<th scope="col">序号</th>
<th scope="col">店铺名</th>
<th scope="col">预警级别</th>
<th scope="col">违规类型</th>
<th scope="col">违规分类</th>
<th scope="col">违规原因</th>
<th scope="col">处置方式</th>
<th scope="col">违规单号</th>
<th scope="col">违规日期</th>
<th scope="col">扣分</th>
<th scope="col">责任人</th>
<th scope="col">状态</th>
<th scope="col">处罚日期</th>
<th scope="col">申诉截止日期</th>
<th scope="col">处理截止</th>
<th scope="col">是否超时</th>
<th scope="col">推送状态</th>
<th scope="col">处理状态</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse (($rows ?? collect()) as $index => $record)
@php
$overdue = \App\Business\StoreViolationAlertCalculator::isOverdue($record->handling_status ?? null, $record->handling_deadline ?? null);
@endphp
<tr>
<td style="color: var(--vip-content-muted, #595959)">{{ ($rows->firstItem() ?? 1) + $index }}</td>
<td>{{ $record->shop_name ?: '-' }}</td>
<td>
@if (! empty($record->alert_level) && ($record->needs_alert ?? false))
<span class="financial-risk-control__chip {{ $levelTone($record->alert_level) }}">{{ $levelLabel($record->alert_level) }}</span>
@else
<span class="financial-risk-control__chip is-muted">不提醒</span>
@endif
</td>
<td title="{{ $record->violation_type }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_type, 40, '…') ?: '-' }}</td>
<td>{{ $record->violation_category ?: '-' }}</td>
<td title="{{ $record->violation_reason }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_reason, 40, '…') ?: '-' }}</td>
<td title="{{ $record->disposition }}">{{ \Illuminate\Support\Str::limit((string) $record->disposition, 30, '…') ?: '-' }}</td>
<td>{{ $record->violation_no ?: '-' }}</td>
<td>{{ $record->violation_date ?: '-' }}</td>
<td class="is-number">{{ $record->points_deducted === null ? '-' : rtrim(rtrim(number_format((float) $record->points_deducted, 2, '.', ''), '0'), '.') }}</td>
<td>{{ $record->owner_name ?: '-' }}</td>
<td>{{ $record->status ?: '-' }}</td>
<td>{{ $record->penalty_date ?: '-' }}</td>
<td>{{ $record->appeal_deadline ?: '-' }}</td>
<td title="{{ $record->handling_deadline }}">{{ $record->handling_deadline ? \Illuminate\Support\Carbon::parse($record->handling_deadline)->format('Y-m-d H:i') : '-' }}</td>
<td>
@if ($overdue)
<span class="financial-risk-control__chip is-warning">已超时</span>
@else
<span class="financial-risk-control__chip is-muted">未超时</span>
@endif
</td>
<td>
<span class="financial-risk-control__chip {{ $pushTone($record->push_status ?? '未推送') }}">{{ $record->push_status ?: '未推送' }}</span>
</td>
<td>
<span class="financial-risk-control__chip {{ $handlingTone((string) $record->handling_status) }}">
{{ $record->handling_status ?: '未处理' }}
</span>
@if (($record->handling_status ?? '') === '已忽略' && ! empty($record->ignore_reason))
<div class="financial-risk-control__muted-note" title="{{ $record->ignore_reason }}">{{ \Illuminate\Support\Str::limit((string) $record->ignore_reason, 14, '…') }}</div>
@endif
</td>
<td>
<form method="post" action="{{ route('business.store-violation-alert.update') }}" class="financial-risk-control__row-actions">
@csrf
<input type="hidden" name="id" value="{{ $record->id }}">
<button type="submit" name="handling_status" value="处理中" title="认领(处理中)">认领</button>
<button type="submit" name="handling_status" value="处理完成" title="标记处理完成">完成</button>
<details class="financial-risk-control__ignore">
<summary title="忽略(需填写原因)">忽略</summary>
<div class="financial-risk-control__ignore-body">
<input type="text" name="ignore_reason" maxlength="191" placeholder="忽略原因(必填)">
<button type="submit" name="handling_status" value="已忽略">确认</button>
</div>
</details>
</form>
</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="11">
<td colspan="19">
@if ($error)
{{ $error }}
@elseif ($platformTotal === 0)

View File

@ -0,0 +1,470 @@
@php
$platforms = $storeViolationPlatforms ?? [];
$activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms);
$panels = $storeViolationPanels ?? [];
$platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []);
$error = $storeViolationError ?? null;
$alertLevels = config('store_violations.alert_levels', []);
$levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted');
$levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒'));
$pushTone = static fn (?string $status): string => match ($status) {
'已推送' => 'is-success',
'推送失败' => 'is-danger',
default => 'is-muted',
};
@endphp
<style>
.financial-risk-control {
margin-top: 1.5rem;
}
.financial-risk-control__tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding: 0.25rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
scrollbar-width: thin;
scrollbar-color: var(--vip-content-accent, #B9954F) transparent;
}
.financial-risk-control__tabs::-webkit-scrollbar {
height: 0.375rem;
}
.financial-risk-control__tabs::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--vip-content-accent, #B9954F);
}
.financial-risk-control__tab {
display: inline-flex;
min-width: 7.5rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0 1rem;
border: 0;
border-bottom: 0.1875rem solid transparent;
border-radius: 0.5rem 0.5rem 0 0;
background: transparent;
color: var(--vip-content-muted, #595959);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.financial-risk-control__tab:hover,
.financial-risk-control__tab:focus-visible {
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
outline: none;
}
.financial-risk-control__tab.is-active {
border-bottom-color: var(--vip-content-accent, #B9954F);
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.financial-risk-control__tab .material-icons-outlined {
font-size: 1.125rem;
}
.financial-risk-control__tab-count {
padding: 0 0.375rem;
border-radius: 999px;
background: rgba(185, 149, 79, 0.18);
color: var(--vip-content-accent-dark, #856632);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.25rem;
}
.financial-risk-control__panel {
min-height: 22rem;
margin-top: 1rem;
padding: 1.5rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
color: var(--vip-content-ink, #171717);
}
.financial-risk-control__panel[hidden] {
display: none;
}
.financial-risk-control__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--vip-border, #D8D2C7);
}
.financial-risk-control__panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.financial-risk-control__panel-title .material-icons-outlined {
color: var(--vip-content-accent-dark, #856632);
font-size: 1.25rem;
}
.financial-risk-control__panel-description {
max-width: 36rem;
margin: 0.5rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.8125rem;
line-height: 1.5;
}
.financial-risk-control__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 999px;
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
font-size: 0.75rem;
line-height: 1.333;
white-space: nowrap;
}
.risk-preview__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
margin: 0 0 1rem;
}
.risk-preview__card {
padding: 0.75rem 1rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
}
.risk-preview__card-label {
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.risk-preview__card-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.risk-preview__card.is-danger .risk-preview__card-value {
color: var(--vip-danger, #B80000);
}
.risk-preview__card.is-warning .risk-preview__card-value {
color: var(--vip-warning, #935F07);
}
.risk-preview__table {
min-width: 64rem;
}
.risk-preview__chip {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
line-height: 1.333;
white-space: nowrap;
}
.risk-preview__chip.is-danger {
border-color: rgba(184, 0, 0, 0.35);
background: rgba(184, 0, 0, 0.08);
color: var(--vip-danger, #B80000);
}
.risk-preview__chip.is-warning {
border-color: rgba(147, 95, 7, 0.35);
background: rgba(147, 95, 7, 0.12);
color: var(--vip-warning, #935F07);
}
.risk-preview__chip.is-success {
border-color: rgba(83, 121, 62, 0.35);
background: rgba(83, 121, 62, 0.12);
color: var(--vip-success, #53793E);
}
.risk-preview__chip.is-muted {
border-color: var(--vip-border, #D8D2C7);
background: #F4F2ED;
color: var(--vip-content-muted, #595959);
}
.risk-preview__actions {
display: flex;
align-items: center;
gap: 0.375rem;
}
.risk-preview__actions button {
padding: 0.125rem 0.5rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.375rem;
background: #FFFFFF;
color: var(--vip-content-ink, #171717);
cursor: pointer;
font-size: 0.75rem;
white-space: nowrap;
}
.risk-preview__actions button:hover {
border-color: var(--vip-content-accent, #B9954F);
color: var(--vip-content-accent-dark, #856632);
}
</style>
<section
class="financial-risk-control upstream-data-page"
data-risk-preview-tabs
data-active-platform="{{ $activePlatform }}"
aria-label="店铺违规预警预览统计"
>
@include('business.pages.partials.upstream-data-styles')
<nav class="financial-risk-control__tabs" role="tablist" aria-label="平台">
@foreach ($platforms as $slug => $platform)
@php $count = $platformCounts[$slug] ?? 0; @endphp
<button
type="button"
class="financial-risk-control__tab {{ $activePlatform === $slug ? 'is-active' : '' }}"
id="risk-preview-tab-{{ $slug }}"
role="tab"
aria-controls="risk-preview-panel-{{ $slug }}"
aria-selected="{{ $activePlatform === $slug ? 'true' : 'false' }}"
data-platform-tab="{{ $slug }}"
>
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] ?? 'storefront' }}</span>
<span>{{ $platform['label'] ?? $slug }}</span>
@if ($count > 0)
<span class="financial-risk-control__tab-count">{{ number_format($count) }}</span>
@endif
</button>
@endforeach
</nav>
@foreach ($platforms as $slug => $platform)
@php
$panel = $panels[$slug] ?? null;
$rows = $panel['rows'] ?? null;
$total = (int) ($panel['total'] ?? 0);
$stats = $panel['stats'] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0];
$label = $platform['label'] ?? $slug;
@endphp
<section
class="financial-risk-control__panel"
id="risk-preview-panel-{{ $slug }}"
role="tabpanel"
aria-labelledby="risk-preview-tab-{{ $slug }}"
data-platform-panel="{{ $slug }}"
@if ($activePlatform !== $slug) hidden @endif
>
<header class="financial-risk-control__panel-header">
<div>
<h2 class="financial-risk-control__panel-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] ?? 'storefront' }}</span>
<span>{{ $label }}预警预览统计</span>
</h2>
<p class="financial-risk-control__panel-description">需要提醒且未处理的违规记录概览;完整查询与处理请前往「店铺违规信息预警」。</p>
</div>
<span class="financial-risk-control__status">
<span class="material-icons-outlined" aria-hidden="true">insights</span>
<span>待处理 {{ number_format($stats['pending']) }} 条</span>
</span>
</header>
@if ($error)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<div class="risk-preview__kpis" aria-label="{{ $label }}预警统计">
<div class="risk-preview__card">
<div class="risk-preview__card-label">今日新增</div>
<div class="risk-preview__card-value">{{ number_format($stats['new_today']) }}</div>
</div>
<div class="risk-preview__card">
<div class="risk-preview__card-label">待处理总数</div>
<div class="risk-preview__card-value">{{ number_format($stats['pending']) }}</div>
</div>
<div class="risk-preview__card is-danger">
<div class="risk-preview__card-label">P0 未处理</div>
<div class="risk-preview__card-value">{{ number_format($stats['p0_pending']) }}</div>
</div>
<div class="risk-preview__card is-warning">
<div class="risk-preview__card-label">已超时</div>
<div class="risk-preview__card-value">{{ number_format($stats['overdue']) }}</div>
</div>
</div>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table risk-preview__table" aria-label="{{ $label }}预警预览">
<thead>
<tr>
<th scope="col">序号</th>
<th scope="col">预警级别</th>
<th scope="col">店铺名</th>
<th scope="col">违规类型</th>
<th scope="col">责任人</th>
<th scope="col">处理截止</th>
<th scope="col">是否超时</th>
<th scope="col">推送状态</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse (($rows ?? collect()) as $index => $record)
@php
$overdue = \App\Business\StoreViolationAlertCalculator::isOverdue($record->handling_status ?? null, $record->handling_deadline ?? null);
@endphp
<tr>
<td style="color: var(--vip-content-muted, #595959)">{{ ($rows->firstItem() ?? 1) + $index }}</td>
<td>
<span class="risk-preview__chip {{ $levelTone($record->alert_level) }}">{{ $levelLabel($record->alert_level) }}</span>
</td>
<td>{{ $record->shop_name ?: '-' }}</td>
<td title="{{ $record->violation_type }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_type, 30, '…') ?: '-' }}</td>
<td>{{ $record->owner_name ?: '-' }}</td>
<td title="{{ $record->handling_deadline }}">{{ $record->handling_deadline ? \Illuminate\Support\Carbon::parse($record->handling_deadline)->format('Y-m-d H:i') : '-' }}</td>
<td>
@if ($overdue)
<span class="risk-preview__chip is-warning">已超时</span>
@else
<span class="risk-preview__chip is-muted">未超时</span>
@endif
</td>
<td>
<span class="risk-preview__chip {{ $pushTone($record->push_status ?? '未推送') }}">{{ $record->push_status ?: '未推送' }}</span>
</td>
<td>
<div class="risk-preview__actions">
<form method="post" action="{{ route('business.store-violation-alert.update') }}">
@csrf
<input type="hidden" name="id" value="{{ $record->id }}">
<button type="submit" name="handling_status" value="处理中" title="认领(处理中)">认领</button>
</form>
<a class="upstream-data-button" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'default', 'keyword' => $record->violation_no]) }}">去处理</a>
</div>
</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="9">
@if ($error)
{{ $error }}
@else
暂无需要提醒且未处理的记录。
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows instanceof \Illuminate\Pagination\LengthAwarePaginator && $rows->total() > 0)
<div class="upstream-data-pagination">
<span>
共 {{ number_format($rows->total()) }} 行
· 每页 {{ $rows->perPage() }} 行
· 第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页
</span>
<div class="upstream-data-pagination__controls" aria-label="{{ $label }}预警预览分页">
@if ($rows->onFirstPage())
<span class="upstream-data-button is-disabled" aria-disabled="true" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</span>
@else
<a class="upstream-data-button" href="{{ $rows->previousPageUrl() }}" aria-label="上一页" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</a>
@endif
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
@if ($rows->hasMorePages())
<a class="upstream-data-button" href="{{ $rows->nextPageUrl() }}" aria-label="下一页" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</a>
@else
<span class="upstream-data-button is-disabled" aria-disabled="true" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</span>
@endif
</div>
</div>
@endif
</div>
</section>
@endforeach
</section>
<script>
(() => {
const root = document.querySelector('[data-risk-preview-tabs]');
if (!root || root.dataset.bound === 'true') return;
root.dataset.bound = 'true';
const tabs = [...root.querySelectorAll('[data-platform-tab]')];
const panels = [...root.querySelectorAll('[data-platform-panel]')];
const validPlatforms = new Set(tabs.map((tab) => tab.dataset.platformTab));
const setPlatform = (platform, updateUrl = true) => {
const active = validPlatforms.has(platform) ? platform : tabs[0]?.dataset.platformTab;
if (!active) return;
root.dataset.activePlatform = active;
tabs.forEach((tab) => {
const selected = tab.dataset.platformTab === active;
tab.classList.toggle('is-active', selected);
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.platformPanel !== active;
});
if (updateUrl) {
const url = new URL(window.location.href);
url.searchParams.set('platform', active);
url.searchParams.delete('page');
window.history.pushState({ platform: active }, '', url);
}
};
tabs.forEach((tab) => tab.addEventListener('click', () => setPlatform(tab.dataset.platformTab)));
window.addEventListener('popstate', () => setPlatform(new URL(window.location.href).searchParams.get('platform'), false));
})();
</script>

View File

@ -1645,9 +1645,9 @@
<span>线上单号:</span>
<input type="text" name="online_order_no" value="{{ request('online_order_no') }}" placeholder="支持包含查询">
</label>
<div class="installation-filter-field cw-ms" id="rdStatus" data-name="match_status"
data-initial="{{ json_encode(array_values((array) request('match_status', [])), JSON_UNESCAPED_UNICODE) }}">
<span>匹配状态:</span>
<div class="installation-filter-field cw-ms" id="rdDifferenceState" data-name="difference_state"
data-initial="{{ json_encode(array_values((array) request('difference_state', [])), JSON_UNESCAPED_UNICODE) }}">
<span>差额:</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
@ -1667,6 +1667,17 @@
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="installation-filter-field cw-ms" id="rdStatus" data-name="match_status"
data-initial="{{ json_encode(array_values((array) request('match_status', [])), JSON_UNESCAPED_UNICODE) }}">
<span>匹配状态:</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
<div class="installation-filter-field cw-ms" id="rdUnmatchedReason" data-name="unmatched_reason"
data-initial="{{ json_encode(array_values((array) request('unmatched_reason', [])), JSON_UNESCAPED_UNICODE) }}">
<span>未匹配原因:</span>
@ -1689,6 +1700,7 @@
if (!api) { return; }
var widgets = [];
[
['rdDifferenceState', @json(['有差额', '无差额']), '打字找差额(可多选)'],
['rdPlatform', @json(array_values($receiptStats['platform_options'] ?? [])), '打字找平台(可多选)'],
['rdStore', @json(array_values($receiptStats['store_options'] ?? [])), '打字找店铺(可多选)'],
['rdStatus', @json(array_values($receiptStats['status_options'] ?? [])), '打字找匹配状态(可多选)'],
@ -1716,6 +1728,8 @@
});
var month = form.querySelector('[name="month"]');
if (month && month.value) { params.set('month', month.value); }
var differenceState = form.querySelector('[name="difference_state"]');
if (differenceState && differenceState.value) { params.set('difference_state', differenceState.value); }
var orderNo = form.querySelector('[name="online_order_no"]');
if (orderNo && orderNo.value.trim()) { params.set('online_order_no', orderNo.value.trim()); }
@ -1753,6 +1767,22 @@
widget.root.addEventListener('click', schedule);
widget.root.addEventListener('change', schedule);
});
var differenceSelect = form.querySelector('[name="difference_state"]');
if (differenceSelect) { differenceSelect.addEventListener('change', schedule); }
// 月份选完即查询:月份是自定义选择器(选完只改隐藏值),这里接上自动提交筛选表单,
// 省掉「选完月份再点查询」这一步;清空 / 本月按钮同属值变更,一并自动查询。
var monthInput = form.querySelector('[data-inventory-month-value]');
if (monthInput) {
monthInput.addEventListener('change', function () {
window.clearTimeout(timer);
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
}
schedule();
})();
</script>
@ -2481,6 +2511,8 @@
if (date) viewYear = date.year;
render();
close();
// 通知使用方「月份已选好」:筛选栏可以据此自动查询,不用再点一次「查询」。
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
};
label.textContent = formatLabel(valueInput.value);
@ -2590,6 +2622,9 @@
// 多选筛选提交的是 key[],重置时一并清掉。
url.searchParams.delete(`${key}[]`);
});
// 「差额」筛选(不在 fields 列表里,提交为 difference_state[])一并清掉。
url.searchParams.delete('difference_state');
url.searchParams.delete('difference_state[]');
url.searchParams.delete('page');
window.location.assign(url);
});

View File

@ -0,0 +1,203 @@
{{--
已打开页面标签栏:点开过的业务页面固定在顶部并排显示,方便来回切换;
只要没退出登录就一直保留(按账号存在浏览器本地,不外传)。
--}}
<div class="open-tabs" data-open-tabs data-user-id="{{ user()->id }}" hidden>
<div class="open-tabs__scroll">
<div class="open-tabs__list" data-open-tabs-list role="tablist" aria-label="已打开页面"></div>
</div>
<button type="button" class="open-tabs__clear" data-open-tabs-clear title="清空已打开页面(当前页面保留)">清空</button>
</div>
<style>
.open-tabs {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.625rem;
background: var(--vip-surface, #fff);
}
.open-tabs[hidden] { display: none; }
.open-tabs__scroll { flex: 1 1 auto; min-width: 0; overflow-x: auto; }
.open-tabs__list { display: flex; align-items: center; gap: 0.35rem; white-space: nowrap; }
.open-tabs__tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
max-width: 15rem;
padding: 0.3rem 0.5rem 0.3rem 0.65rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.5rem;
background: #fbf8f0;
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
text-decoration: none;
}
.open-tabs__tab:hover { border-color: var(--vip-content-accent, #b9954f); color: var(--vip-content-accent-dark, #856632); }
.open-tabs__tab.is-active {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-accent-dark, #856632);
font-weight: 600;
}
.open-tabs__label { overflow: hidden; text-overflow: ellipsis; }
.open-tabs__close {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
opacity: 0.55;
}
.open-tabs__close:hover { background: rgba(133, 102, 50, 0.15); opacity: 1; }
.open-tabs__clear {
flex: 0 0 auto;
height: 1.6rem;
padding: 0 0.5rem;
border: 1px solid var(--vip-border, #e7e0d5);
border-radius: 0.5rem;
background: #fff;
color: var(--vip-content-muted, #595959);
font-size: 0.6875rem;
cursor: pointer;
}
.open-tabs__clear:hover { border-color: var(--vip-content-accent-dark, #856632); color: var(--vip-content-accent-dark, #856632); }
</style>
<script>
(() => {
const root = document.querySelector('[data-open-tabs]');
if (!root) {
return;
}
// 目录里有用 iframe 打开子页面的页面(例如快递报价)。iframe 内不再显示标签栏:
// 它既和外层那条重复,点标签也只会在 iframe 内部再套一层同样的页面。
if (window.self !== window.top) {
return;
}
const list = root.querySelector('[data-open-tabs-list]');
const userId = root.dataset.userId || 'anon';
const storageKey = 'caiwu.open-pages.v1.' + userId;
// 只在"本次浏览器会话"里保留:同一个浏览器窗口内刷新/来回切换都在,
// 关掉浏览器或换一个窗口重新进来就是干净的,不会再把前几天的旧标签带出来。
const storage = window.sessionStorage;
const maxTabs = 15;
// 清理早期版本写在 localStorage 里的记录(那份会跨浏览器一直保留)。
try {
window.localStorage.removeItem(storageKey);
} catch (_) { /* 隐私模式下忽略 */ }
const read = () => {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.url === 'string' && typeof item.path === 'string') : [];
} catch (_) {
return [];
}
};
const save = (items) => {
try {
storage.setItem(storageKey, JSON.stringify(items));
} catch (_) { /* 隐私模式下忽略 */ }
};
const currentUrl = window.location.pathname + window.location.search;
const currentPath = window.location.pathname;
const isBusinessPage = /^\/\d+\/workspace\//.test(window.location.pathname);
const pageTitle = () => (document.title || '').split(' - ')[0].trim() || window.location.pathname;
// 只有"从系统里点进去"的页面才自动固定:浏览器恢复会话、直接输入地址、
// 从其它网站点进来时没有同源来源,不会被记进标签栏。
const openedFromApp = (() => {
try {
return !!document.referrer && new URL(document.referrer).origin === window.location.origin;
} catch (_) {
return false;
}
})();
let items = read();
if (isBusinessPage) {
// 同一张页面(按路径去重)只保留一个标签,点击标签回到最近一次带筛选的地址。
const existing = items.find((item) => item.path === currentPath);
if (existing) {
existing.title = pageTitle();
existing.url = currentUrl;
save(items);
} else if (openedFromApp || items.length === 0) {
// 从系统内点进来的页面,或者当前会话还没有任何标签(例如刷新后),
// 把当前页作为第一个标签,避免标签栏空着。
items.push({ path: currentPath, url: currentUrl, title: pageTitle() });
items = items.slice(-maxTabs);
save(items);
}
}
if (!items.length) {
return;
}
root.hidden = false;
const render = () => {
list.innerHTML = items.map((item) => {
const active = item.path === currentPath ? ' is-active' : '';
const title = String(item.title || item.url).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
const href = item.url.replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
return '<a class="open-tabs__tab' + active + '" href="' + href + '" role="tab"'
+ (active ? ' aria-current="page"' : '') + ' title="' + title + '">'
+ '<span class="open-tabs__label">' + title + '</span>'
+ '<button type="button" class="open-tabs__close" data-open-tab-close="' + href + '" aria-label="关闭 ' + title + '">×</button>'
+ '</a>';
}).join('');
};
render();
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-open-tab-close]');
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
const url = button.getAttribute('data-open-tab-close');
const index = items.findIndex((item) => item.url === url);
const wasActive = items[index] ? items[index].path === currentPath : false;
items = items.filter((item) => item.url !== url);
save(items);
if (wasActive) {
const fallback = items[Math.max(0, index - 1)] || items[0];
if (fallback && fallback.url) {
window.location.assign(fallback.url);
return;
}
}
render();
});
root.querySelector('[data-open-tabs-clear]')?.addEventListener('click', () => {
items = items.filter((item) => item.path === currentPath);
save(items);
if (!items.length) {
root.hidden = true;
list.innerHTML = '';
return;
}
render();
});
})();
</script>

View File

@ -89,6 +89,9 @@
</tbody>
</table>
@if(! empty($temporaryNote))
<div class="notice"><strong>{!! $mixedText($temporaryNote) !!}</strong></div>
@endif
<div class="notice">本文件由财务系统根据已审核报价版本自动生成。乙方签章后文件与完成证书将按本版本单独归档;后续变价须生成新版本并重新签章,旧确认单不会被覆盖。</div>
@if($change)

View File

@ -1,5 +1,16 @@
<!DOCTYPE html>
<html dir="{{ language()->direction() }}" lang="{{ app()->getLocale() }}">
@php
// 钉钉工作台「财务系统」入口:在「财务经营风险管控」目录下隐藏全局菜单,
// 只保留该目录自己的页签(店铺违规信息预警 / 流程统计…),
// 让钉钉里点进来就只是这一块内容;网页端登录(非钉钉容器)不受影响。
// 也支持显式 ?focus=1,便于以后给别的入口做同样的单页模式。
// 路由参数在部分上下文取不到时退回按路径段判断(与目录表同一套兼容写法)。
$focusSection = (string) (request()->route('section') ?: request()->segment(3));
$focusEntry = in_array($focusSection, ['financial-risk-control'], true)
&& (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk'));
@endphp
<x-layouts.admin.head>
@if (! empty($metaTitle))
<x-slot name="metaTitle">
@ -20,13 +31,20 @@
@stack('body_start')
<x-layouts.admin.menu />
@unless ($focusEntry)
<x-layouts.admin.menu />
@endunless
<!-- loading component will add this line -->
<div class="main-content xl:ltr:ml-64 xl:rtl:mr-64 transition-all ease-in-out" id="panel">
<div class="main-content {{ $focusEntry ? '' : 'xl:ltr:ml-64 xl:rtl:mr-64' }} transition-all ease-in-out" id="panel">
<div id="main-body">
<div class="container">
{{-- 已打开页面标签栏放在页面标题那一行上面(更靠近顶部,方便切换)。 --}}
@unless ($focusEntry)
@include('business.pages.partials.open-page-tabs')
@endunless
<x-layouts.admin.header>
<x-slot name="title">
{!! ! empty($title->attributes->has('title')) ? $title->attributes->get('title') : $title !!}

View File

@ -199,6 +199,22 @@
@endphp
<style>
.prototype-tag-filter { margin: 0 0 1rem; }
.prototype-tag-filter input {
width: 100%;
height: 2rem;
padding: 0 0.6rem;
border: 1px solid rgba(240, 228, 191, 0.25);
border-radius: 0.375rem;
background: rgba(240, 228, 191, 0.06);
color: var(--vip-sidebar-gold, #F0E4BF);
font: inherit;
font-size: 0.75rem;
outline: none;
}
.prototype-tag-filter input::placeholder { color: rgba(240, 228, 191, 0.45); }
.prototype-tag-filter input:focus { border-color: var(--vip-sidebar-gold, #F0E4BF); background: rgba(240, 228, 191, 0.12); }
.prototype-tag-empty { padding: 0.35rem 0.75rem; color: rgba(240, 228, 191, 0.5); font-size: 0.75rem; }
.prototype-tag-navigation { margin: 0 -0.25rem 1.25rem; }
.prototype-tag-group { margin-bottom: 1.15rem; }
.prototype-tag-group-title { padding: 0 0.75rem 0.45rem; color: var(--vip-sidebar-gold-soft, #DDD0AA); font-size: 0.6875rem; line-height: 1rem; }
@ -210,6 +226,10 @@
.prototype-tag-link > span:nth-child(2) { flex: 1; white-space: nowrap; }
</style>
<div class="prototype-tag-filter">
<input type="search" id="prototype-menu-filter" placeholder="搜索菜单,如:快递应付" autocomplete="off" aria-label="搜索菜单">
</div>
<div class="prototype-tag-navigation" aria-label="税务功能导航">
@foreach ($prototypeGroups as $group)
<section class="prototype-tag-group">
@ -226,6 +246,7 @@
@endforeach
</section>
@endforeach
<div class="prototype-tag-empty" data-menu-filter-empty hidden>没有匹配的菜单</div>
</div>
<div class="main-menu transform">
@ -347,3 +368,47 @@
</div>
@stack('menu_end')
<script>
// 侧边栏菜单搜索:按关键字过滤菜单项,方便快速找到页面。
(() => {
const input = document.getElementById('prototype-menu-filter');
if (!input) {
return;
}
const nav = document.querySelector('.prototype-tag-navigation');
if (!nav) {
return;
}
const groups = Array.from(nav.querySelectorAll('.prototype-tag-group'));
const empty = nav.querySelector('[data-menu-filter-empty]');
const apply = () => {
const keyword = input.value.trim().toLowerCase();
let total = 0;
groups.forEach((group) => {
let visible = 0;
group.querySelectorAll('.prototype-tag-link').forEach((link) => {
const match = keyword === '' || link.textContent.trim().toLowerCase().includes(keyword);
link.style.display = match ? '' : 'none';
if (match) {
visible += 1;
}
});
group.style.display = visible > 0 ? '' : 'none';
total += visible;
});
if (empty) {
empty.hidden = total > 0;
}
};
input.addEventListener('input', apply);
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
input.value = '';
apply();
}
});
})();
</script>

View File

@ -1,22 +1,30 @@
@php
/** @var \App\Models\Business\CourierOnboarding $onboarding */
$editable = $onboarding->courierCanEdit();
// 后台变价复用本页的报价区:$requote 为后台变价上下文(只显示报价,提交到后台)。
$requote = $requote ?? null;
$editable = $requote !== null || $onboarding->courierCanEdit();
$value = static fn (string $key, $default = null) => old($key, $onboarding->{$key} ?? $default);
$savedRows = collect($onboarding->quoteRowList())->keyBy('region');
$oldRows = old('quote_rows');
$quoteRows = [];
foreach ($provinces as $index => $province) {
$row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($savedRows->get($province) ?? []);
$quoteRows[] = ['region' => $province, 'first_weight' => $row['first_weight'] ?? null, 'first_price' => $row['first_price'] ?? null, 'rate' => $row['rate'] ?? null, 'fixed' => true];
}
$customRows = $savedRows->except($provinces)->values()->all();
for ($i = 0; $i < 4; $i++) {
$index = count($provinces) + $i;
$row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($customRows[$i] ?? []);
$quoteRows[] = ['region' => $row['region'] ?? null, 'first_weight' => $row['first_weight'] ?? null, 'first_price' => $row['first_price'] ?? null, 'rate' => $row['rate'] ?? null, 'fixed' => false];
foreach ([...$provinces, null, null, null, null] as $index => $province) {
$row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($province !== null ? ($savedRows->get($province) ?? []) : ($customRows[$index - count($provinces)] ?? []));
$quoteRows[] = [
'region' => $province ?? ($row['region'] ?? ''),
'fixed' => $province !== null,
'flat' => array_values((array) ($row['flat'] ?? [])),
'base' => $row['base'] ?? null,
'rates' => array_values((array) ($row['rates'] ?? [])),
];
}
$structure = $onboarding->quoteStructure();
$kgList = static fn (array $list): string => implode(',', array_map(static fn ($v) => \App\Support\CourierQuoteStructure::kg((float) $v), $list));
$quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last();
$filesByKind = $onboarding->files->groupBy('kind');
$quoteMode = $value('quote_mode', 'waybill_rate');
$entityChosen = $requote !== null || filled(old('entity_type')) || filled($onboarding->company_full_name) || $onboarding->status !== 'invited' || $onboarding->files->isNotEmpty();
$entityType = $entityChosen ? $value('entity_type', 'company') : null;
$statusTone = match ($onboarding->status) {
'returned' => 'warning',
'activated' => 'success',
@ -32,7 +40,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="format-detection" content="telephone=no">
<title>快递合作入驻</title>
<title>{{ $requote ? '快递变价' : '快递合作入驻' }}</title>
<style>
:root {
--ink: #171717; --muted: #595959; --gold: #856632; --gold-strong: #6A5129; --gold-soft: #F7F0DE;
@ -90,7 +98,9 @@
.quote-tools { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; padding: 12px; border: 1px dashed var(--gold-line); border-radius: 10px; background: #FFFDF8; }
.quote-tools .field > label { font-size: 12px; font-weight: 500; color: var(--muted); }
.quote-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; -webkit-overflow-scrolling: touch; }
.quote-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 14px; }
.quote-table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 14px; }
.quote-table td:first-child, .quote-table th:first-child { position: sticky; left: 0; z-index: 1; background: #fff; }
.quote-table th:first-child { background: var(--gold-soft); }
.quote-table th { position: sticky; top: 0; padding: 9px 8px; background: var(--gold-soft); color: var(--gold-strong); font-size: 12px; font-weight: 700; text-align: left; white-space: nowrap; }
.quote-table td { padding: 5px 6px; border-top: 1px solid #ECE8E0; }
.quote-table th:first-child, .quote-table td:first-child { width: 4.6em; padding-left: 10px; font-weight: 600; white-space: nowrap; }
@ -120,6 +130,55 @@
.summary dt { color: var(--muted); }
.summary dd { margin: 0; word-break: break-all; }
.footer-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; text-align: center; }
.card__body > *, .structure > *, .quote-tools > * { min-width: 0; }
.quote-table-wrap { max-width: 100%; }
.mode-list { display: grid; gap: 8px; }
.mode-option { position: relative; display: block; cursor: pointer; }
.mode-option input { position: absolute; opacity: 0; pointer-events: none; }
.mode-option > span { display: grid; gap: 3px; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.mode-option strong { font-size: 15px; }
.mode-option small { color: var(--muted); font-size: 12px; line-height: 1.5; }
.mode-option input:checked + span { border-color: var(--gold); background: var(--gold-soft); box-shadow: inset 3px 0 0 var(--gold); }
.mode-option input:checked + span strong { color: var(--gold-strong); }
.structure { display: grid; gap: 14px; }
.account { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; }
.account__title { margin: 0; font-weight: 700; font-size: 15px; }
.account__tag { margin-left: 6px; padding: 1px 6px; border-radius: 6px; background: #F1EEE6; color: var(--muted); font-size: 12px; font-weight: 500; }
.account__check { font-size: 12px; }
.account__check.is-ok { color: #2F6B2A; }
.account__check.is-bad { color: #A1281F; }
.band-list { display: grid; gap: 8px; }
.band-row { display: grid; grid-template-columns: 44px 40px 92px auto 40px; gap: 8px; align-items: center; font-size: 15px; }
.band-row__no { color: var(--muted); font-size: 13px; }
.band-row input { width: 92px; }
.band-row__del { min-height: 40px; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--muted); font-size: 18px; cursor: pointer; }
.band-add { justify-self: start; min-height: 40px; font-size: 15px; }
[data-mode="none"] .structure, [data-mode="other"] .structure { display: none; }
.quote-table th { min-width: 84px; }
.quote-table td input[data-q-slot] { width: 76px; min-width: 76px; }
.quote-table td input[data-q-region] { width: 78px; }
.quote-table th { white-space: normal; max-width: 96px; }
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.upload { gap: 6px; padding: 10px; }
.upload__title { font-size: 13px; }
.upload__drop { position: relative; display: block; cursor: pointer; }
.upload__drop input[type=file] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.upload__preview { display: grid; place-items: center; align-content: center; gap: 4px; height: 108px; overflow: hidden; border: 1px dashed var(--gold-line); border-radius: 8px; background: #fff; color: var(--muted); font-size: 12px; text-align: center; }
.upload__preview img { width: 100%; height: 100%; object-fit: cover; }
.upload__plus { color: var(--gold); font-size: 28px; line-height: 1; }
.upload__done { color: var(--success); font-weight: 700; }
.upload__name { max-width: 100%; padding: 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload__remove { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 12px; }
.chooser { display: grid; gap: 12px; margin-bottom: 12px; }
.chooser__title { margin: 4px 0 0; font-size: 17px; font-weight: 700; }
.chooser__card { display: grid; grid-template-columns: 48px 1fr; gap: 12px; align-items: center; width: 100%; padding: 18px 16px; border: 1px solid var(--border); border-radius: 12px; background: #fff; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
.chooser__card:active { background: var(--gold-soft); }
.chooser__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 12px; background: var(--gold-soft); color: var(--gold); font-size: 22px; font-weight: 700; }
.chooser__card strong { display: block; font-size: 16px; }
.chooser__card span span { display: block; margin-top: 3px; color: var(--muted); font-size: 13px; }
[data-entity="none"] .card, [data-entity="none"] ~ .actionbar, [data-entity="none"] .actionbar { display: none !important; }
[data-entity="company"] [data-chooser], [data-entity="person"] [data-chooser] { display: none !important; }
[data-entity="company"] [data-entity-only="person"], [data-entity="person"] [data-entity-only="company"] { display: none !important; }
@media (max-width: 520px) {
.grid-2 { grid-template-columns: 1fr; }
.quote-tools { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@ -128,6 +187,20 @@
</style>
</head>
<body>
@if ($requote)
<header class="hero">
<div class="hero__inner">
<p class="hero__eyebrow"><a href="{{ $requote['back'] }}" style="color:inherit">← 返回报价管理 · 快递报价</a></p>
<h1>{{ $requote['courier'] }} · 变价</h1>
<p>以当前报价为底稿修改,可以只改价格,也可以改计费方式和重量分档。提交后需另一人审核,审核通过自动生成新报价版本,并推送到快递的 e签宝签署。</p>
<div class="hero__meta">
<span>底稿版本 {{ $requote['version']?->version_code ?? '入驻首版' }}</span>
@if ($requote['change'])<span>修改已退回的变价单 {{ $requote['change']->number }}</span>@endif
<span>签署人 {{ $requote['signer'] }}</span>
</div>
</div>
</header>
@else
<header class="hero">
<div class="hero__inner">
<p class="hero__eyebrow">{{ company()?->name ? company()->name . ' · ' : '' }}快递合作</p>
@ -140,6 +213,7 @@
</div>
</div>
</header>
@endif
<main class="main">
@php
@ -151,16 +225,22 @@
default => 1,
};
@endphp
@unless ($requote)
<ol class="steps" aria-label="入驻进度">
@foreach (['填写资料', '我方审核', '签署协议', '开通合作'] as $i => $step)
<li class="{{ $i < $stepIndex ? 'is-done' : ($i === $stepIndex ? 'is-current' : '') }}" @if ($i === $stepIndex) aria-current="step" @endif>{{ $step }}</li>
@endforeach
</ol>
@endunless
@if (session('onboarding_success'))
<div class="notice notice--success" role="status"><div><strong>{{ session('onboarding_success') }}</strong></div></div>
@endif
@if (request()->boolean('too_large'))
<div class="notice notice--danger" role="alert"><div><strong>图片太大,这次没有保存成功</strong>请重新选择照片,或每次少选几张、先点「暂存」再继续上传。</div></div>
@endif
@if ($errors->any())
<div class="notice notice--danger" role="alert">
<div>
@ -170,7 +250,11 @@
</div>
@endif
@if ($onboarding->status === 'returned' && $onboarding->return_reason)
@if ($requote)
@if ($requote['change'] && $requote['change']->status === 'returned')
<div class="notice notice--warning" role="status"><div><strong>变价单 {{ $requote['change']->number }} 已被退回</strong>按审核意见修改后重新提交即可。</div></div>
@endif
@elseif ($onboarding->status === 'returned' && $onboarding->return_reason)
<div class="notice notice--warning" role="status"><div><strong>需要补充或修改</strong>{{ $onboarding->return_reason }}</div></div>
@elseif (! $editable)
<div class="notice notice--{{ $statusTone }}" role="status">
@ -182,7 +266,7 @@
资料已提交,审核中。如需修改,请联系我方对接人退回后再改。
@break
@case('approved')
审核已通过,我方会通过 E 签宝发起合作协议,请留意短信并完成签署。
审核已通过,报价确认单已推送到你填写的 e签宝账号,请留意短信或邮件,登录 e签宝完成签署。
@break
@case('activated')
合作已开通,感谢配合。
@ -197,19 +281,48 @@
</div>
@endif
<form method="POST" action="{{ route('courier-onboarding.update', ['token' => $token]) }}" enctype="multipart/form-data" novalidate data-onboarding-form>
<form method="POST" action="{{ $requote ? $requote['action'] : route('courier-onboarding.update', ['token' => $token]) }}" enctype="multipart/form-data" novalidate data-onboarding-form data-entity="{{ $entityType === null ? 'none' : ($entityType === 'person' ? 'person' : 'company') }}">
@csrf
@if ($editable && ! $requote)
<div class="chooser" data-chooser>
<p class="chooser__title">请选择入驻方式</p>
<button type="button" class="chooser__card" data-choose-entity="company">
<span class="chooser__icon" aria-hidden="true">企</span>
<span><strong>公司 / 个体户入驻</strong><span>有营业执照,以公司名义合作和签署</span></span>
</button>
<button type="button" class="chooser__card" data-choose-entity="person">
<span class="chooser__icon" aria-hidden="true">人</span>
<span><strong>个人入驻</strong><span>以个人身份合作,用本人身份证实名签署</span></span>
</button>
</div>
@endif
<fieldset @disabled(! $editable) style="margin:0;padding:0;border:0;min-width:0">
@unless ($requote)
<section class="card" aria-labelledby="sec-company">
<div class="card__head"><span class="card__index">1</span><h2 class="card__title" id="sec-company">公司主体</h2><span class="card__hint">与营业执照一致</span></div>
<div class="card__head"><span class="card__index">1</span><h2 class="card__title" id="sec-company">入驻主体</h2><span class="card__hint" data-entity-only="company">与营业执照一致</span><span class="card__hint" data-entity-only="person">与身份证一致</span></div>
<div class="card__body">
<div class="field">
<span class="label">入驻类型<span class="req">*</span></span>
<div class="choices" role="radiogroup" aria-label="入驻类型">
@foreach (\App\Models\Business\CourierOnboarding::ENTITY_TYPES as $type => $label)
<label class="choice"><input type="radio" name="entity_type" value="{{ $type }}" @checked($entityType === $type) data-entity-type><span>{{ $label }}</span></label>
@endforeach
</div>
<span class="help" data-entity-only="company">有营业执照的公司或个体工商户选这个,以公司名义签署。</span>
<span class="help" data-entity-only="person">没有营业执照、以个人身份合作选这个,用本人身份证实名签署。</span>
</div>
<div class="field @error('company_full_name') has-error @enderror">
<label for="company_full_name">公司全称<span class="req">*</span></label>
<input id="company_full_name" name="company_full_name" type="text" maxlength="255" autocomplete="organization" value="{{ $value('company_full_name') }}" placeholder="如:常州某某快递服务有限公司">
<label for="company_full_name"><span data-entity-only="company">公司全称</span><span data-entity-only="person">姓名</span><span class="req">*</span></label>
<input id="company_full_name" name="company_full_name" type="text" maxlength="255" value="{{ $value('company_full_name') }}" data-placeholder-company="如:常州某某快递服务有限公司" data-placeholder-person="与身份证一致">
@error('company_full_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('id_number') has-error @enderror" data-entity-only="person">
<label for="id_number">身份证号<span class="req">*</span></label>
<input id="id_number" name="id_number" type="text" maxlength="18" autocapitalize="characters" autocomplete="off" value="{{ old('id_number', $onboarding->id_number) }}" placeholder="18 位">
@error('id_number')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2" data-entity-only="company">
<div class="field @error('unified_social_credit_code') has-error @enderror">
<label for="unified_social_credit_code">统一社会信用代码<span class="req">*</span></label>
<input id="unified_social_credit_code" name="unified_social_credit_code" type="text" maxlength="18" autocapitalize="characters" value="{{ $value('unified_social_credit_code') }}" placeholder="18 位">
@ -283,26 +396,74 @@
</div>
</section>
<section class="card" aria-labelledby="sec-settle">
<div class="card__head"><span class="card__index">3</span><h2 class="card__title" id="sec-settle">结算与开票</h2><span class="card__hint">运费打款账户</span></div>
<section class="card" aria-labelledby="sec-esign">
<div class="card__head"><span class="card__index">3</span><h2 class="card__title" id="sec-esign">e签宝电子签署</h2><span class="card__hint">报价确认单、合作协议都发到这里</span></div>
<div class="card__body">
<div class="field @error('bank_account_name') has-error @enderror">
<label for="bank_account_name">收款户名<span class="req">*</span></label>
<input id="bank_account_name" name="bank_account_name" type="text" maxlength="255" value="{{ $value('bank_account_name') }}" placeholder="一般与公司全称一致">
@error('bank_account_name')<span class="error">{{ $message }}</span>@enderror
</div>
<p class="help" style="margin:0">审核通过后,系统会按你填写的报价自动生成报价确认单,推送到下面这个 e签宝账号,签署人用该账号登录 e签宝(或点短信链接)即可盖章签署,无需再纸质盖章寄回。</p>
<div class="grid-2">
<div class="field @error('bank_name') has-error @enderror">
<label for="bank_name">开户行<span class="req">*</span></label>
<input id="bank_name" name="bank_name" type="text" maxlength="255" value="{{ $value('bank_name') }}" placeholder="写到支行">
@error('bank_name')<span class="error">{{ $message }}</span>@enderror
<div class="field @error('esign_operator_name') has-error @enderror" data-entity-only="company">
<label for="esign_operator_name">签署人姓名<span class="req">*</span></label>
<input id="esign_operator_name" name="esign_operator_name" type="text" maxlength="100" value="{{ $value('esign_operator_name') }}" placeholder="e签宝实名认证的姓名" data-default-from="contact_name">
@error('esign_operator_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('bank_account_number') has-error @enderror">
<label for="bank_account_number">银行账号<span class="req">*</span></label>
<input id="bank_account_number" name="bank_account_number" type="text" inputmode="numeric" maxlength="40" autocomplete="off" value="{{ old('bank_account_number', $onboarding->bank_account_number) }}">
@error('bank_account_number')<span class="error">{{ $message }}</span>@enderror
<div class="field @error('esign_account') has-error @enderror">
<label for="esign_account">e签宝账号<span class="req">*</span></label>
<input id="esign_account" name="esign_account" type="text" inputmode="email" maxlength="255" autocomplete="off" value="{{ $value('esign_account') }}" placeholder="注册 e签宝用的手机号或邮箱" data-default-from="contact_phone">
<span class="help">手机号会收到签署短信,邮箱会收到签署邮件</span>
@error('esign_account')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<p class="help" style="margin:0" data-entity-only="company">签署主体为上面填写的公司全称,签署人需有该公司在 e签宝的签章权限。</p>
<p class="help" style="margin:0" data-entity-only="person">个人入驻由本人签署,e签宝账号需用本人身份证完成实名认证。</p>
</div>
</section>
<section class="card" aria-labelledby="sec-settle">
<div class="card__head"><span class="card__index">4</span><h2 class="card__title" id="sec-settle">结算与开票</h2><span class="card__hint">运费打款账户(对公 / 对私)</span></div>
<div class="card__body">
<div class="account" data-account="corporate">
<p class="account__title">对公账户<span data-entity-only="company"><span class="req">*</span></span><span class="account__tag" data-entity-only="person">选填</span></p>
<div class="field @error('bank_account_name') has-error @enderror">
<label for="bank_account_name">对公户名</label>
<input id="bank_account_name" name="bank_account_name" type="text" maxlength="255" value="{{ $value('bank_account_name') }}" data-placeholder-company="须与营业执照公司全称一致" data-placeholder-person="有个体户或公司账户可填" data-account-name="corporate">
<span class="account__check" data-account-check="corporate" aria-live="polite"></span>
@error('bank_account_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('bank_name') has-error @enderror">
<label for="bank_name">开户行</label>
<input id="bank_name" name="bank_name" type="text" maxlength="255" value="{{ $value('bank_name') }}" placeholder="写到支行">
@error('bank_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('bank_account_number') has-error @enderror">
<label for="bank_account_number">对公账号</label>
<input id="bank_account_number" name="bank_account_number" type="text" inputmode="numeric" maxlength="40" autocomplete="off" value="{{ old('bank_account_number', $onboarding->bank_account_number) }}">
@error('bank_account_number')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</div>
<div class="account" data-account="private">
<p class="account__title">对私账户<span class="req">*</span></p>
<div class="field @error('private_account_name') has-error @enderror">
<label for="private_account_name">对私收款人</label>
<input id="private_account_name" name="private_account_name" type="text" maxlength="255" value="{{ $value('private_account_name') }}" data-placeholder-company="须为营业执照上的法定代表人" data-placeholder-person="须为本人" data-account-name="private">
<span class="account__check" data-account-check="private" aria-live="polite"></span>
@error('private_account_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('private_bank_name') has-error @enderror">
<label for="private_bank_name">开户行</label>
<input id="private_bank_name" name="private_bank_name" type="text" maxlength="255" value="{{ $value('private_bank_name') }}" placeholder="写到支行">
@error('private_bank_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('private_account_number') has-error @enderror">
<label for="private_account_number">银行卡号</label>
<input id="private_account_number" name="private_account_number" type="text" inputmode="numeric" maxlength="40" autocomplete="off" value="{{ old('private_account_number', $onboarding->private_account_number) }}">
@error('private_account_number')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<span class="help">这张银行卡的正反面请在下方「证件照片」上传。</span>
</div>
<div class="field @error('invoice_type') has-error @enderror">
<span class="label">开票类型<span class="req">*</span></span>
<div class="choices">
@ -332,18 +493,83 @@
</div>
</section>
<section class="card" aria-labelledby="sec-quote">
<div class="card__head"><span class="card__index">4</span><h2 class="card__title" id="sec-quote">报价</h2><span class="card__hint">按省份填写,单位:元</span></div>
@endunless
<section class="card" aria-labelledby="sec-quote" data-quote-section>
<div class="card__head"><span class="card__index">{{ $requote ? 1 : 5 }}</span><h2 class="card__title" id="sec-quote">报价</h2><span class="card__hint">单位:元</span></div>
<div class="card__body">
<div class="field @error('quote_mode') has-error @enderror">
<span class="label">计费方式<span class="req">*</span></span>
<div class="choices">
@foreach (\App\Models\Business\CourierOnboarding::QUOTE_MODES as $mode => $label)
<label class="choice"><input type="radio" name="quote_mode" value="{{ $mode }}" @checked($quoteMode === $mode) data-quote-mode><span>{{ $label }}</span></label>
<span class="label">① 计费方式<span class="req">*</span></span>
<div class="mode-list" role="radiogroup" aria-label="计费方式">
@foreach (\App\Support\CourierQuoteStructure::MODES as $mode => $label)
@continue($requote && $mode === 'other')
<label class="mode-option">
<input type="radio" name="quote_mode" value="{{ $mode }}" @checked($value('quote_mode') === $mode) data-quote-mode>
<span><strong>{{ $label }}</strong><small>{{ \App\Support\CourierQuoteStructure::MODE_HINTS[$mode] }}</small></span>
</label>
@endforeach
</div>
@error('quote_mode')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="structure" data-structure>
<span class="label" data-show-for="waybill_rate first_additional flat_bands flat_then">② 计费结构</span>
<div class="field @error('quote_flat_bands') has-error @enderror" data-show-for="flat_bands flat_then">
<span class="label">一口价重量档<span class="req">*</span></span>
<input id="quote_flat_bands" name="quote_flat_bands" type="hidden" value="{{ old('quote_flat_bands', $kgList($structure['flat_bands'])) }}" data-band-value>
<div class="band-list" data-band-list></div>
<button type="button" class="btn band-add" data-band-add>+ 添加一档</button>
<span class="help">每档填上限重量,含上限。例如依次填 1、2、3,就是 0-1kg(含1)、1-2kg(含2)、2-3kg(含3) 三档。</span>
<span class="help" data-show-for="flat_bands">超过最后一档还要按「面单+费率×重量」或「首重+续重」计费的,请改选上面的「小重量一口价 + 超出另算」。</span>
@error('quote_flat_bands')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field" data-show-for="flat_then">
<span class="label">超出一口价部分怎么算<span class="req">*</span></span>
<div class="choices">
<label class="choice"><input type="radio" name="quote_then_mode" value="waybill_rate" @checked(old('quote_then_mode', $structure['then_mode'] ?: 'waybill_rate') === 'waybill_rate') data-structure-input><span>面单 + 费率×重量</span></label>
<label class="choice"><input type="radio" name="quote_then_mode" value="first_additional" @checked(old('quote_then_mode', $structure['then_mode']) === 'first_additional') data-structure-input><span>首重 + 续重</span></label>
</div>
</div>
<div class="field @error('quote_first_weight') has-error @enderror" data-show-for="first_additional flat_then:first_additional">
<label for="quote_first_weight">首重公斤数<span class="req">*</span></label>
<input id="quote_first_weight" name="quote_first_weight" type="number" inputmode="decimal" min="0" step="0.01" value="{{ old('quote_first_weight', $structure['first_weight'] !== null ? \App\Support\CourierQuoteStructure::kg($structure['first_weight']) : '') }}" placeholder="如 1、3、5、20" data-structure-input>
@error('quote_first_weight')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('quote_rate_tiers') has-error @enderror" data-show-for="waybill_rate first_additional flat_then">
<label for="quote_rate_tiers"><span data-rate-name>费率</span>按重量分段(选填,kg)</label>
<input id="quote_rate_tiers" name="quote_rate_tiers" type="text" inputmode="decimal" maxlength="200" value="{{ old('quote_rate_tiers', $kgList($structure['rate_tiers'])) }}" placeholder="不分段就留空;如 35,50" data-structure-input>
<span class="help">「35,50」表示 0-35kg、35-50kg、50kg以上三档,按总重量所在区间取价</span>
@error('quote_rate_tiers')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div data-show-for="waybill_rate first_additional flat_bands flat_then">
<span class="label">③ 各省价格</span>
<div class="quote-tools" data-fill-tools></div>
<p class="help" style="margin:6px 0 10px">全国统一价先在上面填好,点「填充到全部省份」(会覆盖已填的价格),个别省份再单独改;不发的省份整行留空。{{ $requote ? '审核通过后会生成新的报价确认单推送给快递签署,请仔细核对。' : '这份报价会直接生成报价确认单发给你签署,请仔细核对。' }}</p>
@error('quote_rows')<span class="error">{{ $message }}</span>@enderror
<p class="help" style="margin:0 0 6px" data-scroll-hint>列较多时左右滑动表格查看,省份一列固定不动</p>
<div class="quote-table-wrap">
<table class="quote-table"><thead data-quote-head></thead><tbody data-quote-body></tbody></table>
</div>
<script type="application/json" data-quote-rows>{!! json_encode($quoteRows, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}</script>
</div>
<div class="upload @error('files.quote_sheet') has-error @enderror" data-show-for="other" data-upload>
<div class="upload__head"><span class="upload__title">报价表<span class="req">*</span></span></div>
<label class="upload__drop">
<span class="upload__preview" data-upload-preview>
@if ($quoteSheet)
<span class="upload__done">已上传</span><span class="upload__name">{{ $quoteSheet->original_name }}</span>
@else
<span class="upload__plus" aria-hidden="true">+</span><span class="upload__name">上传 Excel、PDF 或照片</span>
@endif
</span>
@if ($editable)<input type="file" name="files[quote_sheet][]" accept="image/*,.pdf,.xls,.xlsx" aria-label="上传报价表" data-upload-input>@endif
</label>
@error('files.quote_sheet')<span class="error">{{ $message }}</span>@enderror
</div>
<span class="label">④ 附加规则</span>
<div class="grid-2">
<div class="field @error('volumetric_rule') has-error @enderror">
<span class="label">是否计泡<span class="req">*</span></span>
@ -355,90 +581,115 @@
@error('volumetric_rule')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('volumetric_divisor') has-error @enderror" data-volumetric-divisor>
<label for="volumetric_divisor">抛比系数</label>
<label for="volumetric_divisor">计泡系数</label>
<input id="volumetric_divisor" name="volumetric_divisor" type="number" inputmode="numeric" min="1000" max="20000" step="1" value="{{ $value('volumetric_divisor') }}" placeholder="长×宽×高 ÷ 6000">
@error('volumetric_divisor')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div class="field @error('quote_effective_date') has-error @enderror">
<label for="quote_effective_date">报价执行日期<span class="req">*</span></label>
<input id="quote_effective_date" name="quote_effective_date" type="date" value="{{ old('quote_effective_date', $onboarding->quote_effective_date?->format('Y-m-d')) }}">
@error('quote_effective_date')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="quote-tools" aria-label="一键填充">
<div class="field"><label for="fill-weight" data-col-label="first_weight">首重(kg)</label><input id="fill-weight" type="number" inputmode="decimal" step="0.01" data-fill="first_weight"></div>
<div class="field"><label for="fill-price" data-col-label="first_price">面单</label><input id="fill-price" type="number" inputmode="decimal" step="0.01" data-fill="first_price"></div>
<div class="field"><label for="fill-rate" data-col-label="rate">费率(元/kg)</label><input id="fill-rate" type="number" inputmode="decimal" step="0.01" data-fill="rate"></div>
<button type="button" class="btn" data-fill-all>填充空白省份</button>
</div>
<p class="help" style="margin:-6px 0 0">全国统一价可先在上面填好再点「填充空白省份」,个别省份再单独改;不发的省份留空即可。</p>
<div class="quote-table-wrap">
<table class="quote-table">
<thead><tr><th>省份</th><th data-col-label="first_weight">首重(kg)</th><th data-col-label="first_price">面单</th><th data-col-label="rate">费率(元/kg)</th></tr></thead>
<tbody>
@foreach ($quoteRows as $i => $row)
<tr class="{{ $row['fixed'] ? '' : 'is-custom' }}">
<td>
@if ($row['fixed'])
{{ $row['region'] }}<input type="hidden" name="quote_rows[{{ $i }}][region]" value="{{ $row['region'] }}">
@else
<input type="text" name="quote_rows[{{ $i }}][region]" value="{{ $row['region'] }}" maxlength="50" placeholder="其他/重量段" aria-label="自定义区域">
@endif
</td>
<td><input type="number" inputmode="decimal" step="0.01" min="0" name="quote_rows[{{ $i }}][first_weight]" value="{{ $num($row['first_weight']) }}" data-cell="first_weight" aria-label="{{ $row['region'] ?: '自定义' }}首重"></td>
<td><input type="number" inputmode="decimal" step="0.01" min="0" name="quote_rows[{{ $i }}][first_price]" value="{{ $num($row['first_price']) }}" data-cell="first_price" aria-label="{{ $row['region'] ?: '自定义' }}价格"></td>
<td><input type="number" inputmode="decimal" step="0.01" min="0" name="quote_rows[{{ $i }}][rate]" value="{{ $num($row['rate']) }}" data-cell="rate" aria-label="{{ $row['region'] ?: '自定义' }}费率"></td>
</tr>
<div class="grid-2">
<div class="field @error('min_weight') has-error @enderror">
<label for="min_weight">起算重量(kg,选填)</label>
<input id="min_weight" name="min_weight" type="number" inputmode="decimal" min="0" step="0.01" value="{{ $num($value('min_weight')) }}" placeholder="如不足 10kg 按 10kg 算,填 10">
</div>
<div class="field">
<label for="weight_rounding">重量取整</label>
<select id="weight_rounding" name="weight_rounding">
@foreach (\App\Support\CourierQuoteStructure::ROUNDING as $key => $label)
<option value="{{ $key }}" @selected($value('weight_rounding', 'none') === $key)>{{ $label }}</option>
@endforeach
</tbody>
</table>
</select>
</div>
</div>
<div class="grid-2">
<div class="field @error('per_ticket_surcharge') has-error @enderror">
<label for="per_ticket_surcharge">每票固定加收(元,选填)</label>
<input id="per_ticket_surcharge" name="per_ticket_surcharge" type="number" inputmode="decimal" min="0" step="0.01" value="{{ $num($value('per_ticket_surcharge')) }}" placeholder="如每票 +1 元,填 1">
</div>
<div class="field @error('quote_effective_date') has-error @enderror">
<label for="quote_effective_date">报价执行日期<span class="req">*</span></label>
<input id="quote_effective_date" name="quote_effective_date" type="date" value="{{ old('quote_effective_date', $onboarding->quote_effective_date?->format('Y-m-d')) }}">
@error('quote_effective_date')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
@if (! empty($requote))
<div class="grid-2">
<div class="field @error('quote_end_date') has-error @enderror">
<label for="quote_end_date">结束日期(选填 · 短期变价)</label>
<input id="quote_end_date" name="quote_end_date" type="date" value="{{ old('quote_end_date', ($requote['change'] ?? null)?->end_date?->format('Y-m-d')) }}">
<span class="help">留空 = 长期执行。填了就是临时价:只在执行日期~结束日期期间用,结束后第二天自动恢复原来的长期报价,快递不用再签。</span>
@error('quote_end_date')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
@endif
<div class="field">
<label for="quote_note">报价备注</label>
<textarea id="quote_note" name="quote_note" maxlength="2000" placeholder="如:超长、限重、偏远地区加价、上楼费等特殊约定">{{ $value('quote_note') }}</textarea>
<textarea id="quote_note" name="quote_note" maxlength="2000" placeholder="如:限长限重、超长费、上楼费、偏远地区加价、深圳等城市单独价格">{{ $value('quote_note') }}</textarea>
</div>
</div>
</section>
<section class="card" aria-labelledby="sec-files">
<div class="card__head"><span class="card__index">5</span><h2 class="card__title" id="sec-files">上传资料</h2><span class="card__hint">可直接拍照,单个 ≤ 10MB</span></div>
@if ($requote)
<section class="card" aria-labelledby="sec-summary">
<div class="card__head"><span class="card__index">2</span><h2 class="card__title" id="sec-summary">变价说明</h2></div>
<div class="card__body">
<div class="field @error('summary') has-error @enderror">
<label for="summary">变价原因 / 依据<span class="req">*</span></label>
<textarea id="summary" name="summary" maxlength="2000" placeholder="如:快递通知 11 月起江浙沪面单上调 0.3 元;改为 0-1、1-2、2-3kg 三档一口价">{{ $requote['summary'] }}</textarea>
@error('summary')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</section>
@else
<section class="card" aria-labelledby="sec-files">
<div class="card__head"><span class="card__index">6</span><h2 class="card__title" id="sec-files">上传证件照片</h2><span class="card__hint">可直接拍照,系统会自动压缩</span></div>
<div class="card__body">
<div class="upload-grid">
@foreach ($fileKinds as $kind => $label)
@php $existing = $filesByKind->get($kind, collect()); @endphp
<div class="upload @error('files.' . $kind) has-error @enderror">
@php $existing = $filesByKind->get($kind, collect())->last(); @endphp
<div class="upload @error('files.' . $kind) has-error @enderror" data-upload>
<div class="upload__head">
<span class="upload__title">{{ $label }}@if (in_array($kind, $requiredKinds, true))<span class="req">*</span>@endif</span>
@if ($existing->isNotEmpty())<span class="help">已上传 {{ $existing->count() }} 个</span>@endif
<span class="upload__title">
<span data-entity-only="company">{{ $label }}@if (in_array($kind, $requiredKinds, true))<span class="req">*</span>@endif</span>
<span data-entity-only="person">{{ $personKinds[$kind] ?? $label }}@if (in_array($kind, $personRequiredKinds, true))<span class="req">*</span>@endif</span>
</span>
</div>
@if ($existing->isNotEmpty())
<ul class="file-list">
@foreach ($existing as $file)
<li>
<span class="name">{{ $file->original_name }}</span>
<span class="size">{{ $file->sizeLabel() }}</span>
@if ($editable)<label><input type="checkbox" name="remove_files[]" value="{{ $file->id }}">删除</label>@endif
</li>
@endforeach
</ul>
<label class="upload__drop">
<span class="upload__preview" data-upload-preview>
@if ($existing)
<span class="upload__done">已上传</span><span class="upload__name">{{ $existing->original_name }}</span>
@else
<span class="upload__plus" aria-hidden="true">+</span><span class="upload__name">拍照或选择图片</span>
@endif
</span>
@if ($editable)
<input type="file" name="files[{{ $kind }}][]" accept="image/*,.pdf" aria-label="上传{{ $label }}" data-upload-input>
@endif
</label>
@if ($existing && $editable && ! in_array($kind, $requiredKinds, true))
<label class="upload__remove"><input type="checkbox" name="remove_files[]" value="{{ $existing->id }}">删除已上传</label>
@endif
@if ($editable)
<input type="file" name="files[{{ $kind }}][]" multiple accept="image/*,.pdf,.xls,.xlsx,.doc,.docx" aria-label="上传{{ $label }}">
@endif
@if ($kind === 'quote_sheet')<span class="help">请上传盖公章的报价单(照片、PDF 或 Excel)。</span>@endif
@error('files.' . $kind)<span class="error">{{ $message }}</span>@enderror
</div>
@endforeach
</div>
<p class="help" style="margin:0" data-upload-total></p>
<div class="field">
<label for="remark">其他说明</label>
<textarea id="remark" name="remark" maxlength="2000">{{ $value('remark') }}</textarea>
</div>
</div>
</section>
@endif
</fieldset>
@if ($editable)
@if ($requote)
<div class="actionbar">
<div class="actionbar__inner">
<a class="btn" href="{{ $requote['back'] }}">返回</a>
<button type="submit" class="btn btn--primary" data-submit-button>提交审核</button>
</div>
</div>
@elseif ($editable)
<div class="actionbar">
<div class="actionbar__inner">
<button type="submit" name="action" value="save" class="btn" data-submit-button>暂存</button>
@ -448,46 +699,316 @@
@endif
</form>
@unless ($requote)
<p class="footer-note">本页面仅用于快递合作入驻,资料仅用于合作审核与结算。<br>如有疑问请联系我方对接人。</p>
@endunless
</main>
<script>
(function () {
var form = document.querySelector('[data-onboarding-form]');
if (!form) return;
var labels = {
waybill_rate: { first_weight: '首重(kg)', first_price: '面单', rate: '费率(元/kg)' },
first_additional: { first_weight: '首重(kg)', first_price: '首重价', rate: '续重(元/kg)' },
flat: { first_weight: '重量上限(kg)', first_price: '一口价', rate: '超出续重(元/kg)' }
};
function applyMode() {
var checked = form.querySelector('[data-quote-mode]:checked');
var map = labels[checked ? checked.value : 'waybill_rate'] || labels.waybill_rate;
form.querySelectorAll('[data-col-label]').forEach(function (el) { el.textContent = map[el.getAttribute('data-col-label')]; });
}
function applyVolumetric() {
var checked = form.querySelector('[data-volumetric-rule]:checked');
var box = form.querySelector('[data-volumetric-divisor]');
if (box) box.style.display = checked && checked.value === '计泡' ? '' : 'none';
}
form.querySelectorAll('[data-quote-mode]').forEach(function (el) { el.addEventListener('change', applyMode); });
form.querySelectorAll('[data-volumetric-rule]').forEach(function (el) { el.addEventListener('change', applyVolumetric); });
applyMode();
applyVolumetric();
function applyEntity() {
var checked = form.querySelector('[data-entity-type]:checked');
var type = checked ? checked.value : 'company';
form.setAttribute('data-entity', type);
form.querySelectorAll('[data-entity-only]').forEach(function (el) {
var show = el.getAttribute('data-entity-only') === type;
el.querySelectorAll('input, select, textarea').forEach(function (input) { if (!input.closest('fieldset[disabled]')) input.disabled = !show; });
});
form.querySelectorAll('[data-placeholder-' + type + ']').forEach(function (el) { el.placeholder = el.getAttribute('data-placeholder-' + type); });
}
form.querySelectorAll('[data-entity-type]').forEach(function (el) { el.addEventListener('change', applyEntity); });
form.querySelectorAll('[data-choose-entity]').forEach(function (button) {
button.addEventListener('click', function () {
var radio = form.querySelector('[data-entity-type][value="' + button.getAttribute('data-choose-entity') + '"]');
if (radio) { radio.checked = true; }
applyEntity();
window.scrollTo(0, 0);
});
});
if (form.querySelector('[data-entity-type]:checked')) applyEntity();
var fillButton = form.querySelector('[data-fill-all]');
if (fillButton) fillButton.addEventListener('click', function () {
['first_weight', 'first_price', 'rate'].forEach(function (key) {
var source = form.querySelector('[data-fill="' + key + '"]');
if (!source || source.value === '') return;
form.querySelectorAll('tr:not(.is-custom) [data-cell="' + key + '"]').forEach(function (cell) {
if (cell.value === '') cell.value = source.value;
// 手机原图往往 3-8MB,上传前在本机压缩到 1600px 左右的 JPEG,避免超过服务器上传上限。
var MAX_TOTAL = 11 * 1024 * 1024;
function compress(file) {
return new Promise(function (resolve) {
if (!/^image\//.test(file.type) || file.size < 600 * 1024) { resolve(file); return; }
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
var scale = Math.min(1, 1600 / Math.max(img.width, img.height));
var canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) {
if (!blob || blob.size >= file.size) { resolve(file); return; }
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.82);
};
img.onerror = function () { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
function totalSize() {
var total = 0;
form.querySelectorAll('[data-upload-input]').forEach(function (input) {
Array.prototype.forEach.call(input.files || [], function (f) { total += f.size; });
});
return total;
}
function refreshTotal() {
var note = form.querySelector('[data-upload-total]');
var total = totalSize();
if (note) note.textContent = total > 0 ? '本次待上传 ' + (total / 1048576).toFixed(1) + ' MB' + (total > MAX_TOTAL ? ',超过 11MB,请分两次暂存上传' : '') : '';
}
form.querySelectorAll('[data-upload-input]').forEach(function (input) {
input.addEventListener('change', function () {
var file = input.files && input.files[0];
var preview = input.closest('[data-upload]').querySelector('[data-upload-preview]');
if (!file) { refreshTotal(); return; }
preview.innerHTML = '<span class="upload__name">处理中…</span>';
compress(file).then(function (result) {
if (result !== file && window.DataTransfer) {
try { var dt = new DataTransfer(); dt.items.add(result); input.files = dt.files; } catch (e) { result = file; }
}
if (/^image\//.test(result.type)) {
var img = document.createElement('img');
img.src = URL.createObjectURL(result);
img.alt = '';
preview.innerHTML = '';
preview.appendChild(img);
} else {
preview.innerHTML = '<span class="upload__done">已选择</span><span class="upload__name"></span>';
preview.querySelector('.upload__name').textContent = result.name;
}
refreshTotal();
});
});
});
form.querySelectorAll('[data-default-from]').forEach(function (target) {
var source = form.querySelector('[name="' + target.getAttribute('data-default-from') + '"]');
if (!source) return;
source.addEventListener('change', function () { if (target.value === '') target.value = source.value; });
});
form.querySelectorAll('[data-volumetric-rule]').forEach(function (el) { el.addEventListener('change', applyVolumetric); });
applyVolumetric();
// ---- 打款账户:对公户名 = 公司全称;对私收款人 = 法定代表人(个人为本人) ----
function normName(v) { return String(v || '').replace(/(/g, '(').replace(/)/g, ')').replace(/[\s ]+/g, '').toUpperCase(); }
function checkAccounts() {
var person = form.getAttribute('data-entity') === 'person';
var company = (form.querySelector('[name=company_full_name]') || {}).value || '';
var legal = (form.querySelector('[name=legal_representative]') || {}).value || '';
[['corporate', person ? '' : company, '营业执照公司全称'], ['private', person ? company : legal, person ? '本人姓名' : '营业执照法定代表人']].forEach(function (item) {
var input = form.querySelector('[data-account-name="' + item[0] + '"]');
var note = form.querySelector('[data-account-check="' + item[0] + '"]');
if (!input || !note) return;
note.className = 'account__check';
if (!input.value.trim() || !item[1].trim()) { note.textContent = ''; return; }
var ok = normName(input.value) === normName(item[1]);
note.classList.add(ok ? 'is-ok' : 'is-bad');
note.textContent = ok ? '✓ 与' + item[2] + '一致' : '✗ 与' + item[2] + '「' + item[1].trim() + '」不一致,提交时会被拦下';
});
}
['company_full_name', 'legal_representative', 'bank_account_name', 'private_account_name'].forEach(function (name) {
var el = form.querySelector('[name=' + name + ']');
if (el) el.addEventListener('input', checkAccounts);
});
form.querySelectorAll('[data-entity-type], [data-choose-entity]').forEach(function (el) { el.addEventListener(el.tagName === 'BUTTON' ? 'click' : 'change', checkAccounts); });
checkAccounts();
// ---- 报价表:按计费方式和重量结构动态生成列 ----
var quoteSection = form.querySelector('[data-quote-section]');
var rowsScript = form.querySelector('[data-quote-rows]');
var model = rowsScript ? JSON.parse(rowsScript.textContent || '[]') : [];
var editableForm = !form.querySelector('fieldset[disabled]');
function kg(v) { return String(Math.round(v * 100) / 100); }
function parseList(text) {
var seen = {}, out = [];
String(text || '').split(/[\s,,、;;\/]+/).forEach(function (p) {
p = p.replace(/(kg|KG|公斤)$/, '');
var n = parseFloat(p);
if (!isNaN(n) && n > 0 && !seen[n]) { seen[n] = true; out.push(Math.round(n * 100) / 100); }
});
return out.sort(function (a, b) { return a - b; });
}
function structure() {
var mode = (form.querySelector('[data-quote-mode]:checked') || {}).value || '';
var thenMode = (form.querySelector('[name=quote_then_mode]:checked') || {}).value || 'waybill_rate';
return {
mode: mode,
thenMode: mode === 'flat_then' ? thenMode : '',
bands: (mode === 'flat_bands' || mode === 'flat_then') ? parseList(form.querySelector('[name=quote_flat_bands]').value).slice(0, 12) : [],
firstWeight: parseFloat(form.querySelector('[name=quote_first_weight]').value) || 0,
tiers: (mode === 'waybill_rate' || mode === 'first_additional' || mode === 'flat_then') ? parseList(form.querySelector('[name=quote_rate_tiers]').value).slice(0, 6) : []
};
}
function columns(st) {
var cols = [];
if (!st.mode || st.mode === 'other') return cols;
var lower = 0;
st.bands.forEach(function (upper, i) { cols.push({ key: 'flat', index: i, slot: 'flat:' + lower + '-' + upper, label: kg(lower) + '-' + kg(upper) + 'kg(含' + kg(upper) + ') 一口价' }); lower = upper; });
if (st.mode === 'flat_bands') return cols;
var first = st.mode === 'first_additional' || st.thenMode === 'first_additional';
var prefix = st.mode === 'flat_then' && st.bands.length ? kg(st.bands[st.bands.length - 1]) + 'kg 以上 ' : '';
cols.push({ key: 'base', slot: 'base', label: prefix + (first ? '首重' + kg(st.firstWeight) + 'kg(元)' : '面单') });
var name = first ? '续重' : '费率';
if (!st.tiers.length) { cols.push({ key: 'rates', index: 0, slot: 'rates:0', label: name + '(元/kg)' }); return cols; }
var start = st.mode === 'flat_then' && st.bands.length ? st.bands[st.bands.length - 1] : 0;
st.tiers.forEach(function (edge, i) { cols.push({ key: 'rates', index: i, slot: 'rates:' + i, label: name + ' ' + kg(i === 0 ? start : st.tiers[i - 1]) + '-' + kg(edge) + 'kg' }); });
cols.push({ key: 'rates', index: st.tiers.length, slot: 'rates:' + st.tiers.length, label: name + ' ' + kg(st.tiers[st.tiers.length - 1]) + 'kg以上' });
return cols;
}
// 每个价格按「计费方式 + 列含义」单独存放:换了计费方式,面单不会跑到首重价那一列;切回来原来填的还在。
function slotKey(st, col) { return st.mode + '|' + st.thenMode + '|' + col.slot; }
function readTable() {
if (!quoteSection) return;
quoteSection.querySelectorAll('[data-q-row]').forEach(function (tr) {
var row = model[+tr.getAttribute('data-q-row')];
var region = tr.querySelector('[data-q-region]');
if (region && region.type === 'text') row.region = region.value;
tr.querySelectorAll('[data-q-slot]').forEach(function (input) { row.vals[input.getAttribute('data-q-slot')] = input.value; });
});
}
function esc(t) { return String(t).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
function nameFor(i, col) { return 'quote_rows[' + i + '][' + col.key + ']' + (col.key === 'base' ? '' : '[' + col.index + ']'); }
// ---- 一口价重量档:逐档添加 / 删除,写回隐藏字段 quote_flat_bands ----
var bandValue = form.querySelector('[data-band-value]');
var bandList = form.querySelector('[data-band-list]');
var bandAdd = form.querySelector('[data-band-add]');
function bandInputs() { return bandList ? Array.prototype.slice.call(bandList.querySelectorAll('[data-band-input]')) : []; }
function syncBands() {
var inputs = bandInputs(), prev = '0';
bandValue.value = inputs.map(function (el) { return el.value; }).filter(function (v) { return v !== ''; }).join(',');
inputs.forEach(function (el, i) {
var row = el.closest('[data-band-row]');
row.querySelector('[data-band-no]').textContent = '第' + (i + 1) + '档';
row.querySelector('[data-band-lower]').textContent = prev + ' -';
prev = el.value !== '' ? el.value : '?';
});
}
function buildBands(values) {
var dis = editableForm ? '' : ' disabled';
bandList.innerHTML = values.map(function (v) {
return '<div class="band-row" data-band-row><span class="band-row__no" data-band-no></span><span data-band-lower></span>'
+ '<input type="number" inputmode="decimal" min="0" step="0.01" value="' + esc(v) + '" placeholder="上限" aria-label="本档上限重量" data-band-input' + dis + '>'
+ '<span>kg(含)</span>'
+ (editableForm ? '<button type="button" class="band-row__del" aria-label="删除这一档" data-band-del>×</button>' : '<span></span>') + '</div>';
}).join('');
bandInputs().forEach(function (el) { el.addEventListener('input', function () { syncBands(); readTable(); render(); }); });
bandList.querySelectorAll('[data-band-del]').forEach(function (btn) {
btn.addEventListener('click', function () {
var values = bandInputs().map(function (el) { return el.value; });
values.splice(bandInputs().indexOf(btn.closest('[data-band-row]').querySelector('[data-band-input]')), 1);
buildBands(values.length ? values : ['']);
syncBands(); readTable(); render();
});
});
syncBands();
}
if (bandList && bandValue) {
var initialBands = parseList(bandValue.value).map(kg);
buildBands(initialBands.length ? initialBands : ['']);
if (bandAdd) {
if (!editableForm) bandAdd.style.display = 'none';
bandAdd.addEventListener('click', function () {
var values = bandInputs().map(function (el) { return el.value; });
if (values.length >= 12) return;
values.push('');
buildBands(values);
var inputs = bandInputs();
inputs[inputs.length - 1].focus();
});
}
}
// 页面带回的已填报价(服务端按当前结构的 flat[] / base / rates[])转成按列含义存放
(function () {
var st0 = structure(), cols0 = columns(st0);
model.forEach(function (row) {
row.vals = {};
cols0.forEach(function (c) {
var v = c.key === 'base' ? row.base : (row[c.key] || [])[c.index];
if (v !== null && v !== undefined && v !== '') row.vals[slotKey(st0, c)] = String(v);
});
});
})();
function render() {
if (!quoteSection) return;
var st = structure();
quoteSection.setAttribute('data-mode', st.mode || 'none');
quoteSection.querySelectorAll('[data-show-for]').forEach(function (el) {
var tokens = el.getAttribute('data-show-for').split(' ');
var show = tokens.some(function (t) {
var parts = t.split(':');
return parts[0] === st.mode && (!parts[1] || parts[1] === st.thenMode);
});
el.style.display = show ? '' : 'none';
});
var rateName = quoteSection.querySelector('[data-rate-name]');
if (rateName) rateName.textContent = (st.mode === 'first_additional' || st.thenMode === 'first_additional') ? '续重' : '费率';
var cols = columns(st);
var head = quoteSection.querySelector('[data-quote-head]');
var body = quoteSection.querySelector('[data-quote-body]');
var tools = quoteSection.querySelector('[data-fill-tools]');
if (!head || !body) return;
var dis = editableForm ? '' : ' disabled';
head.innerHTML = '<tr><th>省份</th>' + cols.map(function (c) { return '<th>' + esc(c.label) + '</th>'; }).join('') + '</tr>';
body.innerHTML = model.map(function (row, i) {
var regionCell = row.fixed
? esc(row.region) + '<input type="hidden" name="quote_rows[' + i + '][region]" value="' + esc(row.region) + '" data-q-region>'
: '<input type="text" name="quote_rows[' + i + '][region]" value="' + esc(row.region || '') + '" maxlength="50" placeholder="其他地区" aria-label="自定义地区" data-q-region' + dis + '>';
return '<tr data-q-row="' + i + '"' + (row.fixed ? '' : ' class="is-custom"') + '><td>' + regionCell + '</td>' + cols.map(function (c) {
var slot = slotKey(st, c), v = row.vals[slot];
return '<td><input type="number" inputmode="decimal" step="0.01" min="0" name="' + nameFor(i, c) + '" value="' + esc(v === undefined ? '' : v) + '" data-q-slot="' + esc(slot) + '" aria-label="' + esc((row.region || '自定义') + c.label) + '"' + dis + '></td>';
}).join('') + '</tr>';
}).join('');
if (tools) {
tools.style.gridTemplateColumns = 'repeat(' + Math.min(Math.max(cols.length, 1), 3) + ', minmax(0, 1fr))';
tools.innerHTML = cols.map(function (c, i) {
return '<div class="field"><label for="fill-' + i + '">' + esc(c.label) + '</label><input id="fill-' + i + '" type="number" inputmode="decimal" step="0.01" data-fill-col="' + i + '"' + dis + '></div>';
}).join('') + (editableForm && cols.length ? '<button type="button" class="btn" data-fill-all style="grid-column:1/-1">填充到全部省份</button>' : '');
var fill = tools.querySelector('[data-fill-all]');
if (fill) fill.addEventListener('click', function () {
var filled = 0;
tools.querySelectorAll('[data-fill-col]').forEach(function (src) {
if (src.value === '') return;
filled++;
var slot = slotKey(st, cols[+src.getAttribute('data-fill-col')]);
body.querySelectorAll('tr:not(.is-custom) [data-q-slot]').forEach(function (cell) {
if (cell.getAttribute('data-q-slot') === slot) cell.value = src.value;
});
});
readTable();
if (!filled) window.alert('先在上面填好要统一的价格,再点填充。');
});
}
}
if (quoteSection) {
quoteSection.querySelectorAll('[data-quote-mode], [data-structure-input]').forEach(function (el) {
var typing = el.type === 'text' || el.type === 'number';
el.addEventListener(typing ? 'input' : 'change', function () { readTable(); render(); });
});
render();
}
form.addEventListener('submit', function (event) {
var submitter = event.submitter;
if (totalSize() > MAX_TOTAL) {
event.preventDefault();
window.alert('本次上传的图片总共超过 11MB,请先选几张点「暂存」,再继续上传剩下的。');
return;
}
if (submitter && submitter.value === 'submit' && !window.confirm('提交后需等待我方审核,审核期间不能修改。确认提交?')) {
event.preventDefault();
return;

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Business\AccountMappingActions;
use App\Http\Controllers\Business\CourierProfiles;
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use App\Http\Controllers\Business\CourierAgreementSignatures;
@ -135,12 +136,16 @@ Route::prefix('workspace/courier-payables/quote-management/changes')
Route::post('{quoteChange}/submit', [CourierQuoteChanges::class, 'submit'])->name('submit');
Route::post('{quoteChange}/approve', [CourierQuoteChanges::class, 'approve'])->name('approve');
Route::post('{quoteChange}/return', [CourierQuoteChanges::class, 'returnForChanges'])->name('return');
Route::post('{quoteChange}/push-courier', [CourierQuoteChanges::class, 'pushToCourier'])->name('push-courier');
Route::post('{quoteChange}/void', [CourierQuoteChanges::class, 'void'])->name('void');
});
Route::prefix('workspace/courier-payables/quote-management/signatures')
->name('business.courier-quote-signatures.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::get('export', [CourierQuoteSignatures::class, 'export'])->name('export');
Route::get('versions/{quoteVersion}/sheet', [CourierQuoteSignatures::class, 'sheet'])->name('sheet');
Route::post('versions/{quoteVersion}/prepare', [CourierQuoteSignatures::class, 'prepare'])->name('prepare');
Route::post('{quoteSignature}/send', [CourierQuoteSignatures::class, 'send'])->name('send');
Route::post('{quoteSignature}/synchronize', [CourierQuoteSignatures::class, 'synchronize'])->name('synchronize');
@ -161,8 +166,14 @@ Route::prefix('workspace/courier-payables/courier-onboarding/applications')
Route::post('{onboarding}/cancel', [CourierOnboardings::class, 'cancel'])->name('cancel');
Route::post('{onboarding}/extend', [CourierOnboardings::class, 'extend'])->name('extend');
Route::post('{onboarding}/internal', [CourierOnboardings::class, 'updateInternal'])->name('internal');
Route::post('{onboarding}/erp-opening', [CourierOnboardings::class, 'erpOpening'])->name('erp-opening');
Route::post('{onboarding}/final-approve', [CourierOnboardings::class, 'finalApprove'])->name('final-approve');
Route::post('{onboarding}/activate', [CourierOnboardings::class, 'activate'])->name('activate');
Route::post('{onboarding}/push-quote', [CourierOnboardings::class, 'pushQuote'])->name('push-quote');
Route::post('{onboarding}/sync-quote', [CourierOnboardings::class, 'syncQuote'])->name('sync-quote');
Route::post('{onboarding}/sign-url', [CourierOnboardings::class, 'signUrl'])->name('sign-url');
Route::get('{onboarding}/requote', [CourierOnboardings::class, 'requote'])->name('requote');
Route::post('{onboarding}/requote', [CourierOnboardings::class, 'storeRequote'])->name('requote.store');
});
Route::prefix('workspace/courier-payables/agreement-signatures')
@ -251,6 +262,8 @@ Route::prefix('workspace/financial-risk-control/store-violation-alert')
Route::get('template', 'Business\Pages@storeViolationTemplate')->name('template');
Route::post('import/preview', 'Business\Pages@previewStoreViolationImport')->middleware('import')->name('import.preview');
Route::post('import/commit', 'Business\Pages@commitStoreViolationImport')->middleware('import')->name('import.commit');
Route::post('update', 'Business\Pages@updateStoreViolation')->name('update');
Route::get('claim/{record}', 'Business\Pages@claimStoreViolation')->name('claim');
});
Route::prefix('workspace/supplier-payables/product-prices')
@ -355,6 +368,15 @@ Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages
->middleware('business.page:financial-operations,small-payment-work-orders')
->name('business.refunds.small-payment-work-orders.export');
Route::prefix('workspace/courier-payables/courier-information/profiles')
->name('business.courier-profiles.')
->middleware('business.page:courier-payables,courier-information')
->group(function (): void {
Route::post('payment-accounts', [CourierProfiles::class, 'storePaymentAccountForCode'])->name('payment-accounts.store-any');
Route::post('{expcode}', [CourierProfiles::class, 'update'])->name('update')->where('expcode', '[0-9A-Za-z_\\-]+');
Route::post('{expcode}/payment-accounts', [CourierProfiles::class, 'storePaymentAccount'])->name('payment-accounts.store')->where('expcode', '[0-9A-Za-z_\\-]+');
});
Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation')
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');
@ -375,6 +397,10 @@ Route::get('workspace/courier-payables/return-pickup-management/detail', 'Busine
->middleware('business.page:courier-payables,return-pickup-management')
->name('business.courier-payables.return-pickup-management.detail');
Route::get('workspace/courier-payables/payable-cloud-warehouse-vehicle-fee/attachment', 'Business\Pages@downloadDingPayableAttachment')
->middleware('business.page:courier-payables,payable-cloud-warehouse-vehicle-fee')
->name('business.courier-payables.ding-payable-attachment.download');
// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。
Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement')
->middleware('business.page:courier-payables,courier-surcharge')

View File

@ -31,6 +31,7 @@ class CourierInformationPageTest extends FeatureTestCase
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'courier_tab' => 'express',
]))
->assertOk()
->assertSeeText('快递资料')
@ -78,6 +79,7 @@ class CourierInformationPageTest extends FeatureTestCase
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'courier_tab' => 'express',
'expcode' => 'SF',
'status' => '1',
]))

File diff suppressed because it is too large Load Diff

View File

@ -20,10 +20,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'page' => 'courier-surcharge-settlement',
]))->assertOk()
->assertSee('data-surcharge-panel="quotes" hidden', false)
->assertSeeText('附加费结算')
->assertSeeText('共享业务镜像尚未建立,附加费结算暂时无法读取。')
->assertSee('data-finance-table-name="courier-surcharge-settlement"', false)
@ -45,8 +43,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'page' => 'courier-surcharge-settlement',
'courier_name' => '测试快递',
'waybill' => '001234',
'shop_name' => '测试店铺',
@ -58,10 +55,14 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
->assertSee('value="测试店铺"', false)
->assertSee('value="SO-001"', false)
->assertSee('value="已计算" selected', false)
->assertSee('data-settlement-filter-picker', false)
->assertSee('data-settlement-filter-check', false)
->assertSee('data-settlement-filter-field="waybill"', false)
->assertSeeText('筛选设置')
->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算');
}
public function test_quote_page_exposes_quote_and_settlement_tabs(): void
public function test_quote_page_only_exposes_quote_tab(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
@ -70,18 +71,29 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
'page' => 'courier-surcharge',
]))->assertOk()
->assertSeeText('附加费报价')
->assertSeeText('附加费结算')
->assertSee('data-surcharge-panel="settlement" hidden', false)
->assertSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'quotes',
]), false)
->assertSee(route('business.pages.show', [
->assertDontSee(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
]), false);
]), false)
->assertDontSee('data-surcharge-panel="settlement"', false)
->assertSee('data-surcharge-panel="quotes"', false);
}
public function test_old_settlement_tab_url_redirects_to_standalone_page(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge',
'tab' => 'settlement',
'waybill' => '0000123456789',
]))->assertRedirect(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge-settlement',
'waybill' => '0000123456789',
]));
}
public function test_settlement_page_renders_as_courier_payables_page(): void
@ -103,6 +115,56 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
->assertSeeText('附加费结算');
}
public function test_settlement_rows_support_click_highlight(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class);
$repository->shouldReceive('available')->andReturn(true);
$repository->shouldReceive('ready')->andReturn(true);
$repository->shouldReceive('paginate')->andReturn([
'data' => [[
'courier_name' => '曹县德邦',
'shipped_at' => '2026-10-12 18:38:33',
'waybill' => 'DPK317248316615',
'platform_product_id' => '993815011271',
'product_code' => 'MKHDNZ0409260601380',
'shop_name' => '拼多多-HX旗舰店101',
'online_order_no' => '260912-325069063762746',
'length' => 168,
'width' => 30,
'height' => 20,
'volume' => 100800,
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
'settlement_amount' => 8,
'surcharge_type' => '超长费',
]],
'total' => 30,
'has_more' => false,
'summary' => ['count' => 30, 'settlement_amount' => 8.0],
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'],
'state' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'],
]);
$this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-surcharge-settlement',
]))->assertOk()
->assertSee('data-settlement-row', false)
->assertSee('is-clickable', false)
->assertSee('is-active', false)
->assertSee('settlement-page-link', false)
->assertDontSee('page-item', false)
->assertSeeText('第 1 / 2 页,本页 1 条,共 30 条')
->assertSeeText('点击行可高亮标记')
->assertSeeText('曹县德邦')
->assertSeeText('超长费');
}
public function test_settlement_export_streams_filtered_rows_as_xlsx(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\Http;
use Mockery;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Tests\Feature\FeatureTestCase;
@ -28,7 +29,17 @@ class DingPayableWorkflowPageTest extends FeatureTestCase
'finishTime' => '2026-08-28 11:00:00',
],
'detail' => ['类型' => '快递运费', '用途' => '拉到江西仓的车费', '付款金额' => '1200'],
'fields' => ['应付明细' => [], '付款明细' => [['付款金额' => '1200']], '附件' => []],
'fields' => [
'应付明细' => [],
'付款明细' => [['付款金额' => '1200']],
'附件' => '',
'未签字版合同附件或其他明细附件' => [[
'spaceId' => '369334649',
'fileName' => '测试对账附件.xlsx',
'fileId' => '123456',
'fileType' => 'xlsx',
]],
],
'tasks' => [['status' => 'COMPLETED']],
]],
'total' => 1,
@ -48,8 +59,16 @@ class DingPayableWorkflowPageTest extends FeatureTestCase
->assertSee('已完成')
->assertSee('拉到江西仓的车费')
->assertSee('用途同时包含“仓”“车费”')
->assertSee('全部表单字段')
->assertSee('审批任务')
->assertDontSeeText('全部表单字段')
->assertDontSeeText('付款明细')
->assertDontSeeText('审批任务')
->assertSee('测试对账附件.xlsx')
->assertSee('process_instance_id=PROC-TEST-001', false)
->assertSee('file_id=123456', false)
->assertSee('ding-payable-attachment"', false)
->assertSee('data-ding-payable-row', false)
->assertSee('is-clickable', false)
->assertSee('is-active', false)
->assertSee('镜像状态:succeeded');
}
@ -136,4 +155,66 @@ class DingPayableWorkflowPageTest extends FeatureTestCase
->assertSee('状态')
->assertSee('全部状态');
}
public function test_attachment_download_streams_dingtalk_file(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
config([
'dingtalk.client_id' => 'test-client-id',
'dingtalk.client_secret' => 'test-client-secret',
]);
$repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class);
$repository->shouldReceive('attachment')
->once()
->with('PROC-TEST-001', '123456')
->andReturn(['fileName' => '测试对账附件.xlsx', 'fileType' => 'xlsx', 'fileSize' => 9]);
$this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository);
Http::fake([
'oapi.dingtalk.com/*' => Http::response(['access_token' => 'test-token']),
'api.dingtalk.com/*' => Http::response([
'success' => true,
'result' => ['downloadUri' => 'https://fake-oss.example.com/file'],
]),
'fake-oss.example.com/*' => Http::response('file-bytes', 200, ['Content-Type' => 'application/octet-stream']),
]);
$response = $this->loginAs()->get(route('business.courier-payables.ding-payable-attachment.download', [
'process_instance_id' => 'PROC-TEST-001',
'file_id' => '123456',
]));
$response->assertOk()
->assertHeader('content-type', 'application/octet-stream');
$this->assertSame('file-bytes', $response->streamedContent());
$this->assertStringContainsString(
rawurlencode('测试对账附件.xlsx'),
(string) $response->headers->get('content-disposition'),
);
}
public function test_attachment_download_rejects_unknown_file(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class);
$repository->shouldReceive('attachment')->once()->andReturn(null);
$this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository);
Http::fake();
$this->loginAs()
->withExceptionHandling()
->withHeaders(['X-Requested-With' => 'XMLHttpRequest'])
->get(route('business.courier-payables.ding-payable-attachment.download', [
'process_instance_id' => 'PROC-UNKNOWN',
'file_id' => '999',
]))
->assertNotFound();
Http::assertNothingSent();
}
}

View File

@ -5,7 +5,9 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@ -35,23 +37,86 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$table->bigIncrements('id');
$table->string('platform', 32);
$table->string('shop_name', 191)->default('');
$table->string('owner_name', 64)->nullable();
$table->string('violation_type', 500)->nullable();
$table->string('violation_category', 64)->nullable();
$table->string('violation_reason', 500)->nullable();
$table->string('disposition', 500)->nullable();
$table->string('violation_no', 100);
$table->date('violation_date')->nullable();
$table->decimal('points_deducted', 10, 2)->nullable();
$table->string('status', 64)->nullable();
$table->date('penalty_date')->nullable();
$table->date('appeal_deadline')->nullable();
$table->dateTime('handling_deadline')->nullable();
$table->string('handling_status', 32)->default('未处理');
$table->string('ignore_reason', 191)->nullable();
$table->string('source', 16)->default('crawl');
$table->string('window_name', 191)->nullable();
$table->date('crawl_date')->nullable();
$table->dateTime('synced_at')->nullable();
$table->boolean('needs_alert')->default(false);
$table->string('alert_level', 8)->nullable();
$table->unsignedBigInteger('alert_rule_id')->nullable();
$table->string('alert_action', 500)->nullable();
$table->string('alert_channel', 64)->nullable();
$table->unsignedInteger('handling_hours')->nullable();
$table->string('push_status', 16)->default('未推送');
$table->dateTime('last_pushed_at')->nullable();
$table->timestamps();
$table->unique(['platform', 'violation_no']);
});
Schema::connection('business_mirror')->create('store_violation_rules', function ($table): void {
$table->bigIncrements('id');
$table->string('platform', 32);
$table->string('violation_category', 64)->nullable();
$table->string('violation_type', 500)->nullable();
$table->string('match_keywords', 500)->nullable();
$table->string('alert_level', 8)->nullable();
$table->boolean('needs_alert')->default(true);
$table->string('suggested_action', 500)->nullable();
$table->unsignedInteger('handling_hours')->nullable();
$table->string('remind_channel', 191)->nullable();
$table->unsignedInteger('sort')->default(0);
$table->boolean('enabled')->default(true);
$table->string('remark', 191)->nullable();
$table->timestamps();
});
Schema::connection('business_mirror')->create('ops_fys_shop_info', function ($table): void {
$table->bigIncrements('id');
$table->string('shopName', 191)->nullable();
$table->string('shopHead', 64)->nullable();
});
Schema::connection('business_mirror')->create('store_violation_push_logs', function ($table): void {
$table->bigIncrements('id');
$table->string('push_no', 32)->unique();
$table->dateTime('pushed_at')->nullable();
$table->string('channel', 32)->default('dingtalk_robot');
$table->string('message_type', 16)->default('instant');
$table->string('platform', 32)->nullable();
$table->string('violation_no', 100)->nullable();
$table->string('message_digest', 500)->nullable();
$table->string('mentioned', 191)->nullable();
$table->string('result', 16)->default('success');
$table->string('error', 500)->nullable();
$table->timestamps();
});
config(['store_violations.push' => [
'enabled' => true,
'channel' => 'dingtalk_robot',
'webhook' => 'https://oapi.dingtalk.com/robot/send?access_token=test-token',
'secret' => '',
'window_start' => '09:00',
'window_end' => '21:00',
'instant_levels' => ['P0', 'P1'],
'digest_time' => '10:00',
'at_all' => false,
]]);
Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void {
$table->string('dataset_key', 191)->primary();
$table->string('source_key', 64)->default('');
@ -66,26 +131,79 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$table->timestamps();
});
DB::connection('business_mirror')->table('store_violation_rules')->insert([
['id' => 1, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '缺货/无货',
'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '联系客户补发',
'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null,
'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'],
['id' => 2, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单延迟发货',
'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '致电客户协商',
'handling_hours' => 24, 'remind_channel' => null, 'sort' => 10, 'enabled' => true, 'remark' => null,
'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'],
['id' => 3, 'platform' => 'jingxi', 'violation_category' => '发票与考核', 'violation_type' => '发票违规',
'match_keywords' => null, 'alert_level' => 'P0', 'needs_alert' => true, 'suggested_action' => '立即补开发票',
'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null,
'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'],
['id' => 4, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单存在揽收超时行为',
'match_keywords' => null, 'alert_level' => null, 'needs_alert' => true, 'suggested_action' => null,
'handling_hours' => null, 'remind_channel' => null, 'sort' => 20, 'enabled' => true, 'remark' => null,
'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'],
]);
DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([
['shopName' => '京东-亿家达全屋旗舰店', 'shopHead' => '张三'],
['shopName' => '京东-卓禾卧室家具旗舰店', 'shopHead' => '李四'],
['shopName' => '京东-纪辰家具旗舰店', 'shopHead' => '王五'],
['shopName' => '京喜-久伴吾家具京喜自营工厂店', 'shopHead' => '幽竹'],
]);
DB::connection('business_mirror')->table('store_violation_records')->insert([
[
'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'violation_type' => '延迟发货/缺货无货',
'violation_category' => '发货履约', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04',
'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'owner_name' => '张三',
'violation_type' => '延迟发货/缺货无货', 'violation_category' => '发货履约',
'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告',
'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04',
'points_deducted' => 2, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => '2026-10-11',
'handling_status' => '未处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04',
'handling_deadline' => '2026-10-12 23:59:59', 'handling_status' => '未处理', 'ignore_reason' => null,
'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null,
'needs_alert' => true, 'alert_level' => 'P1', 'alert_rule_id' => 1, 'alert_action' => '联系客户补发',
'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null,
'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00',
],
[
'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'violation_type' => '订单存在揽收超时行为',
'violation_category' => '发货履约', 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03',
'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'owner_name' => '李四',
'violation_type' => '订单存在揽收超时行为', 'violation_category' => '发货履约',
'violation_reason' => '订单存在揽收超时行为', 'disposition' => null,
'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03',
'points_deducted' => null, 'status' => '超期未申诉', 'penalty_date' => '2026-10-05', 'appeal_deadline' => '2026-10-08',
'handling_status' => '处理中', 'source' => 'crawl', 'crawl_date' => '2026-10-04',
'handling_deadline' => '2026-10-14 23:59:59', 'handling_status' => '处理中', 'ignore_reason' => null,
'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null,
'needs_alert' => true, 'alert_level' => 'P2', 'alert_rule_id' => 4, 'alert_action' => null,
'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00',
'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00',
],
[
'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'violation_type' => '发票违规',
'violation_category' => '发票与考核', 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04',
'platform' => 'jd', 'shop_name' => '京东-无提醒示例店', 'owner_name' => null,
'violation_type' => '普通通知事项', 'violation_category' => '其他',
'violation_reason' => null, 'disposition' => null,
'violation_no' => 'JD202610040003', 'violation_date' => '2026-10-05',
'points_deducted' => null, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => null,
'handling_deadline' => null, 'handling_status' => '未处理', 'ignore_reason' => null,
'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-05', 'synced_at' => null,
'needs_alert' => false, 'alert_level' => null, 'alert_rule_id' => null, 'alert_action' => null,
'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null,
'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00',
],
[
'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'owner_name' => '幽竹',
'violation_type' => '发票违规', 'violation_category' => '发票与考核',
'violation_reason' => null, 'disposition' => '维持原判',
'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04',
'points_deducted' => null, 'status' => '已处置', 'penalty_date' => null, 'appeal_deadline' => null,
'handling_status' => '已处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04',
'handling_deadline' => '2026-10-13 23:59:59', 'handling_status' => '处理完成', 'ignore_reason' => null,
'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null,
'needs_alert' => true, 'alert_level' => 'P0', 'alert_rule_id' => 3, 'alert_action' => '立即补开发票',
'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00',
'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00',
],
]);
@ -115,18 +233,69 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$page->assertOk()
->assertSee('店铺名')
->assertSee('预警级别')
->assertSee('违规类型')
->assertSee('违规分类')
->assertSee('违规原因')
->assertSee('处置方式')
->assertSee('违规单号')
->assertSee('违规日期')
->assertSee('扣分')
->assertSee('责任人')
->assertSee('申诉截止日期')
->assertSee('处理截止')
->assertSee('是否超时')
->assertSee('推送状态')
->assertSee('处理状态')
->assertSee('操作')
->assertSee('P1-较重')
->assertSee('张三')
->assertSee('已超时')
->assertSee('未推送')
->assertSee('默认视图')
->assertSee('订单存在延迟发货、缺货无货')
->assertSee('JD202610040001')
->assertSee('京东-亿家达全屋旗舰店')
->assertSee('data-platform-panel="jingxi"', false);
}
public function test_dingtalk_entry_shows_only_this_page_without_the_global_menu(): void
{
$this->allowPage();
// 钉钉工作台入口:整个「财务经营风险管控」目录都隐藏全局菜单,
// 只保留目录自己的页签(店铺违规信息预警 / 流程统计)。
$this->loginAs()
->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC'])
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))
->assertOk()
->assertDontSee('id="sidenav-main"', false)
->assertDontSee('data-open-tabs', false)
->assertSeeText('店铺违规信息预警');
// 同目录下的另一个页面(流程统计)在钉钉里同样是单页模式。
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC'])
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'process-statistics',
]))
->assertOk()
->assertDontSee('id="sidenav-main"', false)
->assertSeeText('流程统计');
// 网页端(非钉钉)照旧显示完整菜单。
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) Safari/605.1.15'])
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))
->assertOk()
->assertSee('id="sidenav-main"', false);
}
public function test_page_filters_by_keyword(): void
{
$this->allowPage();
@ -136,6 +305,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase
'page' => 'store-violation-alert',
'platform' => 'jd',
'keyword' => '卓禾',
'view' => 'all',
]));
$page->assertOk()
@ -144,6 +314,33 @@ class StoreViolationAlertPageTest extends FeatureTestCase
->assertDontSee('JX202610040001');
}
public function test_default_view_only_shows_alert_and_pending_records(): void
{
$this->allowPage();
$default = $this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => 'jd',
]));
$default->assertOk()
->assertSee('JD202610040001')
->assertDontSee('JD202610040002')
->assertDontSee('JD202610040003');
$all = $this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => 'jd',
'view' => 'all',
]));
$all->assertOk()
->assertSee('JD202610040001')
->assertSee('JD202610040002')
->assertSee('JD202610040003')
->assertSee('不提醒');
}
public function test_export_streams_unified_headers(): void
{
$this->allowPage();
@ -166,13 +363,21 @@ class StoreViolationAlertPageTest extends FeatureTestCase
file_put_contents($temp, $binary);
$sheet = IOFactory::load($temp)->getActiveSheet();
$headers = $sheet->rangeToArray('A1:J1')[0];
$headers = $sheet->rangeToArray('A1:R1')[0];
$this->assertSame(
['店铺名', '违规类型', '违规分类', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'],
[
'店铺名', '预警级别', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分',
'责任人', '状态', '处罚日期', '申诉截止日期', '处理截止', '是否超时', '推送状态', '处理状态', '忽略原因',
],
$headers,
);
$this->assertSame('JD202610040001', $sheet->getCell('D2')->getValue());
$this->assertSame('P1-较重', $sheet->getCell('B2')->getValue());
$this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('E2')->getValue());
$this->assertSame('警告', $sheet->getCell('F2')->getValue());
$this->assertSame('JD202610040001', $sheet->getCell('G2')->getValue());
$this->assertSame('张三', $sheet->getCell('J2')->getValue());
$this->assertSame('已超时', $sheet->getCell('O2')->getValue());
unlink($temp);
}
@ -237,6 +442,210 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$response->assertSessionHasErrors('import');
}
public function test_import_maps_reason_and_disposition_columns(): void
{
$this->allowPage();
$file = $this->makeWorkbook([
['店铺名', '违规单号', '违规原因', '处置方式'],
['京东-纪辰家具旗舰店', 'JD202610040088', '订单存在揽收超时行为', '警告;整改'],
]);
$preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [
'platform' => 'jd',
'import' => $file,
]);
$preview->assertRedirect();
$payload = $preview->getSession()->get('store_violation_preview');
$this->assertIsArray($payload);
$this->assertSame(1, $payload['summary']['new']);
$commit = $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [
'preview_token' => $payload['token'],
]);
$commit->assertRedirect();
$record = DB::connection('business_mirror')
->table('store_violation_records')
->where('platform', 'jd')
->where('violation_no', 'JD202610040088')
->first();
$this->assertNotNull($record);
$this->assertSame('订单存在揽收超时行为', $record->violation_reason);
$this->assertSame('警告;整改', $record->disposition);
// 未提供违规类型时,违规分类按违规原因关键词自动归类。
$this->assertSame('发货履约', $record->violation_category);
// 预警派生字段按字典自动计算(无级别配置的规则默认 P2)。
$this->assertTrue((bool) $record->needs_alert);
$this->assertSame('P2', $record->alert_level);
}
public function test_import_commit_calculates_alert_fields_from_dictionary(): void
{
$this->allowPage();
$file = $this->makeWorkbook([
['店铺名', '违规单号', '违规类型', '违规日期'],
['京东-纪辰家具旗舰店', 'JD202610040099', '订单延迟发货', '2026-10-02'],
]);
$preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [
'platform' => 'jd',
'import' => $file,
]);
$preview->assertRedirect();
$payload = $preview->getSession()->get('store_violation_preview');
$this->loginAs()->post(route('business.store-violation-alert.import.commit'), [
'preview_token' => $payload['token'],
])->assertRedirect();
$record = DB::connection('business_mirror')
->table('store_violation_records')
->where('platform', 'jd')
->where('violation_no', 'JD202610040099')
->first();
$this->assertNotNull($record);
$this->assertTrue((bool) $record->needs_alert);
$this->assertSame('P1', $record->alert_level);
$this->assertSame('王五', $record->owner_name);
// 字典时效 24 小时:违规日期 2026-10-02 起算。
$this->assertSame('2026-10-03 00:00:00', (string) $record->handling_deadline);
}
public function test_update_handling_status_requires_reason_when_ignored(): void
{
$this->allowPage();
$this->withExceptionHandling();
$record = DB::connection('business_mirror')->table('store_violation_records')
->where('violation_no', 'JD202610040001')->first();
// 已忽略缺原因 → 报错且状态不变
$this->loginAs()->post(route('business.store-violation-alert.update'), [
'id' => $record->id,
'handling_status' => '已忽略',
])->assertSessionHasErrors('ignore_reason');
$this->assertSame('未处理', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status'));
// 认领 → 处理中
$this->loginAs()->post(route('business.store-violation-alert.update'), [
'id' => $record->id,
'handling_status' => '处理中',
])->assertRedirect();
$this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status'));
// 忽略(带原因)
$this->loginAs()->post(route('business.store-violation-alert.update'), [
'id' => $record->id,
'handling_status' => '已忽略',
'ignore_reason' => '误报,无需处理',
])->assertRedirect();
$updated = DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->first();
$this->assertSame('已忽略', $updated->handling_status);
$this->assertSame('误报,无需处理', $updated->ignore_reason);
}
public function test_claim_route_marks_record_processing_via_signed_link(): void
{
$this->allowPage();
$this->withExceptionHandling();
$this->loginAs();
$record = DB::connection('business_mirror')->table('store_violation_records')
->where('violation_no', 'JD202610040001')->first();
$url = URL::signedRoute('business.store-violation-alert.claim', [
'record' => $record->id,
'company_id' => $this->company->id,
]);
$this->get($url)->assertRedirect();
$this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status'));
// 幂等:再次访问不报错。
$this->get($url)->assertRedirect();
// 无签名 → 403。
$this->get(route('business.store-violation-alert.claim', [
'record' => $record->id,
'company_id' => $this->company->id,
]))
->assertStatus(403);
}
public function test_preview_statistics_page_renders_kpi_cards_and_pending_rows(): void
{
$this->allowPage();
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'preview-statistics',
'platform' => 'jd',
]));
$response->assertOk()
->assertSeeText('预览统计')
->assertSeeText('今日新增')
->assertSeeText('待处理总数')
->assertSeeText('P0 未处理')
->assertSeeText('已超时')
->assertSeeText('待处理 1 条')
->assertSee('JD202610040001')
->assertDontSee('JD202610040002')
->assertDontSee('JD202610040003')
->assertSee('data-risk-preview-tabs', false);
}
public function test_push_instant_sends_p0_p1_and_marks_pushed(): void
{
$this->allowPage();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->artisan('store-violations:push-instant')->assertExitCode(0);
$record = DB::connection('business_mirror')->table('store_violation_records')
->where('violation_no', 'JD202610040001')->first();
$this->assertSame('已推送', $record->push_status);
$this->assertNotNull($record->last_pushed_at);
$log = DB::connection('business_mirror')->table('store_violation_push_logs')
->where('message_type', 'instant')->first();
$this->assertNotNull($log);
$this->assertSame('JD202610040001', $log->violation_no);
$this->assertSame('success', $log->result);
$this->assertSame('张三', $log->mentioned);
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return str_contains($request->url(), 'oapi.dingtalk.com/robot/send')
&& str_contains($text, 'P1-较重')
&& str_contains($text, '去认领');
});
}
public function test_push_digest_sends_daily_summary(): void
{
$this->allowPage();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$this->artisan('store-violations:push-digest')->assertExitCode(0);
$log = DB::connection('business_mirror')->table('store_violation_push_logs')
->where('message_type', 'daily')->first();
$this->assertNotNull($log);
$this->assertSame('success', $log->result);
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return str_contains($text, '每日汇总') && str_contains($text, '已超时未认领');
});
}
/** @param list<list<mixed>> $rows */
private function makeWorkbook(array $rows): UploadedFile
{

View File

@ -28,7 +28,7 @@ class WarehouseFreightPageTest extends FeatureTestCase
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'warehouse-freight']))
->assertOk()
->assertSeeText('仓储运费')
->assertSeeText('仓储费')
->assertSeeText('2026-08')
->assertSeeText('查看明细')
->assertSeeText('导出核算')

View File

@ -131,6 +131,7 @@ class NavigationTest extends FeatureTestCase
$this->assertSame('security', $navigation[$riskControlIndex]['icon']);
$this->assertSame([
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
['slug' => 'preview-statistics', 'title' => '预览统计'],
[
'slug' => 'process-statistics',
'title' => '流程统计',
@ -188,6 +189,25 @@ class NavigationTest extends FeatureTestCase
->assertSee('/workspace/financial-risk-control/store-violation-alert', false);
}
public function test_business_pages_render_menu_filter_and_open_page_tabs(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))
->assertOk()
// 侧边栏菜单搜索框(方便按名称找页面)。
->assertSee('id="prototype-menu-filter"', false)
->assertSee('prototype-menu-filter', false)
// 已打开页面标签栏(固定在页面上方,点开的页面并排显示)。
->assertSee('data-open-tabs', false)
->assertSee('data-open-tabs-list', false)
->assertSee('caiwu.open-pages.v1.', false)
// 目录 iframe 里的子页面不再重复渲染这条标签栏。
->assertSee('window.self !== window.top', false);
}
public function testProcessStatisticsPageIsAvailableUnderRiskControl(): void
{
$this->loginAs()

View File

@ -0,0 +1,82 @@
<?php
namespace Tests\Unit\Business;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* 店铺违规信息预警:预警级别匹配、处理截止与超时判定。
*/
class StoreViolationAlertCalculatorTest extends TestCase
{
public function test_clean_violation_type_strips_parenthesized_disposition(): void
{
$this->assertSame('虚假交易', StoreViolationAlertCalculator::cleanViolationType('虚假交易(立即删除销量;)'));
$this->assertSame('AE', StoreViolationAlertCalculator::cleanViolationType('A(B(C)D)E(F)'));
$this->assertSame('订单存在揽收超时行为', StoreViolationAlertCalculator::cleanViolationType(' 订单存在揽收超时行为 '));
$this->assertSame('', StoreViolationAlertCalculator::cleanViolationType(null));
}
public function test_match_rule_uses_exact_prefix_parts_and_keywords_in_order(): void
{
$rules = [
['id' => 1, 'platform' => 'jd', 'violation_type' => '缺货/无货', 'match_keywords' => null,
'alert_level' => 'P1', 'needs_alert' => 1, 'suggested_action' => '联系客户补发', 'handling_hours' => null],
['id' => 2, 'platform' => 'jingxi', 'violation_type' => '订单延迟发货', 'match_keywords' => null,
'alert_level' => 'P0', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null],
['id' => 3, 'platform' => 'taobao', 'violation_type' => null, 'match_keywords' => '虚假交易|危及交易安全',
'alert_level' => 'P2', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null],
['id' => 4, 'platform' => 'douyin', 'violation_type' => '效果虚假', 'match_keywords' => null,
'alert_level' => null, 'needs_alert' => 0, 'suggested_action' => null, 'handling_hours' => null],
];
// 分词包含:规则"缺货/无货"命中"延迟发货/缺货无货"
$this->assertSame(1, StoreViolationAlertCalculator::matchRule($rules, 'jd', '延迟发货/缺货无货')['id']);
// 相等与前缀
$this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货')['id']);
$this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货(立即扣分;)')['id']);
// 关键词匹配(先去括号)
$this->assertSame(3, StoreViolationAlertCalculator::matchRule($rules, 'taobao', '虚假交易(立即删除销量&屏蔽评论内容;)')['id']);
// 命中但规则标记为不需要提醒
$this->assertSame(4, StoreViolationAlertCalculator::matchRule($rules, 'douyin', '效果虚假')['id']);
// 平台隔离与未命中
$this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', '效果虚假'));
$this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'pinduoduo', '任意类型'));
$this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', null));
}
public function test_deadline_prefers_appeal_date_then_hours_then_level_days(): void
{
// 申诉截止非空 → 当天 23:59:59
$this->assertSame(
'2026-10-11 23:59:59',
StoreViolationAlertCalculator::deadlineFor('2026-10-04', '2026-10-11', null, 'P1'),
);
// 字典时效(小时)
$this->assertSame(
'2026-10-04 06:00:00',
StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, 6, 'P0'),
);
// 级别默认天数:P0+1 / P1+2 / P2+3 天的 23:59:59
$this->assertSame('2026-10-05 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P0'));
$this->assertSame('2026-10-06 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P1'));
$this->assertSame('2026-10-07 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P2'));
// 无违规日期或无级别 → null
$this->assertNull(StoreViolationAlertCalculator::deadlineFor(null, null, null, 'P0'));
$this->assertNull(StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, null));
}
public function test_is_overdue_respects_status_and_deadline(): void
{
$now = Carbon::parse('2026-10-05 10:00:00');
$this->assertTrue(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-04 23:59:59', $now));
$this->assertTrue(StoreViolationAlertCalculator::isOverdue('处理中', '2026-10-05 09:00:00', $now));
$this->assertFalse(StoreViolationAlertCalculator::isOverdue('处理完成', '2020-01-01 00:00:00', $now));
$this->assertFalse(StoreViolationAlertCalculator::isOverdue('已忽略', '2020-01-01 00:00:00', $now));
$this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', null, $now));
$this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-06 23:59:59', $now));
}
}

Some files were not shown because too many files have changed in this diff Show More