feat: 接入快递运费对账明细镜像

This commit is contained in:
renshen 2026-09-08 15:43:08 +08:00
parent 6fa73352d0
commit 0ffd89e9b5
8 changed files with 316 additions and 143 deletions

View File

@ -108,6 +108,9 @@ return [
'jiansun.dbo.pm_unOrderDetails' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '未定价明细'],
'jiansun.dbo.finance_sampleDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '样品扣款'],
'jiansun.dbo.fys_express_courier_settlement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '代发快递'],
'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'],
'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'],
'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'],
'jiansun.dbo.finance_boardDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '板材扣款'],
'jiansun.dbo.pm_plainPlateData' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '素板资料'],
'jiansun.dbo.pm_boardPurchasePrice' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '板材采购价'],

View File

@ -351,6 +351,33 @@ return [
'jiansun.dbo.pm_unOrderDetails' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_unOrderDetails', 'target' => 'js_pm_unorder_details', 'keys' => ['Id']],
'jiansun.dbo.finance_sampleDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_sampleDeduction', 'target' => 'js_finance_sample_deduction', 'keys' => ['id'], 'watermark' => 'finishTime'],
'jiansun.dbo.fys_express_courier_settlement' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_express_courier_settlement', 'target' => 'js_express_courier_settlement', 'keys' => ['Id'], 'watermark' => 'modify_time'],
'jiansun.dbo.jst_order_express_details' => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
'indexes' => [['send_time', 'id'], ['inner_order_id', 'id']],
'columns' => [
'id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name',
'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time',
'weight', 'f_weight',
],
],
'jiansun.dbo.Jst_SendData_View_extension' => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension',
'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null,
'indexes' => [['io_id', 'Id']],
'columns' => [
'Id', 'io_id', 'qty', 'l', 'w', 'h', 'sku_ids', 'sku_name', 'so_id', 'shop_site',
'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city',
'receiver_district', 'expressName', 'wareHoursName', 'volume', 'weight',
],
],
// 只同步运费对账需要的出库字段,不复制收货电话、买家信息等敏感列。
'jiansun.dbo.jst_salesOutboundInfo' => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo',
'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime',
'indexes' => [['io_id', 'Id']],
'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'],
],
'jiansun.dbo.finance_boardDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_boardDeduction', 'target' => 'js_finance_board_deduction', 'keys' => ['id']],
'jiansun.dbo.pm_plainPlateData' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_plainPlateData', 'target' => 'js_pm_plain_plate_data', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_boardPurchasePrice' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardPurchasePrice', 'target' => 'js_pm_board_purchase_price', 'keys' => ['id'], 'watermark' => 'updateTime'],

View File

@ -55,6 +55,7 @@
| 店铺收款账单 | `dbo.alipay_Bill_Detail` |
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |

View File

