快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
423 lines
15 KiB
PHP
423 lines
15 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 工作平台 2.0 的快递资料只读镜像。
|
||
*
|
||
* 这里不直接连接 JianSun;所有查询都落在 caiwu_business_mirror 中,
|
||
* 由 business-mirror:sync-tables 负责生成和刷新镜像表。
|
||
*/
|
||
class CourierInformationMirrorRepository
|
||
{
|
||
public const DATASET_KEY = 'jiansun.dbo.wp_express';
|
||
|
||
public const NETWORK_DATASET_KEY = 'jiansun.dbo.wp_express_expNet';
|
||
|
||
public const ACCOUNT_DATASET_KEY = 'jiansun.dbo.wp_express_account';
|
||
|
||
public const LOG_DATASET_KEY = 'jiansun.dbo.wp_express_extension';
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(
|
||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||
private readonly GenericBusinessMirrorRepository $mirror,
|
||
) {
|
||
$this->connection = $mirror->connection();
|
||
}
|
||
|
||
public function available(): bool
|
||
{
|
||
$schema = Schema::connection($this->connection->getName());
|
||
|
||
return $schema->hasTable('business_mirror_table_states')
|
||
&& $schema->hasTable($this->dataset()->targetTable());
|
||
}
|
||
|
||
public function ready(): bool
|
||
{
|
||
return $this->mirror->ready($this->dataset());
|
||
}
|
||
|
||
/** @return array<string,mixed>|null */
|
||
public function state(): ?array
|
||
{
|
||
return $this->mirror->state($this->dataset());
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed>|null $state
|
||
* @return array{code:string,lag_minutes:?int}
|
||
*/
|
||
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
|
||
{
|
||
$state ??= $this->state();
|
||
if (! is_array($state)) {
|
||
return ['code' => 'unavailable', 'lag_minutes' => null];
|
||
}
|
||
|
||
$lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null);
|
||
$lagMinutes = $lastSucceeded === null
|
||
? null
|
||
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60);
|
||
$status = (string) ($state['status'] ?? 'pending');
|
||
|
||
if (($state['initial_completed_at'] ?? null) === null) {
|
||
$code = $status === 'failed'
|
||
? 'initial_failed'
|
||
: ($status === 'running' ? 'initializing' : 'pending');
|
||
} elseif ($status === 'failed') {
|
||
$code = 'failed';
|
||
} elseif ($status === 'running') {
|
||
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
|
||
} elseif ($lagMinutes === null
|
||
|| $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) {
|
||
$code = 'delayed';
|
||
} else {
|
||
$code = 'healthy';
|
||
}
|
||
|
||
return ['code' => $code, 'lag_minutes' => $lagMinutes];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $filters
|
||
* @return array{data:list<object>,total:int}
|
||
*/
|
||
public function paginate(array $filters, int $page, int $perPage): array
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$perPage = min(max($perPage, 1), 100);
|
||
$query = $this->filteredExpressQuery($filters);
|
||
$total = (clone $query)->count();
|
||
$rows = $query->orderBy('Id')
|
||
->forPage($page, $perPage)
|
||
->get()
|
||
->all();
|
||
|
||
return ['data' => $rows, 'total' => $total];
|
||
}
|
||
|
||
/**
|
||
* 快递资料中的去重快递公司名称,按资料 Id 的首次出现顺序返回。
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function names(): array
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
return $this->connection
|
||
->table($this->dataset()->targetTable())
|
||
->select(['Id', 'expname'])
|
||
->whereNotNull('expname')
|
||
->orderBy('Id')
|
||
->get()
|
||
->map(static fn ($row): string => trim((string) ($row->expname ?? '')))
|
||
->filter(static fn (string $name): bool => $name !== '')
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $filters
|
||
* @return \Generator<int,object>
|
||
*/
|
||
public function exportRows(array $filters, int $batchSize = 500): \Generator
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
$batchSize = min(max($batchSize, 1), 5000);
|
||
$lastId = null;
|
||
|
||
while (true) {
|
||
$query = $this->filteredExpressQuery($filters);
|
||
if ($lastId !== null) {
|
||
$query->where('Id', '>', $lastId);
|
||
}
|
||
|
||
$rows = $query->orderBy('Id')->limit($batchSize)->get()->all();
|
||
if ($rows === []) {
|
||
return;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
yield $row;
|
||
}
|
||
|
||
$lastId = (int) $rows[array_key_last($rows)]->Id;
|
||
if (count($rows) < $batchSize) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 网点标签数据。快递编码为空时返回全部网点;传入编码时只返回对应网点。
|
||
* @return array{data:list<object>,total:int}
|
||
*/
|
||
public function paginateNetworks(?string $expcode, int $page, int $perPage): array
|
||
{
|
||
$dataset = $this->datasets->get(self::NETWORK_DATASET_KEY);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
$query = $this->connection->table($dataset->targetTable())->select([
|
||
'id', 'expid', 'expcode', 'netcode', 'netno', 'netname', 'isbill',
|
||
'province', 'city', 'area', 'town', 'address', 'linker', 'linkermobile',
|
||
'status', 'apitype', 'apipltmname', 'created', 'expnum',
|
||
]);
|
||
if ($expcode !== null && $expcode !== '') {
|
||
$query->where('expcode', $expcode);
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$perPage = min(max($perPage, 1), 100);
|
||
$total = (clone $query)->count();
|
||
|
||
return [
|
||
'data' => $query->orderByDesc('id')->forPage($page, $perPage)->get()->all(),
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 打款账户标签数据。
|
||
* @return array{data:list<object>,total:int}
|
||
*/
|
||
public function paginateAccounts(?string $expcode, int $page, int $perPage): array
|
||
{
|
||
$dataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
$query = $this->connection->table($dataset->targetTable())->select([
|
||
'Id', 'expcode', 'expname', 'accountType', 'account', 'accountName',
|
||
'accountkhh', 'createTime', 'invoice', 'erpId', 'erpAccountId',
|
||
]);
|
||
if ($expcode !== null && $expcode !== '') {
|
||
$query->where('expcode', $expcode);
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$perPage = min(max($perPage, 1), 100);
|
||
$total = (clone $query)->count();
|
||
|
||
return [
|
||
'data' => $query->orderByDesc('Id')->forPage($page, $perPage)->get()->all(),
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 快递总览用:工作平台全部快递 + 按快递代码归组的打款账户。镜像未就绪返回 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];
|
||
}
|
||
|
||
/**
|
||
* 返回指定快递名称对应的电子签章经办人账号。
|
||
*
|
||
* 工作平台将该值保存在 wp_express.dzqzjbr 中,不能用联系人电话
|
||
* 代替;两者在部分快递资料中可能不同。镜像未完成首次同步时返回空
|
||
* 数组,避免影响协议签署页面的正常打开。
|
||
*
|
||
* @param list<string> $expnames
|
||
* @return array<string,string>
|
||
*/
|
||
public function signingAccountsByName(array $expnames): array
|
||
{
|
||
if (! $this->ready()) {
|
||
return [];
|
||
}
|
||
|
||
$expnames = array_values(array_unique(array_filter(array_map(
|
||
static fn ($name): string => trim((string) $name),
|
||
$expnames,
|
||
), static fn (string $name): bool => $name !== '')));
|
||
|
||
if ($expnames === []) {
|
||
return [];
|
||
}
|
||
|
||
return $this->connection
|
||
->table($this->dataset()->targetTable())
|
||
->select(['expname', 'dzqzjbr', 'Id'])
|
||
->whereIn('expname', $expnames)
|
||
->orderBy('Id')
|
||
->get()
|
||
->groupBy(static fn ($row): string => (string) ($row->expname ?? ''))
|
||
->map(static function ($rows): string {
|
||
foreach ($rows as $row) {
|
||
$account = trim((string) ($row->dzqzjbr ?? ''));
|
||
if ($account !== '') {
|
||
return $account;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
})
|
||
->filter(static fn (string $account): bool => $account !== '')
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* 快递资料操作日志。
|
||
* @return array{data:list<object>,total:int}
|
||
*/
|
||
public function paginateLogs(?string $expcode, int $page, int $perPage): array
|
||
{
|
||
$dataset = $this->datasets->get(self::LOG_DATASET_KEY);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||
}
|
||
|
||
$query = $this->connection->table($dataset->targetTable())->select([
|
||
'Id', 'expcode', 'doTime', 'createTime', 'modifyTime',
|
||
]);
|
||
if ($expcode !== null && $expcode !== '') {
|
||
$query->where('expcode', $expcode);
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$perPage = min(max($perPage, 1), 100);
|
||
$total = (clone $query)->count();
|
||
|
||
return [
|
||
'data' => $query->orderByDesc('Id')->forPage($page, $perPage)->get()->all(),
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
/** @return array{main:bool,networks:bool,accounts:bool,logs:bool,network_ready:bool,account_ready:bool,log_ready:bool} */
|
||
public function relatedAvailability(): array
|
||
{
|
||
try {
|
||
$schema = Schema::connection($this->connection->getName());
|
||
$networkDataset = $this->datasets->get(self::NETWORK_DATASET_KEY);
|
||
$accountDataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY);
|
||
$logDataset = $this->datasets->get(self::LOG_DATASET_KEY);
|
||
|
||
return [
|
||
'main' => $schema->hasTable($this->dataset()->targetTable()),
|
||
'networks' => $schema->hasTable($networkDataset->targetTable()),
|
||
'accounts' => $schema->hasTable($accountDataset->targetTable()),
|
||
'logs' => $schema->hasTable($logDataset->targetTable()),
|
||
'network_ready' => $this->mirror->ready($networkDataset),
|
||
'account_ready' => $this->mirror->ready($accountDataset),
|
||
'log_ready' => $this->mirror->ready($logDataset),
|
||
];
|
||
} catch (Throwable) {
|
||
return ['main' => false, 'networks' => false, 'accounts' => false, 'logs' => false, 'network_ready' => false, 'account_ready' => false, 'log_ready' => false];
|
||
}
|
||
}
|
||
|
||
/** @param array<string,string> $filters */
|
||
private function filteredExpressQuery(array $filters): Builder
|
||
{
|
||
$query = $this->connection->table($this->dataset()->targetTable())->select([
|
||
'Id', 'expcode', 'expname', 'outcode', 'scexpcode', 'status', 'paymethod',
|
||
'addvalidsgn', 'kuaiyunsgn', 'autonosgn', 'packnosgn', 'skucountsplitsgn',
|
||
'weight0sgn', 'stopdate', 'tradebtime', 'tradeetime', 'signcom', 'signmode',
|
||
'linker', 'linkmobile', 'paytype', 'undefined1', 'undefined2', 'undefined3',
|
||
'volume0sgn', 'handnosgn', 'decryptsgn', 'pj1sgn', 'undefinedtype',
|
||
'undefined4', 'undefined5', 'dzqzjbr', 'guid', 'is_delete', 'exp_properties',
|
||
'modified',
|
||
]);
|
||
|
||
if (($filters['expcode'] ?? '') !== '') {
|
||
$query->where('expcode', $filters['expcode']);
|
||
}
|
||
if (($filters['expname'] ?? '') !== '') {
|
||
$query->whereRaw("expname LIKE ? ESCAPE '!'", [$this->contains($filters['expname'])]);
|
||
}
|
||
foreach (['undefined1', 'undefined2', 'undefined3'] as $column) {
|
||
if (($filters[$column] ?? '') !== '') {
|
||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($filters[$column])]);
|
||
}
|
||
}
|
||
if (($filters['tradebtime_from'] ?? '') !== '') {
|
||
$query->whereDate('tradebtime', '>=', $filters['tradebtime_from']);
|
||
}
|
||
if (($filters['tradebtime_to'] ?? '') !== '') {
|
||
$query->whereDate('tradebtime', '<=', $filters['tradebtime_to']);
|
||
}
|
||
if (($filters['status'] ?? '') !== '') {
|
||
$query->where('status', (int) $filters['status']);
|
||
}
|
||
|
||
return $query;
|
||
}
|
||
|
||
private function dataset(): \Modules\EcommerceFinance\Contracts\BusinessMirrorDataset
|
||
{
|
||
return $this->datasets->get(self::DATASET_KEY);
|
||
}
|
||
|
||
private function contains(string $value): string
|
||
{
|
||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
|
||
private function timestamp(mixed $value): ?int
|
||
{
|
||
if (! is_string($value) || $value === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return Carbon::parse($value)->getTimestamp();
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|