From 0ffd89e9b550c4ffdf9a6b2def04e19da8a66bb8 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 8 Sep 2026 15:43:08 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=BF=AB?= =?UTF-8?q?=E9=80=92=E8=BF=90=E8=B4=B9=E5=AF=B9=E8=B4=A6=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/business_data.php | 3 + config/upstream_mirrors.php | 27 +++ docs/上游业务镜像清单.md | 1 + ...rFreightReconciliationMirrorRepository.php | 202 +++++++++++++----- .../freight-reconciliation.blade.php | 39 ++-- tests/Feature/Business/PagesTest.php | 34 ++- .../BusinessDataPlacementTest.php | 2 +- ...ightReconciliationMirrorRepositoryTest.php | 151 ++++++++----- 8 files changed, 316 insertions(+), 143 deletions(-) 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, + ]); + } } } -- 2.47.2 From c67ac02b744cacbf1bc98825e6f181dbfd211b22 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 8 Sep 2026 15:49:07 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E8=BF=90?= =?UTF-8?q?=E8=B4=B9=E5=AF=B9=E8=B4=A6=E4=BE=9B=E5=BA=94=E5=95=86=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/courier-payables/freight-reconciliation.blade.php | 2 +- tests/Feature/Business/PagesTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 65822640..b21854f6 100644 --- a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php +++ b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php @@ -72,7 +72,7 @@ {{ $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['product_name'] ?? '-' }}{{ $row['platform'] ?? '-' }}{{ $row['shop_name'] ?? '-' }}{{ $row['warehouse'] ?? '-' }}{{ $row['supplier'] ?? '-' }} @foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField) {{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }} @endforeach diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 6a7bf181..16a3dd3b 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -57,6 +57,7 @@ class PagesTest extends FeatureTestCase ->assertSeeText('交易订单号') ->assertSeeText('对账体积(cm3)') ->assertSeeText('SKU-1') + ->assertSeeText('测试供应商') ->assertSeeText('运费合计') ->assertSeeText('20.00') ->assertSeeText('数据新鲜') -- 2.47.2 From f2e839600709d875781a3211c069d4437795b700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Tue, 8 Sep 2026 16:19:14 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=86=B2=E5=87=8F?= =?UTF-8?q?=E5=AD=90=E9=A1=B5=E9=9D=A2=E6=94=B9=E4=B8=BA=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=94=AE=E5=90=8E=E8=B4=B9=E7=94=A8=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=90=8C=E6=BA=90=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BillOffsetPageData 注入 OrderDataPageData,page() 改为调用 afterSales(),使账单冲减子页面与订单售后费用明细页同源取数。 - 子页面按 账单冲减.xlsx 表头展示 9 列:内部订单号、线上订单号、店铺名称、店铺编码、商品编码、款式编码、店铺商品编码、店铺款式编码、500112冲减收入。 - 新增查询、重置与“导出当前页”按钮(沿用售后页前端 CSV 导出逻辑),并展示本页 500112 冲减收入合计。 - 500112 列本地镜像暂为 null,接入真实科目来源后自动填充,无需改模板。 --- .../View/BillOffsetPageData.php | 61 +++++++------ .../pages/order-data/bill-offset.blade.php | 88 ++++++++++++++----- 2 files changed, 98 insertions(+), 51 deletions(-) diff --git a/modules/EcommerceFinance/View/BillOffsetPageData.php b/modules/EcommerceFinance/View/BillOffsetPageData.php index befdaeb2..295598fd 100644 --- a/modules/EcommerceFinance/View/BillOffsetPageData.php +++ b/modules/EcommerceFinance/View/BillOffsetPageData.php @@ -2,14 +2,13 @@ namespace Modules\EcommerceFinance\View; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; -use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; +use Illuminate\Pagination\Paginator; +use Modules\EcommerceFinance\View\OrderDataPageData; use Throwable; class BillOffsetPageData { - public function __construct(private readonly BillOffsetMirrorRepository $bills) + public function __construct(private readonly OrderDataPageData $orderData) { } @@ -23,41 +22,39 @@ class BillOffsetPageData 'shop_name' => $this->text(request('shop_name')), 'sku_id' => $this->text(request('sku_id')), ]; - $page = max((int) request('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'), - ]); - $summary = ['amount' => 0.0, 'count' => 0]; - $sync = ['status' => 'unavailable', 'last_succeeded_at' => null]; - $error = null; try { - $result = $this->bills->paginate($filters, $page, $limit); - $rows = new LengthAwarePaginator( - Collection::make($result['data'] ?? []), - (int) ($result['total'] ?? 0), - $limit, - $page, - ['path' => request()->url(), 'query' => request()->except('page')], - ); - $summary = $result['summary'] ?? $summary; - $sync = $result['sync'] ?? $sync; + $data = $this->orderData->afterSales(); } catch (Throwable $exception) { - if (! str_contains($exception->getMessage(), 'bill_offset_mirror_not_ready')) { - report($exception); - } - $error = '账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。'; + report($exception); + $rows = new Paginator([], 25, 1, [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ]); + + return [ + 'view' => 'business.pages.order-data.bill-offset', + 'billOffsetRows' => $rows, + 'billOffsetFilters' => $filters, + 'billOffsetError' => '订单售后费用数据暂时无法读取,请稍后重试。', + 'billOffsetStatus' => null, + 'billOffsetPageAmount' => 0.0, + ]; + } + + $rows = $data['orderDataRows']; + $pageAmount = 0.0; + foreach (($rows->items() ?? []) as $item) { + $pageAmount += (float) (data_get($item, 'account_500112') ?? 0); } return [ 'view' => 'business.pages.order-data.bill-offset', 'billOffsetRows' => $rows, - 'billOffsetFilters' => $filters, - 'billOffsetSummary' => $summary, - 'billOffsetSync' => $sync, - 'billOffsetError' => $error, + 'billOffsetFilters' => $data['orderDataFilters'] ?? $filters, + 'billOffsetError' => $data['orderDataError'] ?? null, + 'billOffsetStatus' => data_get($data, 'orderDataStatus.X-Caiwu-Data-Status'), + 'billOffsetPageAmount' => $pageAmount, ]; } @@ -69,9 +66,11 @@ class BillOffsetPageData private function date(mixed $value): string { $value = trim((string) $value); + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) { return ''; } + return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : ''; } } diff --git a/resources/views/business/pages/order-data/bill-offset.blade.php b/resources/views/business/pages/order-data/bill-offset.blade.php index 6fe889cd..f5bd45c3 100644 --- a/resources/views/business/pages/order-data/bill-offset.blade.php +++ b/resources/views/business/pages/order-data/bill-offset.blade.php @@ -1,19 +1,21 @@ @php $rows = $billOffsetRows ?? null; $filters = $billOffsetFilters ?? []; - $summary = $billOffsetSummary ?? ['amount' => 0, 'count' => 0]; - $sync = $billOffsetSync ?? ['status' => 'unavailable', 'last_succeeded_at' => null]; + $error = $billOffsetError ?? null; + $status = $billOffsetStatus ?? null; + $pageAmount = (float) ($billOffsetPageAmount ?? 0); $columns = [ 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', 'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码', - 'style_code' => '款式编码(参考)', + 'style_code' => '款式编码', 'shop_product_code' => '店铺商品编码', 'shop_style_code' => '店铺款式编码', - 'amount' => '500112冲减收入', + 'account_500112' => '500112冲减收入', ]; + $numberFields = ['account_500112' => true]; @endphp
@@ -31,14 +33,14 @@
- +
- +
@@ -59,33 +61,46 @@
重置 - total() === 0) aria-disabled="true" tabindex="-1" @endif> - 导出全部 - +
- @if ($billOffsetError ?? null) -
{{ $billOffsetError }}
+ @if ($error) +
{{ $error }}
@endif
-
记录数{{ number_format((int) ($summary['count'] ?? 0)) }}
-
冲减收入合计{{ number_format((float) ($summary['amount'] ?? 0), 2) }}
+
本页 500112 冲减收入合计{{ number_format($pageAmount, 2) }}
- @if (!empty($sync['last_succeeded_at'])) -
数据状态:{{ strtoupper((string) ($sync['status'] ?? 'mirror')) }} · 最近同步 {{ $sync['last_succeeded_at'] }}
+ @if ($status) +
数据状态:{{ strtoupper((string) $status) }}
@endif
- @foreach ($columns as $field => $header)@endforeach + @foreach ($columns as $field => $header)@endforeach @forelse ($rows ?? [] as $row) @foreach ($columns as $field => $header) @php $value = data_get($row, $field); @endphp - + @endforeach @empty @@ -95,10 +110,10 @@
$field === 'amount'])>{{ $header }}
isset($numberFields[$field])])>{{ $header }}
$field === 'amount'])>{{ $field === 'amount' ? number_format((float) ($value ?? 0), 2) : (($value === null || $value === '') ? '-' : $value) }} isset($numberFields[$field])]) + >{{ isset($numberFields[$field]) ? (($value === null || $value === '') ? '-' : number_format((float) $value, 2)) : (($value === null || $value === '') ? '-' : $value) }}
- 当前页 {{ $rows?->count() ?? 0 }} 条 · 共 {{ $rows?->total() ?? 0 }} 条 + 当前页 {{ $rows?->count() ?? 0 }} 条 @if ($status) · {{ strtoupper((string) $status) }} @endif
!$rows || $rows->onFirstPage()]) aria-label="上一页"> - 第 {{ $rows?->currentPage() ?? 1 }} / {{ $rows?->lastPage() ?? 1 }} 页 + 第 {{ $rows?->currentPage() ?? 1 }} 页 !$rows?->hasMorePages()]) aria-label="下一页">
@@ -108,10 +123,43 @@ -- 2.47.2 From 398c80f5a8c98364d19747311c799f05e454767f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Tue, 8 Sep 2026 16:41:24 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=86=B2=E5=87=8F/?= =?UTF-8?q?=E5=94=AE=E5=90=8E=E6=98=8E=E7=BB=86=EF=BC=9A=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20500112=20=E5=86=B2=E5=87=8F=E6=94=B6=E5=85=A5=E5=8F=96?= =?UTF-8?q?=E6=95=B0=EF=BC=88=E6=9A=82=E7=94=A8=E9=80=80=E8=B4=A7=E7=BA=A2?= =?UTF-8?q?=E5=86=B2=E9=87=91=E9=A2=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderDataRepository 与 OrderDataMirrorRepository 的 afterSales() 现对 account_500112 取数:\n 暂以「退货红冲金额(reversal_order_amount,即负值商品金额的绝对值)」作为收入冲减口径。\n- 该字段此前在 expenseFields() 中仅被列示、从未取值,故本地恒为 null;\n 其余 account_* 科目字段仍为空,待科目归集(金蝶/外部科目源)整体接入。\n- 取数口径为临时占位,待财务确认最终规则后替换(集中在一处,易于调整)。\n- 账单冲减子页面本页 500112 合计已可正常汇总。 --- .../Repositories/OrderDataMirrorRepository.php | 3 +++ modules/EcommerceFinance/Repositories/OrderDataRepository.php | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php index b3b0abdc..08d99ac1 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php @@ -251,6 +251,9 @@ class OrderDataMirrorRepository foreach ($this->expenseFields() as $field) { $row[$field] = null; } + // 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径, + // 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。 + $row['account_500112'] = round((float) ($row['reversal_order_amount'] ?? 0), 2); unset($row['shop_site'], $row['unit_cost'], $row['sku_name'], $row['seller_income_amount'], $row['src_combine_sku_id'], $row['send_warehouse'], $row['wms_co_id']); return $row; diff --git a/modules/EcommerceFinance/Repositories/OrderDataRepository.php b/modules/EcommerceFinance/Repositories/OrderDataRepository.php index 70ba60f5..e22e3a6b 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataRepository.php @@ -271,6 +271,10 @@ class OrderDataRepository $row[$field] = $this->decimal($row[$field] ?? null); } + // 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径, + // 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。 + $row['account_500112'] = $this->decimal($row['reversal_order_amount'] ?? null); + foreach ($this->amountFields() as $field) { if (array_key_exists($field, $row)) { $row[$field] = $this->decimal($row[$field]) ?? 0.0; -- 2.47.2 From ee41eaac86033e8c4b9b0631f179857f88ff4a1f Mon Sep 17 00:00:00 2001 From: chenxi Date: Wed, 9 Sep 2026 10:47:37 +0800 Subject: [PATCH 05/12] chore: publish table interaction task scope -- 2.47.2 From 056f8321e51d8eb40410991c25278d2d530504d8 Mon Sep 17 00:00:00 2001 From: chenxi Date: Wed, 9 Sep 2026 12:17:38 +0800 Subject: [PATCH 06/12] feat: unify receiving bill table interactions --- .../Http/Controllers/AccountBills.php | 61 ++ .../Http/Controllers/AlipayBills.php | 71 ++- .../js/components/business/AccountBills.vue | 372 ++---------- .../business/TableColumnSettings.vue | 224 ++++++++ .../components/business/TaobaoAlipayBills.vue | 537 +++--------------- resources/views/business/pages/show.blade.php | 1 + 6 files changed, 492 insertions(+), 774 deletions(-) create mode 100644 resources/assets/js/components/business/TableColumnSettings.vue diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index 1b8e5104..beccc706 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -25,6 +25,17 @@ class AccountBills extends Controller abort(404, '未知的账户类型。'); } + $request->validate([ + 'store_name' => ['nullable', 'string', 'max:255'], + 'bill_item' => ['nullable', 'string', 'max:255'], + 'start' => ['nullable', 'date_format:Y-m-d'], + 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], + 'keyword' => ['nullable', 'string', 'max:255'], + 'export' => ['nullable', 'boolean'], + 'columns' => ['nullable', 'array', 'max:40'], + 'columns.*' => ['string', 'max:128'], + ]); + $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $billType); @@ -60,6 +71,10 @@ class AccountBills extends Controller $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $query->orderBy($sort, $dir); + if ($request->boolean('export')) { + return $this->export($request, $query, $billType); + } + $perPage = min(max((int) $request->get('limit', 10), 1), 100); $rows = $query->paginate($perPage); @@ -87,6 +102,52 @@ class AccountBills extends Controller return response()->json($data); } + private function export(Request $request, $query, string $billType) + { + $definition = AccountBillColumns::for($billType); + $available = collect($definition['columns'] ?? [])->keyBy('field'); + $requested = $request->input('columns', []); + $columns = $requested === [] + ? $available->values() + : collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values(); + + abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); + + return response()->streamDownload(function () use ($query, $columns): void { + $csv = fopen('php://output', 'w'); + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, $columns->pluck('label')->all()); + + foreach ($query->cursor() as $row) { + fputcsv($csv, $columns->map(function (array $column) use ($row): string { + $value = $this->columnValue($row, $column['field']); + + return $this->csvValue($value, $column['kind']); + })->all()); + } + fclose($csv); + }, $billType . '_account_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + private function columnValue(AccountBill $row, string $field): mixed + { + if (str_starts_with($field, 'payload.')) { + return data_get($row->payload ?? [], substr($field, 8), ''); + } + + return $row->getAttribute($field) ?? ''; + } + + private function csvValue(mixed $value, string $kind): string + { + $text = (string) ($value ?? ''); + if ($kind === 'num' && preg_match('/^\d{12,}$/', $text)) { + return "\t" . $text; + } + + return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text; + } + public function show(AccountBill $bill) { $this->authorizeStoreReceivingBills(); diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 7cdcf6bc..14471a83 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -11,6 +11,34 @@ use RuntimeException; class AlipayBills extends Controller { + private const EXPORT_COLUMNS = [ + 'record_code' => '记录编码', + 'store_name' => '原始店铺', + 'custom_remark' => '自定义备注', + 'bill_item' => '账单项目', + 'alipay_account' => '平台渠道', + 'account_order_no' => '账务订单号', + 'business_serial_no' => '业务流水号', + 'merchant_order_no' => '商户订单号', + 'business_base_order_no' => '业务基础订单号', + 'goods_name' => '商品编码', + 'counterparty_account' => '对方账号', + 'occurred_at' => '发生时间', + 'income' => '收入', + 'expense' => '支出', + 'balance' => '收支净额', + 'business_type' => '业务类型', + 'remark' => '备注', + 'online_order_no' => '线上单号', + 'online_real_store' => '线上单号对应实际店铺', + 'business_description' => '业务描述', + 'business_bill_source' => '业务账单来源', + 'business_order_no' => '业务订单号', + 'created_at' => '创建时间', + 'updated_at' => '修改时间', + 'modifier' => '修改人', + ]; + /** * 淘系支付宝账户账单列表(店铺收款账户账单 › 淘系支付宝账户)。 * 经由 admin 路由组(web 会话)暴露,前端带 CSRF 调用即可。 @@ -68,8 +96,13 @@ class AlipayBills extends Controller 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], 'keyword' => ['nullable', 'string', 'max:255'], + 'columns' => ['nullable', 'array', 'max:25'], + 'columns.*' => ['string', 'max:64'], ]); + $columns = $this->requestedExportColumns($filters['columns'] ?? []); + unset($filters['columns']); + try { $rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters)); } catch (RuntimeException $exception) { @@ -80,17 +113,49 @@ class AlipayBills extends Controller return response()->json(['message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。'], 503); } - return response()->streamDownload(function () use ($rows): void { + return response()->streamDownload(function () use ($rows, $columns): void { $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); - fputcsv($csv, ['记录编码', '原始店铺', '平台渠道', '账单项目', '账务订单号', '业务流水号', '商户订单号', '业务基础订单号', '商品编码', '发生时间', '收入(元)', '支出(元)', '收支净额(元)', '收支类型', '线上单号', '业务描述', '业务账单来源', '创建时间', '修改时间']); + fputcsv($csv, array_values($columns)); foreach ($rows as $row) { - fputcsv($csv, [$row['record_code'], $row['store_name'], $row['alipay_account'], $row['bill_item'], $row['account_order_no'], $row['business_serial_no'], $row['merchant_order_no'], $row['business_base_order_no'], $row['goods_name'], $row['occurred_at'], $row['income'], $row['expense'], $row['balance'], $row['business_type'], $row['online_order_no'], $row['business_description'], $row['business_bill_source'], $row['created_at'], $row['updated_at']]); + fputcsv($csv, array_map( + fn (string $field): string => $this->csvValue($row[$field] ?? '', $field), + array_keys($columns), + )); } fclose($csv); }, 'taobao_alipay_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); } + /** @return array */ + private function requestedExportColumns(array $requested): array + { + if ($requested === []) { + return self::EXPORT_COLUMNS; + } + + $columns = []; + foreach ($requested as $field) { + if (isset(self::EXPORT_COLUMNS[$field])) { + $columns[$field] = self::EXPORT_COLUMNS[$field]; + } + } + + abort_if($columns === [], 422, '至少选择一个可导出的字段。'); + + return $columns; + } + + private function csvValue(mixed $value, string $field): string + { + $text = (string) ($value ?? ''); + if (preg_match('/(?:_no|_code|serial|goods_name)$/', $field) && preg_match('/^\d{12,}$/', $text)) { + return "\t" . $text; + } + + return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text; + } + public function show(AlipayBill $bill) { $this->authorizeStoreReceivingBills(); diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 9a3dea5b..a32aed35 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -1,342 +1,72 @@ diff --git a/resources/assets/js/components/business/TableColumnSettings.vue b/resources/assets/js/components/business/TableColumnSettings.vue new file mode 100644 index 00000000..938a7441 --- /dev/null +++ b/resources/assets/js/components/business/TableColumnSettings.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 6b5d9c6a..096997ae 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -1,482 +1,119 @@ diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index 416d3a0f..540626c1 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -1060,6 +1060,7 @@ 'biz-card mt-6', 'business-blank-page' => ($page['slug'] ?? null) === 'store-receiving-bills', ]) + @if (($page['slug'] ?? null) === 'store-receiving-bills') data-table-preferences-user="{{ user()->id }}" @endif aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}" >
-- 2.47.2 From 3d14eb3b7e9e3e61f9be314f160585edca18f863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Wed, 9 Sep 2026 12:18:06 +0800 Subject: [PATCH 07/12] fix: use original bill amount for 500112 offset --- .../OrderDataMirrorRepository.php | 3 - .../Repositories/OrderDataRepository.php | 4 - .../View/BillOffsetPageData.php | 64 +++++++------- .../pages/order-data/bill-offset.blade.php | 84 +++++-------------- tests/Feature/Business/PagesTest.php | 52 ++++++++++++ .../BillOffsetMirrorRepositoryTest.php | 7 ++ .../OrderDataMirrorRepositoryTest.php | 1 + .../OrderDataRepositoryTest.php | 1 + 8 files changed, 114 insertions(+), 102 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php index 08d99ac1..b3b0abdc 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php @@ -251,9 +251,6 @@ class OrderDataMirrorRepository foreach ($this->expenseFields() as $field) { $row[$field] = null; } - // 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径, - // 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。 - $row['account_500112'] = round((float) ($row['reversal_order_amount'] ?? 0), 2); unset($row['shop_site'], $row['unit_cost'], $row['sku_name'], $row['seller_income_amount'], $row['src_combine_sku_id'], $row['send_warehouse'], $row['wms_co_id']); return $row; diff --git a/modules/EcommerceFinance/Repositories/OrderDataRepository.php b/modules/EcommerceFinance/Repositories/OrderDataRepository.php index e22e3a6b..70ba60f5 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataRepository.php @@ -271,10 +271,6 @@ class OrderDataRepository $row[$field] = $this->decimal($row[$field] ?? null); } - // 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径, - // 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。 - $row['account_500112'] = $this->decimal($row['reversal_order_amount'] ?? null); - foreach ($this->amountFields() as $field) { if (array_key_exists($field, $row)) { $row[$field] = $this->decimal($row[$field]) ?? 0.0; diff --git a/modules/EcommerceFinance/View/BillOffsetPageData.php b/modules/EcommerceFinance/View/BillOffsetPageData.php index 295598fd..68ebb8d1 100644 --- a/modules/EcommerceFinance/View/BillOffsetPageData.php +++ b/modules/EcommerceFinance/View/BillOffsetPageData.php @@ -2,13 +2,14 @@ namespace Modules\EcommerceFinance\View; -use Illuminate\Pagination\Paginator; -use Modules\EcommerceFinance\View\OrderDataPageData; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository; use Throwable; class BillOffsetPageData { - public function __construct(private readonly OrderDataPageData $orderData) + public function __construct(private readonly BillOffsetMirrorRepository $bills) { } @@ -22,39 +23,44 @@ class BillOffsetPageData 'shop_name' => $this->text(request('shop_name')), 'sku_id' => $this->text(request('sku_id')), ]; + $page = max((int) request('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'), + ]); + $summary = ['amount' => 0.0, 'count' => 0]; + $sync = ['status' => 'unavailable', 'last_succeeded_at' => null]; + $error = null; try { - $data = $this->orderData->afterSales(); + $result = $this->bills->paginate($filters, $page, $limit); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => request()->url(), 'query' => request()->except('page')], + ); + $summary = $result['summary'] ?? $summary; + $sync = $result['sync'] ?? $sync; } catch (Throwable $exception) { - report($exception); - $rows = new Paginator([], 25, 1, [ - 'path' => request()->url(), - 'query' => request()->except('page'), - ]); - - return [ - 'view' => 'business.pages.order-data.bill-offset', - 'billOffsetRows' => $rows, - 'billOffsetFilters' => $filters, - 'billOffsetError' => '订单售后费用数据暂时无法读取,请稍后重试。', - 'billOffsetStatus' => null, - 'billOffsetPageAmount' => 0.0, - ]; - } - - $rows = $data['orderDataRows']; - $pageAmount = 0.0; - foreach (($rows->items() ?? []) as $item) { - $pageAmount += (float) (data_get($item, 'account_500112') ?? 0); + $mirrorNotReady = str_contains($exception->getMessage(), 'bill_offset_mirror_not_ready'); + if (! $mirrorNotReady) { + report($exception); + } + $error = $mirrorNotReady + ? '账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。' + : '账单冲减数据暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.order-data.bill-offset', 'billOffsetRows' => $rows, - 'billOffsetFilters' => $data['orderDataFilters'] ?? $filters, - 'billOffsetError' => $data['orderDataError'] ?? null, - 'billOffsetStatus' => data_get($data, 'orderDataStatus.X-Caiwu-Data-Status'), - 'billOffsetPageAmount' => $pageAmount, + 'billOffsetFilters' => $filters, + 'billOffsetSummary' => $summary, + 'billOffsetSync' => $sync, + 'billOffsetError' => $error, ]; } @@ -66,11 +72,9 @@ class BillOffsetPageData private function date(mixed $value): string { $value = trim((string) $value); - if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) { return ''; } - return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : ''; } } diff --git a/resources/views/business/pages/order-data/bill-offset.blade.php b/resources/views/business/pages/order-data/bill-offset.blade.php index f5bd45c3..9bb8df8a 100644 --- a/resources/views/business/pages/order-data/bill-offset.blade.php +++ b/resources/views/business/pages/order-data/bill-offset.blade.php @@ -1,21 +1,19 @@ @php $rows = $billOffsetRows ?? null; $filters = $billOffsetFilters ?? []; - $error = $billOffsetError ?? null; - $status = $billOffsetStatus ?? null; - $pageAmount = (float) ($billOffsetPageAmount ?? 0); + $summary = $billOffsetSummary ?? ['amount' => 0, 'count' => 0]; + $sync = $billOffsetSync ?? ['status' => 'unavailable', 'last_succeeded_at' => null]; $columns = [ 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', 'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码', - 'style_code' => '款式编码', + 'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码', 'shop_style_code' => '店铺款式编码', - 'account_500112' => '500112冲减收入', + 'amount' => '500112冲减收入', ]; - $numberFields = ['account_500112' => true]; @endphp
@@ -33,14 +31,14 @@
- +
- +
@@ -61,46 +59,33 @@
重置 - + total() === 0) aria-disabled="true" tabindex="-1" @endif> + 导出全部 +
- @if ($error) -
{{ $error }}
+ @if ($billOffsetError ?? null) +
{{ $billOffsetError }}
@endif
-
本页 500112 冲减收入合计{{ number_format($pageAmount, 2) }}
+
记录数{{ number_format((int) ($summary['count'] ?? 0)) }}
+
冲减收入合计{{ number_format((float) ($summary['amount'] ?? 0), 2) }}
- @if ($status) -
数据状态:{{ strtoupper((string) $status) }}
+ @if (!empty($sync['last_succeeded_at'])) +
数据状态:{{ strtoupper((string) ($sync['status'] ?? 'mirror')) }} · 最近同步 {{ $sync['last_succeeded_at'] }}
@endif
- @foreach ($columns as $field => $header)@endforeach + @foreach ($columns as $field => $header)@endforeach @forelse ($rows ?? [] as $row) @foreach ($columns as $field => $header) @php $value = data_get($row, $field); @endphp - + @endforeach @empty @@ -110,10 +95,10 @@
isset($numberFields[$field])])>{{ $header }}
$field === 'amount'])>{{ $header }}
isset($numberFields[$field])]) - >{{ isset($numberFields[$field]) ? (($value === null || $value === '') ? '-' : number_format((float) $value, 2)) : (($value === null || $value === '') ? '-' : $value) }} $field === 'amount'])>{{ $field === 'amount' ? number_format((float) ($value ?? 0), 2) : (($value === null || $value === '') ? '-' : $value) }}
- 当前页 {{ $rows?->count() ?? 0 }} 条 @if ($status) · {{ strtoupper((string) $status) }} @endif + 当前页 {{ $rows?->count() ?? 0 }} 条 · 共 {{ $rows?->total() ?? 0 }} 条
!$rows || $rows->onFirstPage()]) aria-label="上一页"> - 第 {{ $rows?->currentPage() ?? 1 }} 页 + 第 {{ $rows?->currentPage() ?? 1 }} / {{ $rows?->lastPage() ?? 1 }} 页 !$rows?->hasMorePages()]) aria-label="下一页">
@@ -130,36 +115,5 @@ trigger.addEventListener('click', () => { input.focus(); if (typeof input.showPicker === 'function') input.showPicker(); else input.click(); }); }); - const button = document.getElementById('bill-offset-export-button'); - if (!button || button.dataset.exportBound === 'true') return; - button.dataset.exportBound = 'true'; - button.addEventListener('click', () => { - const table = document.getElementById(button.dataset.exportTable || ''); - const bodyRows = table ? Array.from(table.querySelectorAll('tbody tr:not(.is-empty)')) : []; - if (!table || bodyRows.length === 0) return; - - const protectSpreadsheetFormula = (value) => /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; - const csvCell = (value, isNumber = false) => { - const raw = String(value ?? '').replace(/\r\n?/g, '\n'); - const normalized = isNumber ? raw : protectSpreadsheetFormula(raw); - return `"${normalized.replace(/"/g, '""')}"`; - }; - const header = Array.from(table.querySelectorAll('thead th')) - .map((cell) => csvCell(cell.textContent.trim())); - const rows = bodyRows.map((row) => Array.from(row.querySelectorAll('td')) - .map((cell) => csvCell( - cell.dataset.exportValue ?? cell.textContent.trim(), - cell.dataset.exportNumber === 'true', - ))); - const csv = [header, ...rows].map((row) => row.join(',')).join('\r\n'); - const blob = new Blob(['', csv], { type: 'text/csv;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = button.dataset.exportFilename || '账单冲减.csv'; - document.body.appendChild(link); - link.click(); - window.setTimeout(() => { link.remove(); URL.revokeObjectURL(url); }, 1000); - }); })(); diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index d8d037ca..5c09a8da 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -306,6 +306,22 @@ class PagesTest extends FeatureTestCase ->assertSeeText('数据状态:SUCCEEDED'); } + public function testBillOffsetPageShowsReadFailureSeparatelyFromAnUnreadyMirror(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + $repository = Mockery::mock(BillOffsetMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andThrow(new \RuntimeException('database unavailable')); + $this->app->instance(BillOffsetMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'order-data', 'page' => 'bill-offset', + ]))->assertOk() + ->assertSeeText('账单冲减数据暂时无法读取,请稍后重试。') + ->assertDontSeeText('账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。'); + } + public function testBillOffsetExportReturnsCsvForTheFilteredRows(): void { $this->user->attachPermission( @@ -338,6 +354,42 @@ class PagesTest extends FeatureTestCase $this->assertStringContainsString('-20', $csv); } + public function testBillOffsetExportIncludesAllPagesForTheSameFilters(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + $repository = Mockery::mock(BillOffsetMirrorRepository::class); + $filters = [ + 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '', + ]; + $repository->shouldReceive('paginate')->once()->with($filters, 1, 100)->andReturn([ + 'data' => [[ + 'internal_order_no' => 'ORDER-1', 'online_order_no' => 'ONLINE-1', 'shop_name' => '店铺一', + 'shop_code' => 'SHOP-1', 'product_code' => 'SKU-1', 'style_code' => 'STYLE-1', + 'shop_product_code' => 'SP-1', 'shop_style_code' => 'SI-1', 'amount' => -20, + ]], + 'total' => 101, 'sync' => ['status' => 'succeeded'], + ]); + $repository->shouldReceive('paginate')->once()->with($filters, 2, 100)->andReturn([ + 'data' => [[ + 'internal_order_no' => 'ORDER-101', 'online_order_no' => 'ONLINE-101', 'shop_name' => '店铺一', + 'shop_code' => 'SHOP-1', 'product_code' => 'SKU-101', 'style_code' => 'STYLE-101', + 'shop_product_code' => 'SP-101', 'shop_style_code' => 'SI-101', 'amount' => 18.25, + ]], + 'total' => 101, 'sync' => ['status' => 'succeeded'], + ]); + $this->app->instance(BillOffsetMirrorRepository::class, $repository); + + $csv = $this->loginAs()->get(route('business.bill-offset.export', [ + 'from' => '2026-08-01', 'to' => '2026-08-31', + ]))->streamedContent(); + + $this->assertStringContainsString('ORDER-1', $csv); + $this->assertStringContainsString('ORDER-101', $csv); + $this->assertSame(3, substr_count($csv, "\n")); + } + public function testFinancialAfterSalesPageRendersMirroredJushuitanRefunds(): void { $this->user->attachPermission( diff --git a/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php index 7e82c470..569f9739 100644 --- a/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php @@ -64,11 +64,13 @@ class BillOffsetMirrorRepositoryTest extends TestCase DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([ ['Id' => 1, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => -20], ['Id' => 2, 'fee_time' => '2026-08-02 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-2', 'sku_id' => 'SKU-2', 'amount' => 18.25], + ['Id' => 3, 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-3', 'sku_id' => 'SKU-3', 'amount' => 7], ['Id' => 4, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '订单收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => 100], ]); DB::connection('business_mirror')->table('ops_jst_orders')->insert([ ['o_id' => 1001, 'so_id' => 'SO-1', 'shop_name' => '测试店铺', 'shop_id' => 'SHOP-1'], ['o_id' => 1002, 'so_id' => 'SO-2', 'shop_name' => '其他店铺', 'shop_id' => 'SHOP-2'], + ['o_id' => 1003, 'so_id' => 'SO-3', 'shop_name' => '历史店铺', 'shop_id' => 'SHOP-3'], ]); DB::connection('business_mirror')->table('ops_jst_order_item')->insert([ ['o_id' => 1001, 'sku_id' => 'SKU-1', 'i_id' => 'STYLE-1', 'shop_sku_id' => 'SP-1', 'shop_i_id' => 'SI-1'], @@ -82,6 +84,11 @@ class BillOffsetMirrorRepositoryTest extends TestCase $this->assertSame('STYLE-1', $result['data'][0]['style_code']); $this->assertSame(-20.0, $result['summary']['amount']); $this->assertSame('succeeded', $result['sync']['status']); + + $all = $this->repository->paginate([], 1, 10); + $this->assertSame(3, $all['total']); + $this->assertSame(5.25, $all['summary']['amount']); + $this->assertSame('SO-3', $all['data'][0]['online_order_no']); } public function test_it_rejects_unready_mirror(): void diff --git a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php index f602b41e..fed58fee 100644 --- a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php @@ -76,6 +76,7 @@ class OrderDataMirrorRepositoryTest extends TestCase $this->assertSame(40.0, $row['product_cost']); $this->assertSame(1.0, $row['post_return_quantity']); $this->assertSame('https://item.jd.com/1001.html', $row['product_link']); + $this->assertNull($row['account_500112']); $this->assertNull($row['account_8001201']); } diff --git a/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php index 2e061c04..de6f9c2a 100644 --- a/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php @@ -101,6 +101,7 @@ class OrderDataRepositoryTest extends TestCase $this->assertSame('https://item.jd.com/1001.html', $result['rows'][0]['product_link']); $this->assertSame('AF-1', $result['rows'][0]['after_sales_no']); $this->assertSame(1.0, $result['rows'][0]['post_return_quantity']); + $this->assertNull($result['rows'][0]['account_500112']); $this->assertNull($result['rows'][0]['account_8001201']); } -- 2.47.2 From e4d48c59eb9c020ae721dec98c29c36a15bae7ca Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 9 Sep 2026 12:24:35 +0800 Subject: [PATCH 08/12] feat: enhance courier surcharge table interactions --- .../courier-surcharge.blade.php | 236 +++++++++++++++++- .../Business/CourierSurchargePageTest.php | 12 +- 2 files changed, 235 insertions(+), 13 deletions(-) diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 5825aa11..87b0fae4 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -6,9 +6,28 @@ $ready = (bool) ($courierSurchargeReady ?? false); $typeUsage = $courierSurchargeTypeUsage ?? []; $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; + $columns = [ + ['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true], + ['key' => 'courier', 'label' => '快递名称', 'exportable' => true], + ['key' => 'scope', 'label' => '快递归属', 'exportable' => true], + ['key' => 'reconciler', 'label' => '对账人', 'exportable' => true], + ['key' => 'type', 'label' => '附加费类型', 'exportable' => true], + ['key' => 'mode', 'label' => '结算方式', 'exportable' => true], + ['key' => 'standard', 'label' => '计价标准', 'exportable' => true], + ['key' => 'condition', 'label' => '生效条件', 'exportable' => true], + ['key' => 'effective_at', 'label' => '生效日期', 'exportable' => true], + ['key' => 'status', 'label' => '状态', 'exportable' => true], + ['key' => 'remark', 'label' => '备注', 'exportable' => true], + ['key' => 'actions', 'label' => '操作', 'fixed' => true, 'exportable' => false], + ]; + $columnStorageKey = sprintf( + 'caiwu:table-columns:%d:%d:courier-surcharge:quotes', + (int) auth()->id(), + (int) company_id(), + ); @endphp -
+
@@ -62,15 +83,31 @@
-
报价规则列表
规则按生效时间应用到后续结算页面
-
+
报价规则列表
规则按生效时间应用到后续结算页面
+
当前筛选结果(全部):{{ $rows->count() }} 条规则编号与操作列固定显示;列设置仅保存于当前用户
+
规则编号快递名称快递归属对账人附加费类型结算方式计价标准生效条件生效日期状态备注操作
+ @foreach ($columns as $column) + + @endforeach + @forelse ($rows as $row) - + + + @empty - + @endforelse
@if (empty($column['fixed']))@endif{{ $column['label'] }}
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
{{ $row['id'] }}{{ $row['courier'] }}{{ $row['scope'] }}{{ $row['reconciler'] }}{{ $row['type'] }}{{ $row['mode'] }}{{ $row['standard'] }}{{ $row['condition'] }}{{ $row['effective_at'] }}{{ $row['status'] }}{{ $row['remark'] ?: '—' }}
@csrf
没有符合条件的报价规则,请点击“新增报价规则”。
没有符合条件的报价规则,请点击“新增报价规则”。
-
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。