@ -13,17 +13,26 @@ use Throwable;
/** 快递应付运费对账的共享业务镜像只读访问。 */
class CourierFreightReconciliationMirrorRepository
{
/** 保留旧契约,供现有结算同步和调用方识别。 */
public const DATASET = 'jiansun.dbo.fys_express_courier_settlement';
public const DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details';
public const EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension';
public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo';
/** Only columns present in js_express_courier_settlement may be exposed. */
/** @var list<string> */
private const REQUIRED_DATASETS = [
self::DETAILS_DATASET,
self::EXTENSION_DATASET,
self::OUTBOUND_DATASET,
];
/** 仅暴露附件中有演示值、且已经核对来源的字段。 */
private const COLUMNS = [
'Id', 'month', 'status', 'courier_company_name', 'expresscode', 'expressname',
'replenish_courier', 'opening_balance', 'courier_provided_amount',
'reconciliation_difference', 'confirmed_amount', 'waybill', 'advance_payment',
'payable_amount', 'actual_payment', 'reconciliation_person', 'file_status',
'create_time', 'modify_time', 'is_delete', 'taskid', 'guid', 'creator',
'compensation_amount', 'other_replenishment', 'corporate_payment_amount',
'personal_payment_amount',
'detail_id', 'waybill', 'product_id', 'send_time', 'transaction_order_no',
'province', 'city', 'district', 'barcode', 'express_name', 'express_name_2', 'quantity',
'product_name', 'platform', 'shop_name', 'warehouse', 'volume', 'supplier',
'length', 'width', 'height', 'freight', 'reconciliation_weight',
'reconciliation_volume', 'month', 'status',
];
private readonly ConnectionInterface $connection;
@ -39,10 +48,17 @@ class CourierFreightReconciliationMirrorRepository
{
try {
$schema = Schema::connection($this->connection->getName());
$dataset = $this->datasets->get(self::DATASET);
if (! $schema->hasTable('business_mirror_table_states')) {
return false;
}
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($dataset->targetTable());
foreach (self::REQUIRED_DATASETS as $key) {
if (! $schema->hasTable($this->datasets->get($key)->targetTable())) {
return false;
}
}
return true;
} catch (Throwable) {
return false;
}
@ -51,7 +67,13 @@ class CourierFreightReconciliationMirrorRepository
public function ready(): bool
{
try {
return $this->mirror->ready($this->datasets->get(self::DATASET));
foreach (self::REQUIRED_DATASETS as $key) {
if (! $this->mirror->ready($this->datasets->get($key))) {
return false;
}
}
return true;
} catch (Throwable) {
return false;
}
@ -61,7 +83,36 @@ class CourierFreightReconciliationMirrorRepository
public function state(): ?array
{
try {
return $this->mirror->state($this->datasets->get(self::DATASET));
$states = [];
foreach (self::REQUIRED_DATASETS as $key) {
$state = $this->mirror->state($this->datasets->get($key));
if (is_array($state)) {
$states[$key] = $state;
}
}
if ($states === []) {
return null;
}
$first = $states[self::DETAILS_DATASET] ?? reset($states);
$initial = array_values(array_filter(array_map(
static fn (array $state): ?string => $state['initial_completed_at'] ?? null,
$states,
)));
$succeeded = array_values(array_filter(array_map(
static fn (array $state): ?string => $state['last_succeeded_at'] ?? null,
$states,
)));
$statuses = array_map(static fn (array $state): string => (string) ($state['status'] ?? 'pending'), $states);
$first['initial_completed_at'] = count($initial) === count(self::REQUIRED_DATASETS) ? min($initial) : null;
$first['last_succeeded_at'] = count($succeeded) === count(self::REQUIRED_DATASETS) ? min($succeeded) : null;
$first['status'] = in_array('failed', $statuses, true)
? 'failed'
: (in_array('running', $statuses, true) ? 'running' : (count(array_filter($statuses, static fn (string $value): bool => $value === 'succeeded')) === count(self::REQUIRED_DATASETS) ? 'succeeded' : 'pending'));
$first['datasets'] = $states;
return $first;
} catch (Throwable) {
return null;
}
@ -107,79 +158,111 @@ class CourierFreightReconciliationMirrorRepository
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
if (! $this->ready()) {
throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredQuery($dataset->targetTable(), $filters);
$total = (clone $query)->count();
$query = $this->filteredQuery($filters);
$total = (clone $query)->count('d.id');
$summary = (clone $query)->selectRaw(
'COUNT(*) AS aggregate_count, '
. 'COALESCE(SUM(payable_amount), 0) AS payable_amount, '
. 'COALESCE(SUM(actual_payment), 0) AS actual_payment, '
. 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, '
. 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference'
'COUNT(DISTINCT d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight',
)->first();
$rows = (clone $query)->select(self::COLUMNS)
->orderByDesc('Id')
$rows = (clone $query)->select($this->selectColumns())
->orderByDesc('d.id')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
$state = $this->mirror->state($dataset);
$state = $this->state();
$freight = (float) ($summary->freight ?? 0);
return [
'data' => $rows,
'total' => $total,
'summary' => [
'count' => (int) ($summary->aggregate_count ?? 0),
'payable_amount' => (float) ($summary->payable_amount ?? 0),
'actual_payment' => (float) ($summary->actual_payment ?? 0),
'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0),
'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0),
'freight' => $freight,
'payable_amount' => $freight,
'actual_payment' => 0.0,
'confirmed_amount' => 0.0,
'reconciliation_difference' => 0.0,
],
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
private function filteredQuery(string $table, array $filters): Builder
private function filteredQuery(array $filters): Builder
{
$query = $this->connection->table($table)
->where(static function (Builder $query): void {
$query->whereNull('is_delete')->orWhere('is_delete', '=', 0);
});
$this->containsFilter($query, 'waybill', $filters['express_no'] ?? '');
$this->containsFilter($query, 'expressname', $filters['express_name'] ?? '');
$query = $this->detailQuery()->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', '');
$this->containsFilter($query, 'd.logistic_id', $filters['express_no'] ?? '');
if (($filters['month'] ?? '') !== '') {
$query->where('month', '=', $filters['month']);
if (($filters['express_name'] ?? '') !== '') {
$needle = $this->contains((string) $filters['express_name']);
$query->where(function (Builder $name) use ($needle): void {
$name->whereRaw("d.logistic_company_name LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("x.expressName LIKE ? ESCAPE '!'", [$needle]);
});
}
if (($filters['month'] ?? '') !== '' && preg_match('/^\d{4}-\d{2}$/', (string) $filters['month']) === 1) {
$from = (string) $filters['month'] . '-01';
$query->where('d.send_time', '>=', $from . ' 00:00:00')
->where('d.send_time', '<', date('Y-m-d 00:00:00', strtotime($from . ' +1 month')));
}
if (($filters['status'] ?? '') !== '') {
$query->where('status', '=', $filters['status']);
$query->where('d.logistic_status_name', '=', $filters['status']);
}
if (($filters['modified_from'] ?? '') !== '') {
$query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00');
$query->where('d.updatedAt', '>=', $filters['modified_from'] . ' 00:00:00');
}
if (($filters['modified_to'] ?? '') !== '') {
$query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
$query->where('d.updatedAt', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
}
return $query;
}
private function containsFilter(Builder $query, string $column, mixed $value): void
private function detailQuery(): Builder
{
$value = trim((string) $value);
if ($value === '') {
return;
}
$details = $this->datasets->get(self::DETAILS_DATASET)->targetTable();
$extension = $this->datasets->get(self::EXTENSION_DATASET)->targetTable();
$outbound = $this->datasets->get(self::OUTBOUND_DATASET)->targetTable();
$escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value);
$query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']);
return $this->connection->table($details . ' as d')
->leftJoinSub($this->latestBy($extension, 'so_id', 'Id'), 'x', 'x.so_id', '=', 'd.so_id')
->leftJoinSub($this->latestBy($outbound, 'io_id', 'Id'), 'o', 'o.io_id', '=', 'x.io_id');
}
/** 固定取关联键下 Id 最大的一行,避免一对多重复页面行和运费汇总。 */
private function latestBy(string $table, string $relationColumn, string $idColumn): Builder
{
return $this->connection->table($table . ' as candidate')
->whereNotExists(function (Builder $query) use ($table, $relationColumn, $idColumn): void {
$query->selectRaw('1')
->from($table . ' as newer')
->whereColumn('newer.' . $relationColumn, '=', 'candidate.' . $relationColumn)
->whereColumn('newer.' . $idColumn, '>', 'candidate.' . $idColumn);
});
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'd.id as detail_id', 'd.logistic_id as waybill', 'x.shop_i_id as product_id',
'd.send_time', $this->connection->raw('COALESCE(x.so_id, d.so_id) as transaction_order_no'), 'x.receiver_state as province',
'x.receiver_city as city', 'x.receiver_district as district',
'x.sku_ids as barcode',
'd.logistic_company_name as express_name', 'x.expressName as express_name_2',
'x.qty as quantity', 'x.sku_name as product_name', 'd.platform_name as platform',
$this->connection->raw('COALESCE(x.shop_name, d.shop_name) as shop_name'),
$this->connection->raw('COALESCE(x.wareHoursName, d.warehouse_name) as warehouse'), 'x.volume as volume',
'x.supplier_name as supplier', 'x.l as length', 'x.w as width', 'x.h as height',
'o.freight as freight', 'd.f_weight as reconciliation_weight',
'x.volume as reconciliation_volume', 'd.logistic_status_name as status',
];
}
/** @return array<string,mixed> */
@ -189,10 +272,29 @@ class CourierFreightReconciliationMirrorRepository
foreach (self::COLUMNS as $column) {
$mapped[$column] = $row->{$column} ?? null;
}
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) {
$mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount];
$sendTime = $mapped['send_time'];
$mapped['month'] = is_string($sendTime) ? substr($sendTime, 0, 7) : null;
foreach (['length', 'width', 'height', 'volume', 'freight', 'reconciliation_weight', 'reconciliation_volume'] as $number) {
$mapped[$number] = $mapped[$number] === null || $mapped[$number] === '' ? null : (float) $mapped[$number];
}
if ($mapped['quantity'] !== null && $mapped['quantity'] !== '') {
$mapped['quantity'] = (int) $mapped['quantity'];
}
return $mapped;
}
private function containsFilter(Builder $query, string $column, mixed $value): void
{
$value = trim((string) $value);
if ($value !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
}
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
}

View File

@ -4,6 +4,7 @@
$sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用'];
$money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2);
$number = static fn (mixed $value): string => $value === null || $value === '' ? '-' : rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.');
@endphp
<div class="courier-reconciliation-page">
@ -24,7 +25,7 @@
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像</p>
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.jst_order_express_details + dbo.Jst_SendData_View_extension + dbo.jst_salesOutboundInfo · 共享业务镜像</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
@ -36,16 +37,9 @@
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $freightReconciliationError }}</div>
@endif
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-5">
@foreach ([
['label' => '记录数', 'value' => number_format((int) ($summary['count'] ?? 0))],
['label' => '应付金额', 'value' => '¥ ' . $money($summary['payable_amount'] ?? 0)],
['label' => '已确认金额', 'value' => '¥ ' . $money($summary['confirmed_amount'] ?? 0)],
['label' => '实际付款', 'value' => '¥ ' . $money($summary['actual_payment'] ?? 0)],
['label' => '对账差额', 'value' => '¥ ' . $money($summary['reconciliation_difference'] ?? 0)],
] as $stat)
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">{{ $stat['label'] }}</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ $stat['value'] }}</div></div>
@endforeach
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">记录数</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ number_format((int) ($summary['count'] ?? 0)) }}</div></div>
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">运费合计</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">¥ {{ $money($summary['freight'] ?? $summary['payable_amount'] ?? 0) }}</div></div>
</div>
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
@ -54,7 +48,7 @@
['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'],
['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'],
['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'],
['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'],
['name' => 'status', 'label' => '物流状态', 'placeholder' => '精确匹配'],
] as $field)
<label class="flex min-w-[150px] flex-col gap-1 text-xs text-gray-500"><span>{{ $field['label'] }}</span><input class="recon-control" type="text" name="{{ $field['name'] }}" value="{{ $filters[$field['name']] ?? '' }}" placeholder="{{ $field['placeholder'] }}"></label>
@endforeach
@ -66,18 +60,25 @@
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
<div class="overflow-x-auto"><table class="recon-table min-w-full text-sm"><thead><tr>
<th class="text-left">快递单号</th><th class="text-left">快递名称</th><th class="text-left">月份</th><th class="text-left">状态</th>
<th class="text-right">对账差额</th><th class="text-right">已确认金额</th><th class="text-right">应付金额</th><th class="text-right">实际付款</th><th class="text-right">预付款</th><th class="text-right">赔偿金额</th><th class="text-right">其他补款</th><th class="text-right">公司付款</th><th class="text-right">个人付款</th><th class="text-left">创建时间</th><th class="text-left">修改时间</th>
@foreach (['快递单号','商品ID','发货时间','交易订单号','省','市','区','条形码','快递名称1','快递名称2','数量','商品名称','平台','店铺名称','仓库','体积(cm3)','供应商','长(cm)','宽(cm)','高(cm)','运费','对账重量(kg)','对账体积(cm3)'] as $heading)
<th class="text-left">{{ $heading }}</th>
@endforeach
</tr></thead><tbody>
@forelse ($freightReconciliations ?? [] as $row)
<tr><td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td><td>{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}</td><td>{{ $row['month'] ?? '-' }}</td><td>{{ $row['status'] ?? '-' }}</td>
@foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount)
<td class="text-right tabular-nums">{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}</td>
<tr>
<td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td>
<td>{{ $row['product_id'] ?? '-' }}</td>
<td>{{ $row['send_time'] ?? '-' }}</td>
<td>{{ $row['transaction_order_no'] ?? '-' }}</td>
<td>{{ $row['province'] ?? '-' }}</td><td>{{ $row['city'] ?? '-' }}</td><td>{{ $row['district'] ?? '-' }}</td><td>{{ $row['barcode'] ?? '-' }}</td>
<td>{{ $row['express_name'] ?? '-' }}</td><td>{{ $row['express_name_2'] ?? '-' }}</td><td>{{ $row['quantity'] ?? '-' }}</td>
<td>{{ $row['product_name'] ?? '-' }}</td><td>{{ $row['platform'] ?? '-' }}</td><td>{{ $row['shop_name'] ?? '-' }}</td><td>{{ $row['warehouse'] ?? '-' }}</td>
@foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField)
<td class="text-right tabular-nums">{{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }}</td>
@endforeach
<td>{{ $row['create_time'] ?? '-' }}</td><td>{{ $row['modify_time'] ?? '-' }}</td>
</tr>
@empty
<tr><td colspan="15" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
<tr><td colspan="23" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待物流明细镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
@endforelse
</tbody></table></div>
@if (($freightReconciliations ?? null)?->hasPages())

