feat: 接入工作平台快递资料界面

This commit is contained in:
limeng 2026-08-29 09:03:31 +08:00
parent 2b723d88e6
commit 66d90edeef
9 changed files with 1346 additions and 4 deletions

View File

@ -18,6 +18,7 @@ use Illuminate\Support\Collection;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
@ -48,6 +49,7 @@ class Pages extends Controller
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly SupplierPayableReportRepository $supplierPayableReports,
@ -288,6 +290,7 @@ class Pages extends Controller
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
@ -1166,6 +1169,66 @@ class Pages extends Controller
]);
}
public function exportCourierInformation(Request $request): StreamedResponse
{
abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503);
$filters = $this->courierInformationFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建快递资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式',
'是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号',
'停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3',
'体积0寻号', '手工单号', '解密', '最后修改',
]);
$bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
$date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10);
foreach ($this->courierInformation->exportRows($filters) as $row) {
fputcsv($csv, [
$row->Id,
$this->csvText($row->expcode),
$this->csvText($row->expname),
$this->csvText($row->outcode),
$this->csvText($row->scexpcode),
$this->csvText($row->paymethod),
$bool($row->status),
$bool($row->addvalidsgn),
$bool($row->kuaiyunsgn),
$bool($row->autonosgn),
$bool($row->packnosgn),
$bool($row->skucountsplitsgn),
$bool($row->weight0sgn),
$date($row->stopdate),
$date($row->tradebtime),
$date($row->tradeetime),
$this->csvText($row->linker),
$this->csvText($row->linkmobile),
$this->csvText($row->undefined1),
$this->csvText($row->undefined2),
$this->csvText($row->undefined3),
$bool($row->volume0sgn),
$bool($row->handnosgn),
$bool($row->decryptsgn),
$date($row->modified),
]);
}
fclose($csv);
}, '快递资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,mixed> */
private function normalProductFilters(Request $request): array
{
@ -1236,6 +1299,129 @@ class Pages extends Controller
];
}
protected function contentForCourierInformation(): array
{
$filters = $this->courierInformationFilters(request());
$tab = (string) request()->query('courier_tab', 'express');
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express';
$page = max((int) request()->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]);
$error = null;
$pending = false;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$relatedAvailability = $this->courierInformation->relatedAvailability();
try {
if ($tab === 'express') {
if (! $this->courierInformation->available()) {
$error = '共享业务镜像尚未建立,快递资料暂时无法读取。';
} else {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
if (! $this->courierInformation->ready()) {
$pending = true;
$error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginate($filters, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} elseif ($tab === 'networks') {
if (! ($relatedAvailability['networks'] ?? false)) {
$error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。';
} elseif (! ($relatedAvailability['network_ready'] ?? false)) {
$pending = true;
$error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} elseif ($tab === 'accounts') {
if (! ($relatedAvailability['accounts'] ?? false)) {
$error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。';
} elseif (! ($relatedAvailability['account_ready'] ?? false)) {
$pending = true;
$error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} else {
if (! ($relatedAvailability['logs'] ?? false)) {
$error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。';
} elseif (! ($relatedAvailability['log_ready'] ?? false)) {
$pending = true;
$error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '快递资料共享镜像暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab,
'courierFilters' => $filters,
'courierRows' => $rows,
'courierState' => $state,
'courierSyncStatus' => $syncStatus,
'courierError' => $error,
'courierPending' => $pending,
'courierRelatedAvailability' => $relatedAvailability,
];
}
/** @return array<string,string> */
private function courierInformationFilters(Request $request): array
{
$filters = [
'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64),
'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100),
'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100),
'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100),
'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100),
'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')),
'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')),
'status' => in_array((string) $request->query('status', ''), ['0', '1'], true)
? (string) $request->query('status')
: '',
];
if ($filters['tradebtime_from'] !== ''
&& $filters['tradebtime_to'] !== ''
&& $filters['tradebtime_from'] > $filters['tradebtime_to']) {
[$filters['tradebtime_from'], $filters['tradebtime_to']] = [
$filters['tradebtime_to'],
$filters['tradebtime_from'],
];
}
return $filters;
}
private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator
{
return new LengthAwarePaginator(
Collection::make($rows),
$total,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForProductPrices(): array
{
$filters = [

View File

@ -418,6 +418,7 @@ return [
'icon' => 'local_shipping',
'pages' => [
['slug' => 'quote-management', 'title' => '报价管理'],
['slug' => 'courier-information', 'title' => '快递资料'],
['slug' => 'agreement-signing', 'title' => '协议签署'],
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
['slug' => 'bookkeeping-details', 'title' => '记账明细'],

View File

@ -55,6 +55,10 @@ return [
'reconcile_window_hours' => (int) env('PRODUCT_PURCHASE_PRICE_MIRROR_RECONCILE_WINDOW_HOURS', 6),
],
'courier_information' => [
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
],
/*
|--------------------------------------------------------------------------
| 上游原表镜像目录
@ -174,5 +178,53 @@ return [
'jiansun.dbo.fys_supplierGoods_sttlement_purchase' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_purchase', 'target' => 'js_supplier_goods_purchase', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.fys_supplierGoods_sttlement_sale' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_sale', 'target' => 'js_supplier_goods_sale', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_purchaseIn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_purchaseIn', 'target' => 'js_pm_purchase_in', 'keys' => ['id'], 'watermark' => 'updateTime'],
// 工作平台 2.0:快递资料及其网点、打款账户。页面只读取共享镜像,
// 首次使用前需由刷新节点显式执行对应数据集的 --full 同步。
'jiansun.dbo.wp_express' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'wp_express',
'target' => 'js_wp_express',
'keys' => ['Id'],
'watermark' => 'modified',
'indexes' => [
['expcode'],
['status', 'Id'],
],
],
'jiansun.dbo.wp_express_expNet' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'wp_express_expNet',
'target' => 'js_wp_express_exp_net',
'keys' => ['id'],
'indexes' => [
['expid', 'id'],
['status', 'id'],
],
],
'jiansun.dbo.wp_express_account' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'wp_express_account',
'target' => 'js_wp_express_account',
'keys' => ['Id'],
'indexes' => [
['expcode', 'Id'],
['accountType', 'Id'],
],
],
'jiansun.dbo.wp_express_extension' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'wp_express_extension',
'target' => 'js_wp_express_extension',
'keys' => ['Id'],
'watermark' => 'modifyTime',
'indexes' => [
['expcode', 'Id'],
],
],
],
];

View File

@ -2,7 +2,7 @@
## 结论
项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 40 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、板材、封边条和库存。
项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 43 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、快递资料、板材、封边条和库存。
`caiwu_business_mirror` 的目标是把项目实际使用的上游原表数据保存为共享业务镜像。网页只读镜像,后台刷新节点在低压、可断点的条件下完成首次全量和后续增量;不得再按页码或筛选条件临时生成不完整的数据副本。
@ -14,6 +14,7 @@
| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 |
| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 |
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
| 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 |
| 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 |
@ -42,6 +43,7 @@
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
| 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` |
| 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) |
| 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` |
其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
@ -60,8 +62,9 @@
1. 公司、店铺、商标等小型维表。
2. 营销账户五张单表账单。
3. 供应商应付与货款对账。
4. 订单、退款和推广费用。
5. 板材、封边条和库存的跨表查询。
3. 快递资料、网点和打款账户。
4. 供应商应付与货款对账。
5. 订单、退款和推广费用。
6. 板材、封边条和库存的跨表查询。
每个阶段都必须先完成首次全量和数量核对,再将对应 Repository 切到镜像;任一阶段失败时保留上一版镜像和旧只读链路用于回滚。

View File

@ -0,0 +1,319 @@
<?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];
}
/**
* @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,
];
}
/**
* 快递资料操作日志。
* @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;
}
}
}

View File

@ -0,0 +1,495 @@
@php
$tab = $courierTab ?? 'express';
$filters = $courierFilters ?? [];
$rows = $courierRows ?? null;
$sync = $courierSyncStatus ?? ['code' => 'unavailable', 'lag_minutes' => null];
$state = $courierState ?? null;
$statusLabels = [
'healthy' => '同步正常',
'delayed' => '同步延迟',
'syncing' => '增量同步中',
'reconciling' => '全量校准中',
'initializing' => '首次同步中',
'initial_failed' => '首次同步失败',
'failed' => '同步失败',
'pending' => '等待首次同步',
'unavailable' => '镜像未建立',
];
$statusClass = in_array($sync['code'] ?? '', ['healthy'], true)
? 'is-success'
: (in_array($sync['code'] ?? '', ['failed', 'initial_failed', 'unavailable'], true) ? 'is-danger' : 'is-warning');
$date = static function ($value): string {
if ($value === null || $value === '') {
return '-';
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->format('Y-m-d');
} catch (\Throwable) {
return (string) $value;
}
};
$datetime = static function ($value): string {
if ($value === null || $value === '') {
return '-';
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->format('Y-m-d H:i');
} catch (\Throwable) {
return (string) $value;
}
};
$bool = static function ($value): string {
return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
};
$tabUrl = static function (string $targetTab) use ($filters): string {
return route('business.pages.show', array_merge(
['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => $targetTab],
array_filter($filters, static fn ($value) => $value !== '' && $value !== null),
));
};
@endphp
<style>
.courier-information-page {
color: var(--vip-content-ink, #171717);
font-family: JetBrains Mono, Microsoft YaHei, PingFang SC, monospace;
font-size: 0.8125rem;
}
.courier-information-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
background: #fff;
}
.courier-information-status__main,
.courier-information-status__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.courier-information-status__meta { color: var(--vip-content-muted, #595959); font-size: 0.75rem; }
.courier-information-status__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--vip-warning, #935f07);
}
.courier-information-status__dot.is-success { background: var(--vip-success, #53793e); }
.courier-information-status__dot.is-danger { background: var(--vip-danger, #b80000); }
.courier-information-tabs {
display: flex;
gap: 24px;
min-height: 48px;
margin-bottom: 16px;
padding: 0 16px;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: #fff;
}
.courier-information-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 48px;
color: var(--vip-content-muted, #595959);
font-size: 0.8125rem;
white-space: nowrap;
}
.courier-information-tab:hover,
.courier-information-tab.is-active { color: var(--vip-content-accent-dark, #856632); }
.courier-information-tab.is-active::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 3px;
background: var(--vip-content-accent, #b9954f);
content: '';
}
.courier-information-filters {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 12px;
background: #fff;
}
.courier-information-field { min-width: 0; }
.courier-information-field label {
display: block;
margin-bottom: 5px;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 500;
}
.courier-information-field input,
.courier-information-field select {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
background: #fff;
color: var(--vip-content-ink, #171717);
font: inherit;
}
.courier-information-field input:focus,
.courier-information-field select:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: 3px solid rgba(133, 102, 50, 0.2);
}
.courier-information-actions {
display: flex;
align-items: flex-end;
gap: 8px;
}
.courier-information-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
background: #fff;
color: var(--vip-content-ink, #171717);
font: inherit;
font-weight: 500;
white-space: nowrap;
}
.courier-information-button:hover { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-soft, #f7f0de); }
.courier-information-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.courier-information-notice {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid rgba(147, 95, 7, 0.35);
border-radius: 8px;
background: rgba(147, 95, 7, 0.06);
color: var(--vip-warning, #935f07);
}
.courier-information-notice.is-error {
border-color: rgba(184, 0, 0, 0.3);
background: rgba(184, 0, 0, 0.05);
color: var(--vip-danger, #b80000);
}
.courier-information-table-wrap {
overflow-x: auto;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 12px;
background: #fff;
}
.courier-information-table {
width: 100%;
min-width: 2260px;
border-collapse: collapse;
font-size: 0.78125rem;
}
.courier-information-table th {
padding: 9px 10px;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: var(--vip-content-accent-soft, #f7f0de);
color: #4f4a43;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.courier-information-table td {
max-width: 240px;
padding: 9px 10px;
border-bottom: 1px solid #eeeae3;
color: var(--vip-content-ink, #171717);
line-height: 1.45;
vertical-align: middle;
white-space: nowrap;
}
.courier-information-table tbody tr:hover { background: #fcfaf5; }
.courier-information-table .is-code { font-variant-numeric: tabular-nums; }
.courier-information-table .is-clamped { overflow: hidden; text-overflow: ellipsis; }
.courier-information-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 7px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 6px;
background: #fff;
color: var(--vip-content-muted, #595959);
}
.courier-information-badge.is-enabled { border-color: rgba(83, 121, 62, .3); background: rgba(83, 121, 62, .08); color: var(--vip-success, #53793e); }
.courier-information-badge.is-disabled { border-color: rgba(184, 0, 0, .25); background: rgba(184, 0, 0, .05); color: var(--vip-danger, #b80000); }
.courier-information-empty { min-height: 160px; padding: 42px 16px !important; color: var(--vip-content-muted, #595959) !important; text-align: center; }
.courier-information-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.courier-information-pagination { display: flex; align-items: center; gap: 6px; }
.courier-information-page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 30px;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 6px;
color: var(--vip-content-ink, #171717);
}
.courier-information-page-link:hover { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-soft, #f7f0de); }
.courier-information-page-link.is-disabled { opacity: .4; pointer-events: none; }
@media (max-width: 1000px) {
.courier-information-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@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-status" role="status">
<div class="courier-information-status__main">
<span class="courier-information-status__dot {{ $statusClass }}" aria-hidden="true"></span>
<span>{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
</div>
<div class="courier-information-status__meta">
@if (!empty($state['last_succeeded_at']))
<span>最近同步 {{ $datetime($state['last_succeeded_at']) }}</span>
@endif
@if (($sync['lag_minutes'] ?? null) !== null)
<span>数据延迟 {{ number_format((int) $sync['lag_minutes']) }} 分钟</span>
@endif
@if (isset($state['mirror_row_count']))
<span>主表 {{ number_format((int) $state['mirror_row_count']) }} 条</span>
@endif
@if ($rows && !$courierError)
<span>当前结果 {{ number_format($rows->total()) }} 条</span>
@endif
</div>
</div>
<nav class="courier-information-tabs" aria-label="快递资料分组">
@foreach ([
'express' => ['icon' => 'local_shipping', 'label' => '快递资料'],
'networks' => ['icon' => 'storefront', 'label' => '网点'],
'accounts' => ['icon' => 'account_balance', 'label' => '打款信息'],
'logs' => ['icon' => 'history', '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>
<span>{{ $item['label'] }}</span>
</a>
@endforeach
</nav>
@if ($tab === 'express')
<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>
<input id="courier-expcode" name="expcode" type="text" value="{{ $filters['expcode'] ?? '' }}" placeholder="精确匹配">
</div>
<div class="courier-information-field">
<label for="courier-expname">快递名称</label>
<input id="courier-expname" name="expname" type="text" value="{{ $filters['expname'] ?? '' }}" placeholder="名称关键词">
</div>
<div class="courier-information-field">
<label for="courier-start">合同开始时间(起)</label>
<input id="courier-start" name="tradebtime_from" type="date" value="{{ $filters['tradebtime_from'] ?? '' }}">
</div>
<div class="courier-information-field">
<label for="courier-end">合同开始时间(止)</label>
<input id="courier-end" name="tradebtime_to" type="date" value="{{ $filters['tradebtime_to'] ?? '' }}">
</div>
<div class="courier-information-field">
<label for="courier-custom-1">自定义1</label>
<input id="courier-custom-1" name="undefined1" type="text" value="{{ $filters['undefined1'] ?? '' }}">
</div>
<div class="courier-information-field">
<label for="courier-custom-2">自定义2</label>
<input id="courier-custom-2" name="undefined2" type="text" value="{{ $filters['undefined2'] ?? '' }}">
</div>
<div class="courier-information-field">
<label for="courier-custom-3">自定义3</label>
<input id="courier-custom-3" name="undefined3" type="text" value="{{ $filters['undefined3'] ?? '' }}">
</div>
<div class="courier-information-field">
<label for="courier-status">是否启用</label>
<select id="courier-status" name="status">
<option value="">全部状态</option>
<option value="1" @selected(($filters['status'] ?? '') === '1')>已启用</option>
<option value="0" @selected(($filters['status'] ?? '') === '0')>已停用</option>
</select>
</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.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>
</div>
</form>
@elseif (in_array($tab, ['networks', 'accounts'], true))
<form method="GET" class="courier-information-filters" aria-label="{{ $tab === 'networks' ? '网点' : '打款信息' }}筛选">
<input type="hidden" name="courier_tab" value="{{ $tab }}">
<div class="courier-information-field">
<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>
</div>
</form>
@endif
@if ($courierError)
<div class="courier-information-notice is-error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $courierError }}</span>
</div>
@elseif ($courierPending)
<div class="courier-information-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">schedule</span>
<span>首次全量同步完成后刷新本页,即可查询工作平台中的快递资料。</span>
</div>
@endif
@if ($tab === 'logs')
<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>
@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')
<div class="courier-information-table-wrap">
<table class="courier-information-table" 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>重量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>
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
@php $enabled = $bool($row->status ?? null) === '是'; @endphp
<tr>
<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">{{ $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">{{ $datetime($row->modified ?? null) }}</td>
</tr>
@empty
<tr><td colspan="25" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}</td></tr>
@endforelse
</tbody>
</table>
@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 === 'networks')
<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>
@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>
@else
<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>
@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>
@endif
</div>

View File

@ -110,6 +110,10 @@ Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@ex
->middleware('business.page:refunds,non-jingxi-refund-data')
->name('business.refunds.non-jingxi-refund-data.export');
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');
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');

View File

@ -0,0 +1,91 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Tests\Feature\FeatureTestCase;
class CourierInformationPageTest extends FeatureTestCase
{
public function test_page_explains_when_courier_mirror_is_not_available(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$repository = Mockery::mock(CourierInformationMirrorRepository::class);
$repository->shouldReceive('relatedAvailability')->once()->andReturn([
'main' => false, 'networks' => false, 'accounts' => false,
'network_ready' => false, 'account_ready' => false,
]);
$repository->shouldReceive('available')->once()->andReturnFalse();
$repository->shouldNotReceive('state');
$repository->shouldNotReceive('syncStatus');
$repository->shouldNotReceive('ready');
$repository->shouldNotReceive('paginate');
$this->app->instance(CourierInformationMirrorRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
]))
->assertOk()
->assertSeeText('快递资料')
->assertSeeText('共享业务镜像尚未建立,快递资料暂时无法读取。')
->assertSeeText('网点')
->assertSeeText('打款信息')
->assertSeeText('操作日志');
}
public function test_page_renders_mirrored_courier_rows_and_filters(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$repository = Mockery::mock(CourierInformationMirrorRepository::class);
$repository->shouldReceive('relatedAvailability')->once()->andReturn([
'main' => true, 'networks' => false, 'accounts' => false,
'network_ready' => false, 'account_ready' => false,
]);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('state')->once()->andReturn([
'status' => 'succeeded',
'initial_completed_at' => '2026-08-28 10:00:00',
'last_succeeded_at' => '2026-08-28 10:00:00',
'mirror_row_count' => 1,
]);
$repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'lag_minutes' => 3]);
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit): bool {
return $filters['expcode'] === 'SF' && $filters['status'] === '1' && $page === 1 && $limit > 0;
})->andReturn([
'data' => [(object) [
'Id' => 1,
'expcode' => 'SF',
'expname' => '顺丰速运',
'status' => 1,
'tradebtime' => '2026-01-01 00:00:00',
]],
'total' => 1,
]);
$this->app->instance(CourierInformationMirrorRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'expcode' => 'SF',
'status' => '1',
]))
->assertOk()
->assertSeeText('顺丰速运')
->assertSeeText('SF')
->assertSeeText('同步正常')
->assertSeeText('可达服务')
->assertSee('name="expcode"', false);
}
}

View File

@ -0,0 +1,191 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class CourierInformationMirrorRepositoryTest extends TestCase
{
private CourierInformationMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
CourierInformationMirrorRepository::DATASET_KEY => $this->definition('wp_express', 'js_wp_express', ['Id'], 'modified'),
CourierInformationMirrorRepository::NETWORK_DATASET_KEY => $this->definition('wp_express_expNet', 'js_wp_express_exp_net', ['id']),
CourierInformationMirrorRepository::ACCOUNT_DATASET_KEY => $this->definition('wp_express_account', 'js_wp_express_account', ['Id']),
CourierInformationMirrorRepository::LOG_DATASET_KEY => $this->definition('wp_express_extension', 'js_wp_express_extension', ['Id'], 'modifyTime'),
],
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->nullable();
$table->string('run_mode')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('mirror_row_count')->default(0);
});
$this->createExpressTable();
$this->createRelatedTables();
$this->repository = new CourierInformationMirrorRepository(
new BusinessMirrorDatasetRegistry(),
new GenericBusinessMirrorRepository(app('db')),
);
}
public function test_it_filters_and_pages_mirrored_courier_records(): void
{
$this->markReady(2);
$this->insertExpress(1, ['expcode' => 'SF', 'expname' => '顺丰', 'status' => 1]);
$this->insertExpress(2, ['expcode' => 'YD', 'expname' => '韵达', 'status' => 0]);
$result = $this->repository->paginate([
'expcode' => 'SF',
'expname' => '',
'undefined1' => '',
'undefined2' => '',
'undefined3' => '',
'tradebtime_from' => '',
'tradebtime_to' => '',
'status' => '1',
], 1, 1);
$this->assertSame(1, $result['total']);
$this->assertSame('SF', $result['data'][0]->expcode);
$this->assertTrue($this->repository->available());
$this->assertTrue($this->repository->ready());
}
public function test_it_reads_networks_and_accounts_by_courier_code(): void
{
$this->markReady(1);
$this->markDatasetReady(CourierInformationMirrorRepository::NETWORK_DATASET_KEY);
$this->markDatasetReady(CourierInformationMirrorRepository::ACCOUNT_DATASET_KEY);
$this->markDatasetReady(CourierInformationMirrorRepository::LOG_DATASET_KEY);
DB::connection('business_mirror')->table('js_wp_express_exp_net')->insert([
'_mirror_key' => 'network-1', 'id' => 1, 'expid' => 1, 'expcode' => 'SF', 'netname' => '浦东网点',
]);
DB::connection('business_mirror')->table('js_wp_express_account')->insert([
'_mirror_key' => 'account-1', 'Id' => 1, 'expcode' => 'SF', 'accountName' => '顺丰账户',
]);
DB::connection('business_mirror')->table('js_wp_express_extension')->insert([
'_mirror_key' => 'log-1', 'Id' => 1, 'expcode' => 'SF', 'doTime' => 2,
'createTime' => '2026-08-28 10:00:00', 'modifyTime' => '2026-08-28 10:00:00',
]);
$networks = $this->repository->paginateNetworks('SF', 1, 20);
$accounts = $this->repository->paginateAccounts('SF', 1, 20);
$logs = $this->repository->paginateLogs('SF', 1, 20);
$this->assertSame(1, $networks['total']);
$this->assertSame('浦东网点', $networks['data'][0]->netname);
$this->assertSame(1, $accounts['total']);
$this->assertSame('顺丰账户', $accounts['data'][0]->accountName);
$this->assertSame(1, $logs['total']);
$this->assertSame(2, $logs['data'][0]->doTime);
}
private function createExpressTable(): void
{
Schema::connection('business_mirror')->create('js_wp_express', function (Blueprint $table): void {
$table->string('_mirror_key')->primary();
$table->integer('Id');
foreach (['expcode', 'expname', 'outcode', 'scexpcode', 'paymethod', 'signcom', 'signmode', 'linker', 'linkmobile', 'paytype', 'undefined1', 'undefined2', 'undefined3', 'undefinedtype', 'undefined4', 'undefined5', 'dzqzjbr', 'guid', 'exp_properties'] as $column) {
$table->string($column)->nullable();
}
foreach (['status', 'addvalidsgn', 'kuaiyunsgn', 'autonosgn', 'packnosgn', 'skucountsplitsgn', 'weight0sgn', 'volume0sgn', 'handnosgn', 'decryptsgn', 'pj1sgn', 'is_delete'] as $column) {
$table->integer($column)->nullable();
}
foreach (['stopdate', 'tradebtime', 'tradeetime', 'modified'] as $column) {
$table->dateTime($column)->nullable();
}
});
}
private function createRelatedTables(): void
{
Schema::connection('business_mirror')->create('js_wp_express_exp_net', function (Blueprint $table): void {
$table->string('_mirror_key')->primary();
$table->integer('id');
$table->integer('expid')->nullable();
foreach (['expcode', 'netcode', 'netno', 'netname', 'isbill', 'province', 'city', 'area', 'town', 'address', 'linker', 'linkermobile', 'status', 'apitype', 'apipltmname', 'created', 'expnum'] as $column) {
$table->string($column)->nullable();
}
});
Schema::connection('business_mirror')->create('js_wp_express_account', function (Blueprint $table): void {
$table->string('_mirror_key')->primary();
$table->integer('Id');
foreach (['expcode', 'expname', 'accountType', 'account', 'accountName', 'accountkhh', 'createTime', 'invoice', 'erpId', 'erpAccountId'] as $column) {
$table->string($column)->nullable();
}
});
Schema::connection('business_mirror')->create('js_wp_express_extension', function (Blueprint $table): void {
$table->string('_mirror_key')->primary();
$table->integer('Id');
$table->string('expcode')->nullable();
$table->integer('doTime')->nullable();
$table->dateTime('createTime')->nullable();
$table->dateTime('modifyTime')->nullable();
});
}
private function insertExpress(int $id, array $overrides): void
{
DB::connection('business_mirror')->table('js_wp_express')->insert(array_merge([
'_mirror_key' => 'express-' . $id,
'Id' => $id,
'expcode' => 'EXP-' . $id,
'expname' => '测试快递',
'status' => 1,
'modified' => '2026-08-28 10:00:00',
], $overrides));
}
private function markReady(int $rows): void
{
$this->markDatasetReady(CourierInformationMirrorRepository::DATASET_KEY, $rows);
}
private function markDatasetReady(string $key, int $rows = 1): void
{
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $key,
'status' => 'succeeded',
'initial_completed_at' => '2026-08-28 10:00:00',
'last_succeeded_at' => '2026-08-28 10:00:00',
'mirror_row_count' => $rows,
]);
}
private function definition(string $object, string $target, array $keys, ?string $watermark = null): array
{
return [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => $object,
'target' => $target,
'keys' => $keys,
'watermark' => $watermark,
];
}
}