diff --git a/config/business_data.php b/config/business_data.php index 01a618bf..68d46c9c 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -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' => '板材采购价'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index ad37961a..018e4c05 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 30956928..ec8844a7 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` | diff --git a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php index 75e8018c..f8b35f97 100644 --- a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php @@ -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 */ + 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 */ + 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 */ @@ -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) . '%'; + } } diff --git a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php index e7082813..65822640 100644 --- a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php +++ b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php @@ -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
@@ -24,7 +25,7 @@

{{ $page['title'] ?? '运费对账' }}

-

数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像

+

数据来源:JianSun · dbo.jst_order_express_details + dbo.Jst_SendData_View_extension + dbo.jst_salesOutboundInfo · 共享业务镜像

{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }} @@ -36,16 +37,9 @@
{{ $freightReconciliationError }}
@endif -
- @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) -
{{ $stat['label'] }}
{{ $stat['value'] }}
- @endforeach +
+
记录数
{{ number_format((int) ($summary['count'] ?? 0)) }}
+
运费合计
¥ {{ $money($summary['freight'] ?? $summary['payable_amount'] ?? 0) }}
@@ -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) @endforeach @@ -66,18 +60,25 @@
- - + @foreach (['快递单号','商品ID','发货时间','交易订单号','省','市','区','条形码','快递名称1','快递名称2','数量','商品名称','平台','店铺名称','仓库','体积(cm3)','供应商','长(cm)','宽(cm)','高(cm)','运费','对账重量(kg)','对账体积(cm3)'] as $heading) + + @endforeach @forelse ($freightReconciliations ?? [] as $row) - - @foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount) - + + + + + + + + + @foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField) + @endforeach - @empty - + @endforelse
快递单号快递名称月份状态对账差额已确认金额应付金额实际付款预付款赔偿金额其他补款公司付款个人付款创建时间修改时间{{ $heading }}
{{ $row['waybill'] ?? '-' }}{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}{{ $row['month'] ?? '-' }}{{ $row['status'] ?? '-' }}{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}
{{ $row['waybill'] ?? '-' }}{{ $row['product_id'] ?? '-' }}{{ $row['send_time'] ?? '-' }}{{ $row['transaction_order_no'] ?? '-' }}{{ $row['province'] ?? '-' }}{{ $row['city'] ?? '-' }}{{ $row['district'] ?? '-' }}{{ $row['barcode'] ?? '-' }}{{ $row['express_name'] ?? '-' }}{{ $row['express_name_2'] ?? '-' }}{{ $row['quantity'] ?? '-' }}{{ $row['product_name'] ?? '-' }}{{ $row['platform'] ?? '-' }}{{ $row['shop_name'] ?? '-' }}{{ $row['warehouse'] ?? '-' }}{{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }}{{ $row['create_time'] ?? '-' }}{{ $row['modify_time'] ?? '-' }}
@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif
@if ($freightReconciliationPending ?? false) 正在等待物流明细镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif
@if (($freightReconciliations ?? null)?->hasPages()) diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 26b1f90c..6a7bf181 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -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('共享业务镜像') diff --git a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php index 63cf644e..b471b74c 100644 --- a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php +++ b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php @@ -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); diff --git a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php index 67bb15ef..2801629b 100644 --- a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php @@ -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 */ - 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, + ]); + } } }