View File

@ -27,25 +27,19 @@ class PagesTest extends FeatureTestCase
Mockery::type('int'),
)->andReturn([
'data' => [[
'waybill' => 'FIXTURE-WAYBILL',
'expressname' => '测试快递',
'courier_company_name' => '测试快递',
'month' => '2026-08',
'status' => '已确认',
'reconciliation_difference' => 1.25,
'confirmed_amount' => 20.0,
'payable_amount' => 20.0,
'actual_payment' => 18.75,
'advance_payment' => 0.0,
'compensation_amount' => 0.0,
'other_replenishment' => 0.0,
'corporate_payment_amount' => 18.75,
'personal_payment_amount' => 0.0,
'create_time' => '2026-08-01 09:00:00',
'modify_time' => '2026-08-02 09:00:00',
'detail_id' => 1,
'waybill' => 'FIXTURE-WAYBILL', 'product_id' => 'ITEM-1',
'send_time' => '2026-08-01 09:00:00', 'transaction_order_no' => 'SO-1',
'province' => '江苏省', 'city' => '常州市', 'district' => '武进区', 'barcode' => 'SKU-1',
'express_name' => '测试快递', 'express_name_2' => '测试快递', 'quantity' => 1,
'product_name' => '测试商品', 'platform' => '淘宝天猫', 'shop_name' => '测试店铺',
'warehouse' => '测试仓库', 'volume' => 1.2, 'supplier' => '测试供应商',
'length' => 1.0, 'width' => 2.0, 'height' => 3.0, 'freight' => 20.0,
'weight' => 1.25, 'reconciliation_weight' => 1.25, 'reconciliation_volume' => 1.2,
'month' => '2026-08', 'status' => '已发货',
]],
'total' => 1,
'summary' => ['count' => 1, 'payable_amount' => 20.0, 'actual_payment' => 18.75, 'confirmed_amount' => 20.0, 'reconciliation_difference' => 1.25],
'summary' => ['count' => 1, 'freight' => 20.0, 'payable_amount' => 20.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0],
'state' => ['status' => 'succeeded'],
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1],
]);
@ -59,7 +53,11 @@ class PagesTest extends FeatureTestCase
]))
->assertOk()
->assertSeeText('FIXTURE-WAYBILL')
->assertSeeText('应付金额')
->assertSeeText('条形码')
->assertSeeText('交易订单号')
->assertSeeText('对账体积(cm3)')
->assertSeeText('SKU-1')
->assertSeeText('运费合计')
->assertSeeText('20.00')
->assertSeeText('数据新鲜')
->assertSeeText('共享业务镜像')

View File

@ -75,7 +75,7 @@ class BusinessDataPlacementTest extends TestCase
$upstream = require base_path('config/upstream_mirrors.php');
$businessData = require base_path('config/business_data.php');
$this->assertCount(52, $upstream['datasets']);
$this->assertCount(55, $upstream['datasets']);
$upstreamKeys = array_keys($upstream['datasets']);
$policyKeys = array_keys($businessData['datasets']);
sort($upstreamKeys);

View File

@ -21,19 +21,21 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
CourierFreightReconciliationMirrorRepository::DATASET => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'fys_express_courier_settlement',
'target' => 'js_express_courier_settlement',
'keys' => ['Id'],
'watermark' => 'modify_time',
CourierFreightReconciliationMirrorRepository::DETAILS_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
],
CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension',
'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null,
],
CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo',
'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime',
],
],
]);
@ -46,18 +48,30 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
$table->dateTime('last_succeeded_at')->nullable();
$table->string('run_mode')->nullable();
});
Schema::connection('business_mirror')->create('js_express_courier_settlement', function (Blueprint $table): void {
$table->integer('Id');
foreach (['month', 'status', 'courier_company_name', 'expresscode', 'expressname', 'replenish_courier', 'reconciliation_person', 'file_status', 'taskid', 'guid', 'creator'] as $column) {
Schema::connection('business_mirror')->create('js_jst_order_express_details', function (Blueprint $table): void {
$table->integer('id');
foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'platform_name', 'shop_name', 'warehouse_name', 'logistic_status_name'] as $column) {
$table->string($column)->nullable();
}
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $column) {
$table->decimal($column, 20, 6)->nullable();
$table->dateTime('send_time')->nullable();
$table->decimal('weight', 20, 4)->nullable();
$table->decimal('f_weight', 20, 4)->nullable();
});
Schema::connection('business_mirror')->create('js_jst_send_data_view_extension', function (Blueprint $table): void {
$table->integer('Id');
$table->integer('io_id')->nullable();
$table->integer('qty')->nullable();
foreach (['l', 'w', 'h', 'volume'] as $column) {
$table->decimal($column, 20, 4)->nullable();
}
$table->string('waybill')->nullable();
$table->dateTime('create_time')->nullable();
$table->dateTime('modify_time')->nullable();
$table->boolean('is_delete')->nullable();
foreach (['so_id', 'sku_ids', 'sku_name', 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', 'receiver_district', 'expressName', 'wareHoursName'] as $column) {
$table->string($column)->nullable();
}
});
Schema::connection('business_mirror')->create('js_jst_sales_outbound_info', function (Blueprint $table): void {
$table->integer('Id');
$table->integer('io_id')->nullable();
$table->decimal('freight', 20, 4)->nullable();
});
$this->repository = new CourierFreightReconciliationMirrorRepository(
@ -74,51 +88,78 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
$this->repository->paginate([], 1, 25);
}
public function test_it_filters_sorts_and_summarizes_only_approved_mirror_fields(): void
public function test_it_joins_workbook_fields_and_keeps_freight_aggregate_unique(): void
{
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => CourierFreightReconciliationMirrorRepository::DATASET,
'initial_completed_at' => '2026-09-01 09:00:00',
'status' => 'succeeded',
'last_succeeded_at' => now()->toDateTimeString(),
$this->markReady();
DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([
'id' => 1, 'updatedAt' => '2026-09-01 01:00:00', 'so_id' => 'SO-1', 'logistic_id' => 'YT-001',
'logistic_company_name' => '圆通速递', 'platform_name' => '淘宝天猫', 'shop_name' => '店铺A',
'warehouse_name' => '仓库A', 'send_time' => '2026-09-01 00:02:06', 'weight' => 0.8,
'f_weight' => 0.7, 'logistic_status_name' => '已发货',
]);
DB::connection('business_mirror')->table('js_express_courier_settlement')->insert([
$this->row(1, 'YT-001', '圆通速递', 100, 80, 20, '2026-08'),
$this->row(2, 'YT-002', '圆通速递', 50, 50, 0, '2026-08'),
$this->row(3, 'ZT-003', '中通礼品单', 30, 25, 5, '2026-09'),
DB::connection('business_mirror')->table('js_jst_send_data_view_extension')->insert([
['Id' => 10, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 1, 'l' => 10, 'w' => 20, 'h' => 30,
'sku_ids' => 'SKU-OLD', 'sku_name' => '旧商品', 'shop_name' => '旧店铺', 'shop_i_id' => 'OLD-I',
'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市',
'receiver_district' => '武进区', 'expressName' => '圆通旧', 'wareHoursName' => '旧仓', 'volume' => 6000],
['Id' => 11, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 2, 'l' => 11, 'w' => 21, 'h' => 31,
'sku_ids' => 'SKU-1', 'sku_name' => '演示商品', 'shop_name' => '店铺A', 'shop_i_id' => 'ITEM-1',
'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市',
'receiver_district' => '武进区', 'expressName' => '圆通速递', 'wareHoursName' => '仓库A', 'volume' => 7161],
]);
DB::connection('business_mirror')->table('js_jst_sales_outbound_info')->insert([
['Id' => 100, 'io_id' => 100, 'freight' => 0.5],
['Id' => 101, 'io_id' => 100, 'freight' => 0.8],
]);
$result = $this->repository->paginate([
'express_no' => 'YT',
'express_name' => '圆通',
'month' => '2026-08',
], 1, 1);
$result = $this->repository->paginate(['express_no' => 'YT', 'month' => '2026-09'], 1, 25);
$this->assertSame(2, $result['total']);
$this->assertSame('YT-002', $result['data'][0]['waybill']);
$this->assertSame(150.0, $result['summary']['payable_amount']);
$this->assertSame(130.0, $result['summary']['actual_payment']);
$this->assertSame(20.0, $result['summary']['reconciliation_difference']);
$this->assertSame(1, $result['total']);
$this->assertSame('YT-001', $result['data'][0]['waybill']);
$this->assertSame('ITEM-1', $result['data'][0]['product_id']);
$this->assertSame('SKU-1', $result['data'][0]['barcode']);
$this->assertSame('演示商品', $result['data'][0]['product_name']);
$this->assertSame('江苏省', $result['data'][0]['province']);
$this->assertSame(2, $result['data'][0]['quantity']);
$this->assertSame(0.8, $result['data'][0]['freight']);
$this->assertSame(0.8, $result['summary']['freight']);
$this->assertSame('fresh', $result['sync']['code']);
$this->assertArrayNotHasKey('receiver_address', $result['data'][0]);
$this->assertArrayNotHasKey('password', $result['data'][0]);
}
/** @return array<string,mixed> */
private function row(int $id, string $waybill, string $express, float $payable, float $actual, float $difference, string $month): array
public function test_it_keeps_a_shipment_visible_when_optional_detail_rows_are_missing(): void
{
return [
'Id' => $id,
'waybill' => $waybill,
'expressname' => $express,
'courier_company_name' => $express,
'month' => $month,
'status' => '已确认',
'payable_amount' => $payable,
'actual_payment' => $actual,
'confirmed_amount' => $payable,
'reconciliation_difference' => $difference,
'create_time' => '2026-09-07 09:00:00',
'modify_time' => '2026-09-07 10:00:00',
];
$this->markReady();
DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([
'id' => 2, 'updatedAt' => '2026-09-02 01:00:00', 'so_id' => 'SO-2',
'logistic_id' => 'ZT-002', 'logistic_company_name' => '中通快递',
'platform_name' => '京东商城', 'shop_name' => '店铺B', 'warehouse_name' => '仓库B',
'send_time' => '2026-09-02 00:00:00', 'logistic_status_name' => '已发货',
]);
$result = $this->repository->paginate(['express_no' => 'ZT-002'], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('SO-2', $result['data'][0]['transaction_order_no']);
$this->assertSame('店铺B', $result['data'][0]['shop_name']);
$this->assertSame('仓库B', $result['data'][0]['warehouse']);
$this->assertNull($result['data'][0]['product_id']);
$this->assertNull($result['data'][0]['freight']);
}
private function markReady(): void
{
$now = now()->toDateTimeString();
foreach ([
CourierFreightReconciliationMirrorRepository::DETAILS_DATASET,
CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET,
CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET,
] as $dataset) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-01 09:00:00',
'status' => 'succeeded', 'last_succeeded_at' => $now,
]);
}
}
}