From 28f99acebc1da4f74aa3d419662f682fe49eecbe Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Wed, 23 Sep 2026 16:41:22 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=B0=86=E9=99=84=E5=8A=A0?= =?UTF-8?q?=E8=B4=B9=E6=8A=A5=E4=BB=B7=E5=92=8C=E7=BB=93=E7=AE=97=E6=95=B4?= =?UTF-8?q?=E5=90=88=E4=B8=BA=E5=90=8C=E4=B8=80=E5=85=A5=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E9=A1=B5=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit a83347aef3c9069df2f521df9b4cbe1096255a33) --- app/Http/Controllers/Business/Pages.php | 45 +++- config/business_navigation.php | 9 +- .../courier-surcharge-settlement.blade.php | 201 ++++++++++++++++++ .../courier-surcharge-workspace.blade.php | 63 ++++++ .../Business/CourierSurchargePageTest.php | 3 +- .../CourierSurchargeSettlementPageTest.php | 93 ++++++++ 6 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php create mode 100644 resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php create mode 100644 tests/Feature/Business/CourierSurchargeSettlementPageTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 907d6cc6..e0e96ec9 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -324,6 +324,15 @@ class Pages extends Controller abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); + // Keep the old direct URL working after settlement becomes a tab. + if ($section === 'courier-payables' && $page === 'courier-surcharge-settlement') { + return redirect()->route('business.pages.show', array_merge(request()->query(), [ + 'section' => $section, + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ])); + } + $pages = collect($sectionConfig['pages'] ?? []); if ($pages->isEmpty()) { @@ -390,6 +399,18 @@ class Pages extends Controller } if ($tab === null) { + if ($page === 'courier-surcharge') { + $activeTab = $tabs->first(); + + return view('business.pages.show', $this->viewData( + $sectionConfig, + $pageConfig, + $tabs, + $activeTab, + $this->resolveContent($page, $activeTab['slug']), + )); + } + return $this->redirectToPage($sectionConfig, $pageConfig); } @@ -2645,7 +2666,7 @@ class Pages extends Controller 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'product-costs' => $this->contentForProductCosts(), 'financial-analysis' => $this->contentForFinancialAnalysis(), - 'courier-surcharge' => $this->contentForCourierSurcharge(), + 'courier-surcharge' => $this->contentForCourierSurcharge($tab), 'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page), 'shipping-data' => $this->contentForShippingData(), 'return-pickup-management' => $this->contentForReturnPickupManagement(), @@ -3644,24 +3665,26 @@ class Pages extends Controller ]; } - protected function contentForCourierSurcharge(): array + protected function contentForCourierSurcharge(?string $tab = null): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); + $quoteInput = $tab === 'settlement' ? [] : request()->query(); $filters = [ - 'keyword' => $clean(request('keyword', ''), 40), - 'courier' => $clean(request('courier', ''), 40), - 'type' => $clean(request('type', ''), 40), - 'mode' => $clean(request('mode', ''), 40), - 'scope' => $clean(request('scope', ''), 20), - 'reconciler' => $clean(request('reconciler', ''), 40), - 'supplier' => $clean(request('supplier', ''), 191), - 'status' => $clean(request('status', ''), 20), + 'keyword' => $clean($quoteInput['keyword'] ?? '', 40), + 'courier' => $clean($quoteInput['courier'] ?? '', 40), + 'type' => $clean($quoteInput['type'] ?? '', 40), + 'mode' => $clean($quoteInput['mode'] ?? '', 40), + 'scope' => $clean($quoteInput['scope'] ?? '', 20), + 'reconciler' => $clean($quoteInput['reconciler'] ?? '', 40), + 'supplier' => $clean($quoteInput['supplier'] ?? '', 191), + 'status' => $clean($quoteInput['status'] ?? '', 20), ]; $data = $this->courierSurchargeQuotes->pageData($filters); $auditReady = $this->courierSurchargeAudits->ready(); return [ - 'view' => 'business.pages.courier-payables.courier-surcharge', + 'view' => 'business.pages.courier-payables.courier-surcharge-workspace', + 'courierSurchargeActiveTab' => $tab ?? 'quotes', 'courierSurchargeRows' => $data['rows'], 'courierSurchargeFilters' => $filters, 'courierSurchargeSummary' => $data['summary'], diff --git a/config/business_navigation.php b/config/business_navigation.php index bed51c82..37e03707 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -712,7 +712,14 @@ $navigation = [ ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], ['slug' => 'other-adjustments', 'title' => '其他扣款补款'], - ['slug' => 'courier-surcharge', 'title' => '附加费报价维护'], + [ + 'slug' => 'courier-surcharge', + 'title' => '附加费报价维护', + 'tabs' => [ + ['slug' => 'quotes', 'title' => '附加费报价'], + ['slug' => 'settlement', 'title' => '附加费结算'], + ], + ], ['slug' => 'payable-cloud-warehouse-vehicle-fee', 'title' => '应付云仓车费'], ['slug' => 'courier-payables-report', 'title' => '快递应付表'], ['slug' => 'freight-difference', 'title' => '运费差'], diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php new file mode 100644 index 00000000..20069ec9 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -0,0 +1,201 @@ +@php + $rows = $courierSurchargeSettlementRows ?? collect(); + $columns = [ + ['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'], + ['key' => 'waybill', 'label' => '单号'], + ['key' => 'platform_product_id', 'label' => '平台商品ID'], + ['key' => 'product_code', 'label' => '商品编码'], + ['key' => 'shop_name', 'label' => '店铺名称'], + ['key' => 'online_order_no', 'label' => '线上订单号'], + ['key' => 'length', 'label' => '长'], + ['key' => 'width', 'label' => '宽'], + ['key' => 'height', 'label' => '高'], + ['key' => 'volume', 'label' => '体积'], + ['key' => 'inspection_type', 'label' => '查件类型'], + ['key' => 'inspection_original', 'label' => '查件原件'], + ['key' => 'return_waybill_no', 'label' => '退回单号'], + ['key' => 'settlement_weight', 'label' => '结算重量'], + ['key' => 'settlement_amount', 'label' => '结算金额', 'fixed' => 'end'], + ]; + $filterValue = static fn (string $key): string => trim((string) request()->query($key, '')); + $displayValue = static function (mixed $value, string $key): string { + if ($value === null || $value === '') { + return '—'; + } + + if ($key === 'settlement_amount' && is_numeric($value)) { + return number_format((float) $value, 2, '.', ''); + } + + return (string) $value; + }; +@endphp + +
+ + +
+ +
等待接入结算数据;确认数据来源后,将按附加费报价规则计算。
+
+ +
+
附加费结算
结算标准读取附加费报价维护
+
当前明细
{{ $rows->count() }}
+
结算金额
待接入
+
数据状态
待确认来源
+
+ +
+
+
+
+
+
+
+
重置
+
+ +
+
附加费结算明细
快递名称和结算金额固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
+
当前筛选结果:{{ $rows->count() }} 条报价匹配和金额计算将在确认数据来源后接入
+
+ + + @foreach ($columns as $column) + + @endforeach + + + @forelse ($rows as $row) + + @foreach ($columns as $column) + + @endforeach + + @empty + + @endforelse + +
{{ $column['label'] }}
{{ $displayValue(data_get($row, $column['key']), $column['key']) }}
页面已就绪,等待接入附加费结算数据。
+
+
+
diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php new file mode 100644 index 00000000..41ce8674 --- /dev/null +++ b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php @@ -0,0 +1,63 @@ +@php + $surchargeTabUrls = collect(['quotes', 'settlement'])->mapWithKeys(fn ($tab) => [ + $tab => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => $tab]), + ])->all(); +@endphp +
+ + +
+ + diff --git a/tests/Feature/Business/CourierSurchargePageTest.php b/tests/Feature/Business/CourierSurchargePageTest.php index 133c5c77..1614bf87 100644 --- a/tests/Feature/Business/CourierSurchargePageTest.php +++ b/tests/Feature/Business/CourierSurchargePageTest.php @@ -31,7 +31,8 @@ class CourierSurchargePageTest extends FeatureTestCase 'page' => 'courier-surcharge', ])) ->assertOk() - ->assertSeeText('附加费报价维护') + ->assertSeeText('附加费报价') + ->assertSee('aria-label="附加费报价维护"', false) ->assertSeeText('正式维护数据') ->assertSeeText('报价规则列表') ->assertSeeText('管理附加费类型') diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php new file mode 100644 index 00000000..8fe98792 --- /dev/null +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -0,0 +1,93 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ]))->assertOk() + ->assertSee('data-surcharge-panel="quotes" hidden', false) + ->assertSeeText('附加费结算') + ->assertSeeText('页面已就绪,等待接入附加费结算数据。') + ->assertSee('data-finance-table-name="courier-surcharge-settlement"', false) + ->assertSee('data-finance-fixed="true"', false) + ->assertSee('data-finance-fixed="end"', false) + ->assertSeeText('快递名称') + ->assertSeeText('平台商品ID') + ->assertSeeText('查件原件') + ->assertSeeText('退回单号') + ->assertSeeText('结算重量') + ->assertSeeText('结算金额'); + } + + public function test_settlement_filters_are_preserved_in_the_form(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + 'courier_name' => '测试快递', + 'waybill' => '001234', + 'shop_name' => '测试店铺', + 'online_order_no' => 'SO-001', + 'status' => '待接入', + ]))->assertOk() + ->assertSee('value="测试快递"', false) + ->assertSee('value="001234"', false) + ->assertSee('value="测试店铺"', false) + ->assertSee('value="SO-001"', false) + ->assertSee('value="待接入" selected', false) + ->assertViewHas('content', fn (array $content) => $content['courierSurchargeFilters']['status'] === ''); + } + + public function test_quote_page_exposes_quote_and_settlement_tabs(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + ]))->assertOk() + ->assertSeeText('附加费报价') + ->assertSeeText('附加费结算') + ->assertSee('data-surcharge-panel="settlement" hidden', false) + ->assertSee(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'quotes', + ]), false) + ->assertSee(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + ]), false); + } + + public function test_old_settlement_page_url_redirects_to_settlement_tab(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge-settlement', + 'waybill' => '0000123456789', + ]))->assertRedirect(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge', + 'tab' => 'settlement', + 'waybill' => '0000123456789', + ])); + } +} -- 2.47.2 From d3fee64f80be7bfccede4b00fc87590f2a9af90f Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Sun, 27 Sep 2026 15:00:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=81=9A?= =?UTF-8?q?=E6=B0=B4=E6=BD=AD=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 4b79370faea61aefe05dfe058480600b6fe9981e) --- app/Http/Controllers/Business/Pages.php | 99 ++- config/business_data.php | 6 + config/upstream_mirrors.php | 68 +- docs/上游业务镜像清单.md | 2 + ...ierSurchargeSettlementMirrorRepository.php | 611 ++++++++++++++++++ .../courier-surcharge-settlement.blade.php | 67 +- .../CourierSurchargeSettlementPageTest.php | 15 +- ...ierSurchargeSettlementMirrorConfigTest.php | 59 ++ ...urchargeSettlementMirrorRepositoryTest.php | 198 ++++++ 9 files changed, 1101 insertions(+), 24 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php create mode 100644 tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php create mode 100644 tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index e0e96ec9..3946330b 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -47,6 +47,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; +use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -241,6 +242,8 @@ class Pages extends Controller private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly WithdrawalManagementReport $withdrawalManagementReport, + private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, + private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements, private readonly ShippingOrderRepository $shippingOrders, ) { $this->middleware('auth'); @@ -3682,7 +3685,7 @@ class Pages extends Controller $data = $this->courierSurchargeQuotes->pageData($filters); $auditReady = $this->courierSurchargeAudits->ready(); - return [ + $content = [ 'view' => 'business.pages.courier-payables.courier-surcharge-workspace', 'courierSurchargeActiveTab' => $tab ?? 'quotes', 'courierSurchargeRows' => $data['rows'], @@ -3695,6 +3698,100 @@ class Pages extends Controller 'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()), 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; + + if ($tab === 'settlement') { + $content += $this->contentForCourierSurchargeSettlement(); + } + + return $content; + } + + /** @return array */ + protected function contentForCourierSurchargeSettlement(): array + { + $request = request(); + $filters = $this->courierSurchargeSettlementFilters($request); + $page = max((int) $request->query('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 100); + $rows = new LengthAwarePaginator([], 0, $limit, $page, [ + 'path' => $request->url(), + 'query' => $request->except('page'), + ]); + $state = null; + $sync = ['code' => 'unavailable', 'last_succeeded_at' => null]; + $summary = ['count' => 0, 'settlement_amount' => 0.0]; + $error = null; + $pending = false; + $quoteData = $this->courierSurchargeQuotes->pageData([]); + $quotes = Collection::make($quoteData['rows'] ?? []); + + try { + if (! $this->courierSurchargeSettlements->available()) { + $error = '共享业务镜像尚未建立,附加费结算暂时无法读取。'; + } elseif (! $this->courierSurchargeSettlements->ready()) { + $state = $this->courierSurchargeSettlements->state(); + $sync = [ + 'code' => $state['code'] ?? 'pending', + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + ]; + $pending = ($state['code'] ?? 'pending') !== 'failed'; + $error = ($state['code'] ?? null) === 'failed' + ? '附加费结算镜像同步失败,请联系管理员查看失败阶段。' + : '附加费结算镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。'; + } else { + $result = $this->courierSurchargeSettlements->paginate($filters, $page, $limit, $quotes); + $rows = new LengthAwarePaginator( + Collection::make($result['data'] ?? []), + (int) ($result['total'] ?? 0), + $limit, + $page, + ['path' => $request->url(), 'query' => $request->except('page')], + ); + $state = $result['state'] ?? null; + $sync = $result['sync'] ?? $sync; + $summary = $result['summary'] ?? $summary; + } + } catch (Throwable $exception) { + report($exception); + $error = '附加费结算数据暂时无法读取,请稍后重试。'; + } + + return [ + 'courierSurchargeSettlementRows' => $rows, + 'courierSurchargeSettlementFilters' => $filters, + 'courierSurchargeSettlementSummary' => $summary, + 'courierSurchargeSettlementState' => $state, + 'courierSurchargeSettlementSync' => $sync, + 'courierSurchargeSettlementError' => $error, + 'courierSurchargeSettlementPending' => $pending, + 'courierSurchargeSettlementQuoteReady' => (bool) ($quoteData['ready'] ?? false), + ]; + } + + /** @return array */ + private function courierSurchargeSettlementFilters(Request $request): array + { + $text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length); + $date = static function (mixed $value): string { + $value = trim((string) $value); + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; + }; + + return [ + 'courier_name' => $text($request->query('courier_name')), + 'waybill' => $text($request->query('waybill')), + 'platform_product_id' => $text($request->query('platform_product_id')), + 'product_code' => $text($request->query('product_code')), + 'shop_name' => $text($request->query('shop_name')), + 'online_order_no' => $text($request->query('online_order_no')), + 'inspection_type' => $text($request->query('inspection_type'), 60), + 'inspection_reason' => $text($request->query('inspection_reason'), 120), + 'surcharge_type' => $text($request->query('surcharge_type'), 40), + 'status' => $text($request->query('status'), 40), + 'created_from' => $date($request->query('created_from')), + 'created_to' => $date($request->query('created_to')), + ]; } private function freightReconciliationFilters(): array diff --git a/config/business_data.php b/config/business_data.php index da05a165..5b85eb08 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -135,6 +135,12 @@ 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.ekf_Bookkeeping' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.express_Original' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_BookkeepingConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_rCheck_Piece' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_rCheckPieceConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], + 'jiansun.dbo.ekf_ReturnPickupManagement' => ['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' => '运费对账出库运费'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 5cbe08e6..95579053 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -167,6 +167,15 @@ return [ 'stale_after_minutes' => (int) env('RETURN_PICKUP_MIRROR_STALE_MINUTES', 180), ], + // 快递附加费结算要求所有必需镜像都完成首次同步;页面按最旧数据集 + // 的成功时间判断新鲜度,不把尚未核对的审核文本扩大为已通过数据。 + 'courier_surcharge_settlement' => [ + 'stale_after_minutes' => (int) env('COURIER_SURCHARGE_SETTLEMENT_MIRROR_STALE_MINUTES', 60), + // 聚水潭现有审核文本使用“同意”和“同意记账”;“通过”保留给 + // 后续上游版本,不能把任意非空文本当作审核通过。 + 'approved_values' => ['通过', '同意', '同意记账'], + ], + // 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。 'work_platform_prices' => [ 'dataset_key' => 'jiansun.dbo.pm_priceAdjustment', @@ -241,6 +250,60 @@ return [ 'indexes' => [['scmname', 'Id']], ], // 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。 + // 快递附加费结算:只同步上游审核字段已核对的“通过/同意/同意记账”明细; + // 页面通过共享镜像读取,不在请求中直连聚水潭。 + 'jiansun.dbo.ekf_Bookkeeping' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', + 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', + 'columns' => [ + 'Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', + 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', + 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', + 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', + 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', + 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid', + ], + 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], + 'indexes' => [['ExpressNum', 'Id'], ['OrderNum', 'Id'], ['shopproid', 'Id'], ['CreationTime', 'Id']], + ], + 'jiansun.dbo.express_Original' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', + 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', + 'columns' => [ + 'Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Weight', 'TotalFreight', + 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName', + 'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime', + ], + 'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid', 'Id']], + ], + 'jiansun.dbo.ekf_BookkeepingConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', + 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], + 'indexes' => [['ParentId', 'Id']], + ], + 'jiansun.dbo.ekf_rCheck_Piece' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', + 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', + 'columns' => [ + 'Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', + 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', + 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid', + ], + 'indexes' => [['ExpressNum', 'OrderNum', 'Id'], ['shopproid', 'Id']], + ], + 'jiansun.dbo.ekf_rCheckPieceConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', + 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], + 'indexes' => [['ParentId', 'Id']], + ], + 'jiansun.dbo.ekf_ReturnPickupManagement' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', + 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], + 'indexes' => [['transactionNo', 'Id'], ['orderId', 'Id'], ['returnExpressNo', 'Id']], + ], 'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], @@ -642,7 +705,10 @@ return [ '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'], ['id', 'logistic_id']], + 'indexes' => [ + ['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id'], + ['logistic_id', 'so_id(64)', 'id'], ['logistic_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', diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 587ff703..c2d7991f 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -17,6 +17,7 @@ | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 工作平台“客服系统 → 退款数据 → 退货取件管理”读取 `jiansun/dbo.ekf_ReturnPickupManagement`(主表)、`dbo.ekf_ReturnPickupManagement_Item`(明细分录)与 `dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`(实退快递单号映射),分别镜像为 `js_ekf_return_pickup_management`、`js_ekf_return_pickup_management_item`、`js_ekf_return_pickup_truth_express`;主表按 `Id` + `updateTime`、明细按 `id` + `updatedTime`、映射按 `Id` + `createTime` 增量,逆向物流运费核算与退件跟踪只读共享镜像,不在页面请求中直连工作平台。2026-10-02 已完成首次全量(296,707 / 378,705 / 173,346 行)。 明细 `warehouse` 是混合编码:4 位工作平台仓码(`dbo.wp_warehouse.code` / `dbo.wp_warehouseConfig.whCode`)镜像为 `js_wp_warehouse`(992 行,`id` + `modified`)与 `js_wp_warehouse_config`(132 行,无更新时间按主键全量校准),8 位聚水潭 `wms_co_id` 继续读 `ops_jst_warehouse_info`;页面与导出按名称展示,未命中时保留原始编码。 | +| 本次接入 | 快递附加费结算读取 `jiansun.dbo.ekf_Bookkeeping` 中审核字段为固定白名单“通过/同意/同意记账”的明细,并关联 `jiansun.dbo.express_Original` 的物流公司、尺寸、商品、订单和结算字段,以及记账原因、查件配置、物流明细、发货扩展、出库重量和退件管理镜像;查件类型/查件原因只取 `ekf_rCheckPieceConfig` 的一级/二级选项,记账次要原因不作回退。页面固定限制九种附加费类型,按附加费报价维护匹配并计算。首次同步完成前只显示镜像状态,不把空结果当成无数据。当前上游严格值“通过”没有记录,“同意/同意记账”是否等价于审核通过仍需业务确认;实际样本只直接证明退回/拦截标记,其余类型依赖原因文本关键词,正式上线前需补充类型字段口径。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | @@ -77,6 +78,7 @@ | 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) | | 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) | | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | +| 快递附加费结算 | `dbo.ekf_Bookkeeping`、`dbo.express_Original`、`dbo.ekf_BookkeepingConfig`、`dbo.ekf_rCheck_Piece`、`dbo.ekf_rCheckPieceConfig`、既有物流镜像 `dbo.jst_order_express_details` / `dbo.Jst_SendData_View_extension` / `dbo.jst_salesOutboundInfo`、`dbo.ekf_ReturnPickupManagement`(审核字段固定为“通过/同意/同意记账”;查件原因只读二级选项;页面只读 `caiwu_business_mirror`) | | 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | | 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) | | 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) | diff --git a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php new file mode 100644 index 00000000..0707bfd7 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php @@ -0,0 +1,611 @@ + */ + private const REQUIRED_DATASETS = [ + self::PRIMARY_DATASET, + self::EXPRESS_ORIGINAL_DATASET, + self::BOOKKEEPING_CONFIG_DATASET, + self::CHECK_DATASET, + self::CHECK_CONFIG_DATASET, + self::SHIPPING_DETAILS_DATASET, + self::SHIPPING_EXTENSION_DATASET, + self::OUTBOUND_DATASET, + self::RETURN_DATASET, + ]; + + /** @var list */ + public const SURCHARGE_TYPES = [ + '退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', + ]; + + /** + * 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。 + * 其它说明、退回原因、空值都不能被当作审核通过。 + * + * @var list + */ + private const APPROVED_VALUES = ['通过', '同意', '同意记账']; + + private readonly ConnectionInterface $connection; + + public function __construct( + DatabaseManager $database, + private readonly BusinessMirrorDatasetRegistry $datasets, + private readonly GenericBusinessMirrorRepository $mirror, + ) { + $this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + if (! $schema->hasTable('business_mirror_table_states')) { + return false; + } + + foreach (self::REQUIRED_DATASETS as $key) { + if (! $schema->hasTable($this->table($key))) { + return false; + } + } + + return true; + } + + public function ready(): bool + { + foreach (self::REQUIRED_DATASETS as $key) { + $dataset = $this->datasets->get($key); + if (! $this->mirror->ready($dataset)) { + return false; + } + } + + return true; + } + + /** @return array */ + public function state(): array + { + $states = []; + foreach (self::REQUIRED_DATASETS as $key) { + $dataset = $this->datasets->get($key); + $states[$key] = $this->mirror->state($dataset); + } + + $required = array_intersect_key($states, array_flip(self::REQUIRED_DATASETS)); + $failed = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'failed'); + $pending = collect($required)->first(fn (?array $state): bool => ($state['initial_completed_at'] ?? null) === null); + $running = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'running'); + // The page is only as fresh as its oldest required dataset. Using the + // newest timestamp here would hide a lagging lookup table behind a + // recently completed fact-table sync. + $lastSucceeded = collect($required) + ->map(fn (?array $state): ?string => $state['last_succeeded_at'] ?? null) + ->filter() + ->sort() + ->first(); + $staleMinutes = max((int) config('upstream_mirrors.courier_surcharge_settlement.stale_after_minutes', 60), 1); + $code = $failed !== null + ? 'failed' + : ($pending !== null ? 'pending' : ($running !== null ? 'syncing' : 'fresh')); + if ($code === 'fresh' && $lastSucceeded !== null && now()->diffInMinutes($lastSucceeded) > $staleMinutes) { + $code = 'stale'; + } + + return [ + 'code' => $code, + 'last_succeeded_at' => $lastSucceeded, + 'failure_stage' => $failed === null ? null : $this->datasetKeyForState($required, $failed), + 'error_code' => $failed['last_error_code'] ?? null, + 'required' => $required, + ]; + } + + /** + * @param array $filters + * @param Collection $quotes + * @return array{data:Collection>,has_more:bool,total:int,summary:array,sync:array,state:array} + */ + public function paginate(array $filters, int $page, int $limit, Collection $quotes): array + { + $state = $this->state(); + if (! $this->ready()) { + return [ + 'data' => collect(), 'has_more' => false, 'total' => 0, + 'summary' => ['count' => 0, 'settlement_amount' => 0.0], + 'sync' => $state, 'state' => $state, + ]; + } + + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + $limit = min(max($limit, 1), 100); + if (($filters['status'] ?? '') !== '') { + // 结算状态来自本地报价匹配,不能下推到镜像 SQL。按稳定顺序 + // 流式读取并只保留当前页,避免大镜像表一次性 get() 造成内存峰值。 + $offset = (max($page, 1) - 1) * $limit; + $matched = collect(); + $total = 0; + foreach ($query->orderBy('b.CreationTime')->orderBy('b.Id')->cursor() as $sourceRow) { + $row = $this->mapRow($sourceRow, $quotes); + if ($row['settlement_status'] !== $filters['status']) { + continue; + } + + if ($total >= $offset && $matched->count() < $limit) { + $matched->push($row); + } + $total++; + } + $rows = $matched; + } else { + $total = (clone $query)->count('b.Id'); + $rows = $query + ->orderBy('b.CreationTime') + ->orderBy('b.Id') + ->forPage(max($page, 1), $limit + 1) + ->get() + ->map(fn (object $row): array => $this->mapRow($row, $quotes)) + ->values(); + } + $hasMore = ($filters['status'] ?? '') !== '' + ? $total > ((max($page, 1) - 1) * $limit) + $limit + : $rows->count() > $limit; + $rows = $rows->take($limit)->values(); + + return [ + 'data' => $rows, + 'has_more' => $hasMore, + 'total' => $total, + 'summary' => [ + 'count' => $total, + 'settlement_amount' => round((float) $rows->sum(fn (array $row): float => (float) ($row['settlement_amount'] ?? 0)), 2), + ], + 'sync' => $state, + 'state' => $state, + ]; + } + + private function baseQuery(): Builder + { + $bookkeeping = $this->table(self::PRIMARY_DATASET); + $bookkeepingConfig = $this->table(self::BOOKKEEPING_CONFIG_DATASET); + $check = $this->table(self::CHECK_DATASET); + $checkConfig = $this->table(self::CHECK_CONFIG_DATASET); + + $query = $this->connection->table($bookkeeping . ' as b') + ->leftJoin($bookkeepingConfig . ' as bm', 'bm.Id', '=', 'b.ReasonId') + ->leftJoin($bookkeepingConfig . ' as bs', 'bs.Id', '=', 'b.SecodaryCause') + ->leftJoinSub($this->latestCheck(), 'r', function ($join): void { + $join->on('r.ExpressNum', '=', 'b.ExpressNum') + ->on('r.OrderNum', '=', 'b.OrderNum'); + }) + ->leftJoin($checkConfig . ' as rt', 'rt.Id', '=', 'r.CheckType') + ->leftJoin($checkConfig . ' as rr', function ($join): void { + $join->on('rr.Id', '=', 'r.CheckReason') + // 查件原因必须是所选查件类型的二级选项,避免同名配置串层。 + ->on('rr.ParentId', '=', 'r.CheckType'); + }); + + // 镜像同步使用同一固定条件;这里再次约束,避免旧镜像中残留未审核行。 + $query->whereIn('b.IsAgreeReason', $this->approvedValues()); + + // 物流公司、商品、店铺、尺寸和重量复用已经上线的物流业务镜像。 + // 先按单号+线上订单号精确关联,再用单号的最新记录补齐历史数据, + // 避免新增依赖旧的 fys_omsDataDay 快照表。 + $query->leftJoinSub($this->latestShippingDetailByOrder(), 'd', function ($join): void { + $join->on('d.logistic_id', '=', 'b.ExpressNum') + ->on('d.so_id', '=', 'b.OrderNum'); + })->leftJoinSub($this->latestShippingDetailByWaybill(), 'd0', function ($join): void { + $join->on('d0.logistic_id', '=', 'b.ExpressNum'); + })->leftJoinSub($this->latestShippingExtension(), 'x', function ($join): void { + $join->on('x.so_id', '=', 'b.OrderNum'); + })->leftJoinSub($this->latestOutbound(), 'o', function ($join): void { + $join->on('o.io_id', '=', 'x.io_id'); + })->leftJoinSub($this->latestExpressOriginal(), 'e', function ($join): void { + // express_Original.OrderNum 是附加费记录的物流单号;保留记账表 + // 字段作为后备,避免历史记录没有对应原始物流行时整行消失。 + $join->on('e.OrderNum', '=', 'b.ExpressNum'); + }); + + if ($this->hasTable(self::RETURN_DATASET)) { + $query->leftJoinSub($this->latestReturn(), 'ret', function ($join): void { + $join->where(function ($condition): void { + $condition->on('ret.transactionNo', '=', 'b.OrderNum') + ->orOn('ret.orderId', '=', 'b.OrderNum') + ->orOn('ret.returnExpressNo', '=', 'b.ExpressNum'); + }); + }); + } + + $type = $this->surchargeTypeExpression(); + $select = [ + 'b.Id as source_id', 'b.CreationTime as created_at', + $this->connection->raw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) as courier_name"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum) as waybill"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), '')) as platform_product_id"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), '')) as product_code"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode) as shop_name"), + $this->connection->raw("COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum) as online_order_no"), + $this->connection->raw("COALESCE(e.Lenght, x.l) as length"), + $this->connection->raw("COALESCE(e.Width, x.w) as width"), + $this->connection->raw("COALESCE(e.Height, x.h) as height"), + $this->connection->raw("COALESCE(e.Volume, NULLIF(o.f_volume, ''), NULLIF(o.volume, ''), NULLIF(x.volume, ''), b.Svolume) as volume"), + $this->connection->raw('COALESCE(rt.Name, \'\') as inspection_type'), + // “查件原因”必须来自查件配置的二级选项;记账次要原因 + // 不是同一业务字段,不能在这里兜底混入。 + $this->connection->raw("COALESCE(rr.Name, '') as inspection_reason"), + $this->connection->raw('ret.returnExpressNo as return_waybill_no'), + $this->connection->raw("COALESCE(e.SetWeight, NULLIF(o.f_weight, ''), NULLIF(o.weight, ''), NULLIF(d.f_weight, ''), NULLIF(d0.f_weight, ''), NULLIF(x.weight, ''), b.Skuweight) as settlement_weight"), + $this->connection->raw("COALESCE(e.SetMoney, b.Payfee) as source_amount"), + $this->connection->raw("{$type} as surcharge_type"), + 'b.IsAgreeReason as approval_status', 'bm.Name as primary_reason', 'bs.Name as secondary_reason', + ]; + + return $query->select($select); + } + + private function latestCheck(): Builder + { + $table = $this->table(self::CHECK_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.ExpressNum', '=', 'candidate.ExpressNum') + ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select([ + 'candidate.ExpressNum', 'candidate.OrderNum', 'candidate.CheckType', 'candidate.CheckReason', + 'candidate.OmsSDName', 'candidate.DHLName', 'candidate.SkuCode', 'candidate.shopproid', + ]); + } + + private function latestExpressOriginal(): Builder + { + $table = $this->table(self::EXPRESS_ORIGINAL_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select([ + 'candidate.Id', 'candidate.ExpressName', 'candidate.OrderNum', 'candidate.Lenght', + 'candidate.Width', 'candidate.Height', 'candidate.Volume', 'candidate.SkuCode', + 'candidate.JiaoYiNum', 'candidate.ShopName', 'candidate.SetMoney', 'candidate.SetWeight', + 'candidate.shopproid', + ]); + } + + private function latestShippingDetailByOrder(): Builder + { + $table = $this->table(self::SHIPPING_DETAILS_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') + ->whereColumn('newer.so_id', '=', 'candidate.so_id') + ->whereColumn('newer.id', '>', 'candidate.id'); + }) + ->select(['candidate.id', 'candidate.logistic_id', 'candidate.so_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); + } + + private function latestShippingDetailByWaybill(): Builder + { + $table = $this->table(self::SHIPPING_DETAILS_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') + ->whereColumn('newer.id', '>', 'candidate.id'); + }) + ->select(['candidate.id', 'candidate.logistic_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); + } + + private function latestShippingExtension(): Builder + { + $table = $this->table(self::SHIPPING_EXTENSION_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.so_id', '=', 'candidate.so_id') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.Id', 'candidate.io_id', 'candidate.so_id', 'candidate.shop_i_id', 'candidate.sku_ids', 'candidate.shop_name', 'candidate.l', 'candidate.w', 'candidate.h', 'candidate.volume', 'candidate.weight']); + } + + private function latestOutbound(): Builder + { + $table = $this->table(self::OUTBOUND_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->whereColumn('newer.io_id', '=', 'candidate.io_id') + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.Id', 'candidate.io_id', 'candidate.f_weight', 'candidate.weight', 'candidate.f_volume', 'candidate.volume']); + } + + private function latestReturn(): Builder + { + $table = $this->table(self::RETURN_DATASET); + + return $this->connection->table($table . ' as candidate') + ->whereNotExists(function ($query) use ($table): void { + $query->selectRaw('1')->from($table . ' as newer') + ->where(function ($condition): void { + $condition->whereColumn('newer.transactionNo', '=', 'candidate.transactionNo') + ->orWhereColumn('newer.orderId', '=', 'candidate.orderId') + ->orWhereColumn('newer.returnExpressNo', '=', 'candidate.returnExpressNo'); + }) + ->whereColumn('newer.Id', '>', 'candidate.Id'); + }) + ->select(['candidate.orderId', 'candidate.transactionNo', 'candidate.returnExpressNo']); + } + + /** @param array $filters */ + private function applyFilters(Builder $query, array $filters): void + { + if (($filters['courier_name'] ?? '') !== '') { + $courier = trim((string) $filters['courier_name']); + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $courier) . '%'; + $query->whereRaw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) LIKE ? ESCAPE '!'", [$needle]); + } + $waybillColumn = "COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum)"; + if (($filters['waybill'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['waybill'])) . '%'; + $query->whereRaw($waybillColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + $onlineOrderColumn = "COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum)"; + if (($filters['online_order_no'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['online_order_no'])) . '%'; + $query->whereRaw($onlineOrderColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + $platformProductColumn = "COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), ''))"; + $productCodeColumn = "COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), ''))"; + if (($filters['platform_product_id'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['platform_product_id'])) . '%'; + $query->whereRaw($platformProductColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['product_code'] ?? '') !== '') { + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['product_code'])) . '%'; + $query->whereRaw($productCodeColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['shop_name'] ?? '') !== '') { + $shop = trim((string) $filters['shop_name']); + $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $shop) . '%'; + $shopColumn = "COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode)"; + $query->whereRaw($shopColumn . " LIKE ? ESCAPE '!'", [$needle]); + } + if (($filters['created_from'] ?? '') !== '') { + $query->where('b.CreationTime', '>=', $filters['created_from'] . ' 00:00:00'); + } + $type = $this->surchargeTypeExpression(); + if (($filters['surcharge_type'] ?? '') !== '') $query->whereRaw("{$type} = ?", [$filters['surcharge_type']]); + if (($filters['inspection_type'] ?? '') !== '') $query->where('rt.Name', '=', $filters['inspection_type']); + if (($filters['inspection_reason'] ?? '') !== '') $query->where('rr.Name', '=', $filters['inspection_reason']); + if (($filters['created_to'] ?? '') !== '') $query->where('b.CreationTime', '<', date('Y-m-d 00:00:00', strtotime($filters['created_to'] . ' +1 day'))); + $query->whereRaw("{$type} IN (?, ?, ?, ?, ?, ?, ?, ?, ?)", self::SURCHARGE_TYPES); + } + + private function surchargeTypeExpression(): string + { + return "CASE + WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfReturn, '')))) IN ('已退回', '1', '是', 'true') THEN '退回费' + WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfBuckle, '')))) IN ('已扣单', '1', '是', 'true') THEN '拦截费' + WHEN COALESCE(bm.Name, '') LIKE '%退回%' OR COALESCE(bs.Name, '') LIKE '%退回%' + OR COALESCE(bm.Name, '') LIKE '%退货%' OR COALESCE(bs.Name, '') LIKE '%退货%' + OR COALESCE(rt.Name, '') LIKE '%退回%' OR COALESCE(rr.Name, '') LIKE '%退回%' THEN '退回费' + WHEN COALESCE(bm.Name, '') LIKE '%拦截%' OR COALESCE(bs.Name, '') LIKE '%拦截%' + OR COALESCE(b.expressCause, '') LIKE '%拦截%' + OR COALESCE(rt.Name, '') LIKE '%拦截%' OR COALESCE(rr.Name, '') LIKE '%拦截%' + OR COALESCE(rt.Name, '') LIKE '%扣单%' OR COALESCE(rr.Name, '') LIKE '%扣单%' THEN '拦截费' + WHEN COALESCE(bm.Name, '') LIKE '%改地址%' OR COALESCE(bs.Name, '') LIKE '%改地址%' + OR COALESCE(b.expressCause, '') LIKE '%改地址%' + OR COALESCE(rt.Name, '') LIKE '%改地址%' OR COALESCE(rr.Name, '') LIKE '%改地址%' THEN '改地址' + WHEN COALESCE(bm.Name, '') LIKE '%上楼%' OR COALESCE(bs.Name, '') LIKE '%上楼%' + OR COALESCE(b.expressCause, '') LIKE '%上楼%' + OR COALESCE(rt.Name, '') LIKE '%上楼%' OR COALESCE(rr.Name, '') LIKE '%上楼%' THEN '上楼费' + WHEN COALESCE(bm.Name, '') LIKE '%送货%' OR COALESCE(bs.Name, '') LIKE '%送货%' + OR COALESCE(b.expressCause, '') LIKE '%送货%' + OR COALESCE(rt.Name, '') LIKE '%送货%' OR COALESCE(rr.Name, '') LIKE '%送货%' THEN '送货费' + WHEN COALESCE(bm.Name, '') LIKE '%超区%' OR COALESCE(bs.Name, '') LIKE '%超区%' + OR COALESCE(b.expressCause, '') LIKE '%超区%' + OR COALESCE(rt.Name, '') LIKE '%超区%' OR COALESCE(rr.Name, '') LIKE '%超区%' THEN '超区费' + WHEN COALESCE(bm.Name, '') LIKE '%安装%' OR COALESCE(bs.Name, '') LIKE '%安装%' + OR COALESCE(b.expressCause, '') LIKE '%安装%' + OR COALESCE(rt.Name, '') LIKE '%安装%' OR COALESCE(rr.Name, '') LIKE '%安装%' THEN '安装费' + WHEN COALESCE(bm.Name, '') LIKE '%保费%' OR COALESCE(bs.Name, '') LIKE '%保费%' + OR COALESCE(bm.Name, '') LIKE '%保丢保损%' OR COALESCE(bs.Name, '') LIKE '%保丢保损%' + OR COALESCE(b.expressCause, '') LIKE '%保费%' + OR COALESCE(rt.Name, '') LIKE '%保费%' OR COALESCE(rr.Name, '') LIKE '%保费%' THEN '保费' + WHEN COALESCE(bm.Name, '') LIKE '%进仓%' OR COALESCE(bs.Name, '') LIKE '%进仓%' + OR COALESCE(b.expressCause, '') LIKE '%进仓%' + OR COALESCE(rt.Name, '') LIKE '%进仓%' OR COALESCE(rr.Name, '') LIKE '%进仓%' THEN '进仓费' + ELSE NULL END"; + } + + /** @return list */ + private function approvedValues(): array + { + $configured = (array) config('upstream_mirrors.courier_surcharge_settlement.approved_values', self::APPROVED_VALUES); + $values = array_values(array_filter(array_map('strval', $configured), static fn (string $value): bool => trim($value) !== '')); + + return $values === [] ? self::APPROVED_VALUES : $values; + } + + /** @param Collection|CourierSurchargeQuote> $quotes */ + private function mapRow(object $row, Collection $quotes): array + { + $type = trim((string) ($row->surcharge_type ?? '')); + $quote = $quotes->first(function (array|CourierSurchargeQuote $quote) use ($row, $type): bool { + $status = is_array($quote) ? ($quote['status_code'] ?? $quote['status'] ?? null) : $quote->status; + $courier = is_array($quote) ? ($quote['courier'] ?? '') : $quote->courier; + $types = is_array($quote) ? ($quote['types'] ?? []) : $quote->types; + + return $status === CourierSurchargeQuote::STATUS_ACTIVE + && (string) $courier === (string) ($row->courier_name ?? '') + && in_array($type, (array) $types, true); + }); + $weight = $this->number($row->settlement_weight ?? null); + $volume = $this->number($row->volume ?? null); + $length = $this->number($row->length ?? null); + $width = $this->number($row->width ?? null); + $height = $this->number($row->height ?? null); + $amount = $quote === null ? null : $this->calculate($quote, $weight, $volume, $row->source_amount ?? null, $length, $width, $height); + + return [ + 'source_id' => (string) ($row->source_id ?? ''), + 'created_at' => (string) ($row->created_at ?? ''), + 'courier_name' => (string) ($row->courier_name ?? ''), + 'waybill' => (string) ($row->waybill ?? ''), + 'platform_product_id' => (string) ($row->platform_product_id ?? ''), + 'product_code' => (string) ($row->product_code ?? ''), + 'shop_name' => (string) ($row->shop_name ?? ''), + 'online_order_no' => (string) ($row->online_order_no ?? ''), + 'length' => $length, + 'width' => $width, + 'height' => $height, + 'volume' => $volume, + 'inspection_type' => (string) ($row->inspection_type ?? ''), + 'inspection_reason' => (string) ($row->inspection_reason ?? ''), + 'return_waybill_no' => (string) ($row->return_waybill_no ?? ''), + 'settlement_weight' => $weight, + 'settlement_amount' => $amount, + 'surcharge_type' => $type, + 'approval_status' => (string) ($row->approval_status ?? ''), + 'quote_standard' => is_array($quote) ? ($quote['standard'] ?? null) : $quote?->standard, + 'settlement_status' => $quote === null ? '未匹配报价' : ($amount === null ? '待核价' : '已计算'), + ]; + } + + private function calculate(array|CourierSurchargeQuote $quote, ?float $weight, ?float $volume, mixed $sourceAmount, ?float $length = null, ?float $width = null, ?float $height = null): ?float + { + $mode = is_array($quote) ? (string) ($quote['mode'] ?? '') : (string) $quote->mode; + $standard = is_array($quote) ? ($quote['standard'] ?? null) : $quote->standard; + if ($mode === '固定单价') return $this->firstNumber($standard); + if ($mode === '按尺寸/最长边') { + // 尺寸报价以最长边和标准中的比较符匹配,例如: + // “最长边 > 160 cm:8 元/单”。没有明确阈值时不猜测金额。 + $dimensions = array_filter([$length, $width, $height], static fn (?float $dimension): bool => $dimension !== null); + + return $this->thresholdAmount($standard, $dimensions === [] ? null : max($dimensions)); + } + if ($mode === '按重量' && $weight !== null && $this->hasComparator($standard)) { + return $this->thresholdAmount($standard, $weight); + } + if ($mode === '按体积' && $volume !== null) { + $threshold = $this->thresholdAmount($standard, $volume); + if ($threshold !== null || $this->hasComparator($standard)) return $threshold; + } + $unit = $this->firstNumber($standard); + if ($unit !== null && $mode === '按重量' && $weight !== null) return round($unit * $weight, 2); + if ($unit !== null && $mode === '按体积' && $volume !== null) return round($unit * $volume, 2); + if ($mode === '发出运费倍数' && $sourceAmount !== null) { + $multiplier = $this->firstNumber($standard); + $amount = $this->number($sourceAmount); + + return $multiplier === null || $amount === null ? null : round($amount * $multiplier, 2); + } + if (in_array($mode, ['取发出运费', '按账单'], true)) { + return $this->number($sourceAmount); + } + + return null; + } + + private function thresholdAmount(?string $standard, ?float $value): ?float + { + if ($value === null) return null; + $text = trim((string) $standard); + if (preg_match('/(<=|>=|≤|≥|<|>|=)\s*(-?\d+(?:\.\d+)?)[^\d]{0,30}[::]\s*(-?\d+(?:\.\d+)?)/u', $text, $match) !== 1) { + return null; + } + + $operator = $match[1]; + $threshold = (float) $match[2]; + $amount = (float) $match[3]; + $matched = match ($operator) { + '<' => $value < $threshold, + '<=', '≤' => $value <= $threshold, + '>' => $value > $threshold, + '>=', '≥' => $value >= $threshold, + '=' => abs($value - $threshold) < 0.000001, + default => false, + }; + + return $matched ? round($amount, 2) : null; + } + + private function hasComparator(?string $standard): bool + { + return preg_match('/(?:<=|>=|≤|≥|<|>|=)/u', (string) $standard) === 1; + } + + private function firstNumber(?string $value): ?float + { + return preg_match('/-?\d+(?:\.\d+)?/u', (string) $value, $match) === 1 ? (float) $match[0] : null; + } + + private function number(mixed $value): ?float + { + return is_numeric($value) ? (float) $value : null; + } + + private function table(string $datasetKey): string + { + return $this->datasets->get($datasetKey)->targetTable(); + } + + private function hasTable(string $datasetKey): bool + { + return Schema::connection($this->connection->getName())->hasTable($this->table($datasetKey)); + } + + /** @param array $states */ + private function datasetKeyForState(array $states, ?array $needle): ?string + { + foreach ($states as $key => $state) { + if ($state === $needle) return $key; + } + + return null; + } +} diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php index 20069ec9..ce07f435 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -1,5 +1,10 @@ @php $rows = $courierSurchargeSettlementRows ?? collect(); + $filters = $courierSurchargeSettlementFilters ?? []; + $summary = $courierSurchargeSettlementSummary ?? ['count' => 0, 'settlement_amount' => 0]; + $sync = $courierSurchargeSettlementSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null]; + $state = $courierSurchargeSettlementState ?? []; + $error = $courierSurchargeSettlementError ?? null; $columns = [ ['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'], ['key' => 'waybill', 'label' => '单号'], @@ -12,12 +17,26 @@ ['key' => 'height', 'label' => '高'], ['key' => 'volume', 'label' => '体积'], ['key' => 'inspection_type', 'label' => '查件类型'], - ['key' => 'inspection_original', 'label' => '查件原件'], + ['key' => 'inspection_reason', 'label' => '查件原因'], ['key' => 'return_waybill_no', 'label' => '退回单号'], ['key' => 'settlement_weight', 'label' => '结算重量'], - ['key' => 'settlement_amount', 'label' => '结算金额', 'fixed' => 'end'], + ['key' => 'settlement_amount', 'label' => '结算金额'], + ['key' => 'surcharge_type', 'label' => '附加费类型', 'fixed' => 'end'], ]; - $filterValue = static fn (string $key): string => trim((string) request()->query($key, '')); + $filterValue = static fn (string $key): string => trim((string) ($filters[$key] ?? '')); + $syncLabel = match ($sync['code'] ?? null) { + 'fresh' => '最新', + 'stale' => '稍有延迟', + 'pending', 'initializing', 'syncing' => '同步中', + 'failed', 'initial_failed' => '同步失败', + default => '不可用', + }; + $emptyMessage = match (true) { + $error !== null => $error, + ($sync['code'] ?? null) === 'pending' || ($sync['code'] ?? null) === 'syncing' => '镜像正在同步,完成后将自动显示附加费明细。', + ($sync['code'] ?? null) === 'failed' || ($sync['code'] ?? null) === 'initial_failed' => '镜像同步失败,暂不展示不完整数据。', + default => '当前筛选条件没有匹配的附加费明细。', + }; $displayValue = static function (mixed $value, string $key): string { if ($value === null || $value === '') { return '—'; @@ -152,31 +171,45 @@ } -
- -
等待接入结算数据;确认数据来源后,将按附加费报价规则计算。
-
+ @if ($error !== null || ($sync['code'] ?? null) !== 'fresh') +
+ +
+ {{ $error !== null ? '附加费结算暂不可用' : '附加费结算数据状态' }} +
{{ $error ?? $emptyMessage }}
+ @if (! empty($state['failure_stage'])) + 失败阶段:{{ $state['failure_stage'] }} + @endif +
+
+ @endif
附加费结算
结算标准读取附加费报价维护
-
当前明细
{{ $rows->count() }}
-
结算金额
待接入
-
数据状态
待确认来源
+
筛选结果
{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}
+
当前页结算金额
{{ number_format((float) ($summary['settlement_amount'] ?? 0), 2) }}
+
镜像状态
{{ $syncLabel }}
+
+
-
-
+
+
+
+
+
+
重置
-
附加费结算明细
快递名称和结算金额固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
-
当前筛选结果:{{ $rows->count() }} 条报价匹配和金额计算将在确认数据来源后接入
+
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
+
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
@@ -192,10 +225,14 @@ @endforeach @empty - + @endforelse
页面已就绪,等待接入附加费结算数据。
{{ $emptyMessage }}
+ + @if (method_exists($rows, 'links') && $rows->hasPages()) +
{{ $rows->withQueryString()->links() }}
+ @endif diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index 8fe98792..4eb366fc 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -7,7 +7,7 @@ use Tests\Feature\FeatureTestCase; class CourierSurchargeSettlementPageTest extends FeatureTestCase { - public function test_page_renders_requested_settlement_columns_without_fake_rows(): void + public function test_page_renders_requested_settlement_columns_and_mirror_state(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -18,16 +18,17 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ]))->assertOk() ->assertSee('data-surcharge-panel="quotes" hidden', false) ->assertSeeText('附加费结算') - ->assertSeeText('页面已就绪,等待接入附加费结算数据。') + ->assertSeeText('共享业务镜像尚未建立,附加费结算暂时无法读取。') ->assertSee('data-finance-table-name="courier-surcharge-settlement"', false) ->assertSee('data-finance-fixed="true"', false) ->assertSee('data-finance-fixed="end"', false) ->assertSeeText('快递名称') ->assertSeeText('平台商品ID') - ->assertSeeText('查件原件') + ->assertSeeText('查件原因') ->assertSeeText('退回单号') ->assertSeeText('结算重量') - ->assertSeeText('结算金额'); + ->assertSeeText('结算金额') + ->assertSeeText('附加费类型'); } public function test_settlement_filters_are_preserved_in_the_form(): void @@ -42,14 +43,14 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase 'waybill' => '001234', 'shop_name' => '测试店铺', 'online_order_no' => 'SO-001', - 'status' => '待接入', + 'status' => '已计算', ]))->assertOk() ->assertSee('value="测试快递"', false) ->assertSee('value="001234"', false) ->assertSee('value="测试店铺"', false) ->assertSee('value="SO-001"', false) - ->assertSee('value="待接入" selected', false) - ->assertViewHas('content', fn (array $content) => $content['courierSurchargeFilters']['status'] === ''); + ->assertSee('value="已计算" selected', false) + ->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算'); } public function test_quote_page_exposes_quote_and_settlement_tabs(): void diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php new file mode 100644 index 00000000..ce123d97 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php @@ -0,0 +1,59 @@ +get(CourierSurchargeSettlementMirrorRepository::PRIMARY_DATASET); + + $this->assertSame('jiansun', $dataset->sourceKey()); + $this->assertSame('dbo', $dataset->sourceSchema()); + $this->assertSame('ekf_Bookkeeping', $dataset->sourceObject()); + $this->assertSame('js_ekf_bookkeeping_surcharge', $dataset->targetTable()); + $this->assertSame(['Id'], $dataset->keyColumns()); + $this->assertSame('CreationTime', $dataset->watermarkColumn()); + $this->assertSame(['通过', '同意', '同意记账'], $dataset->fixedFilters()['IsAgreeReason']); + $this->assertSame(['通过', '同意', '同意记账'], config('upstream_mirrors.courier_surcharge_settlement.approved_values')); + + foreach ([ + CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET, + CourierSurchargeSettlementMirrorRepository::BOOKKEEPING_CONFIG_DATASET, + CourierSurchargeSettlementMirrorRepository::CHECK_DATASET, + CourierSurchargeSettlementMirrorRepository::CHECK_CONFIG_DATASET, + CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET, + CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET, + CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET, + CourierSurchargeSettlementMirrorRepository::RETURN_DATASET, + ] as $key) { + $this->assertSame('jiansun', $registry->get($key)->sourceKey()); + $this->assertNotSame('', $registry->get($key)->targetTable()); + } + + $expressOriginal = $registry->get(CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET); + $this->assertSame('express_Original', $expressOriginal->sourceObject()); + $this->assertSame('js_express_original', $expressOriginal->targetTable()); + $this->assertSame('DeliveryTime', $expressOriginal->watermarkColumn()); + + $this->assertSame('js_jst_order_express_details', $registry + ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET)->targetTable()); + $this->assertSame('js_jst_send_data_view_extension', $registry + ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET)->targetTable()); + $this->assertSame('js_jst_sales_outbound_info', $registry + ->get(CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET)->targetTable()); + } + + public function test_surcharge_type_contract_has_only_the_requested_nine_types(): void + { + $this->assertSame([ + '退回费', '拦截费', '改地址', '上楼费', '送货费', + '超区费', '安装费', '保费', '进仓费', + ], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES); + } +} diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php new file mode 100644 index 00000000..cdcd9984 --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php @@ -0,0 +1,198 @@ + [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.courier_surcharge_settlement.approved_values' => ['通过', '同意', '同意记账'], + 'upstream_mirrors.datasets' => [ + 'jiansun.dbo.ekf_Bookkeeping' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', + 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', + 'columns' => ['Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'], + 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], + ], + 'jiansun.dbo.express_Original' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', + 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', + 'columns' => ['Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'], + ], + 'jiansun.dbo.ekf_BookkeepingConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', + 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], + ], + 'jiansun.dbo.ekf_rCheck_Piece' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', + 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', + 'columns' => ['Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'], + ], + 'jiansun.dbo.ekf_rCheckPieceConfig' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', + 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', + 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], + ], + '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', + '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, + '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', + 'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'], + ], + 'jiansun.dbo.ekf_ReturnPickupManagement' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', + 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], + ], + ], + ]); + DB::purge('business_mirror'); + + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->dateTime('initial_completed_at')->nullable(); + $table->string('status')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->string('last_error_code')->nullable(); + }); + $this->createMirrorTables($schema); + + $this->repository = new CourierSurchargeSettlementMirrorRepository( + app('db'), + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_it_maps_approved_bookkeeping_to_requested_fields_and_uses_outbound_weight(): void + { + $db = DB::connection('business_mirror'); + $this->markReady($db); + + $db->table('js_ekf_bookkeeping_config')->insert([ + ['Id' => 10, 'Name' => '退回', 'ParentId' => null], + ['Id' => 20, 'Name' => '二级原因', 'ParentId' => 1], + ]); + $db->table('js_ekf_rcheck_piece_config')->insert([ + ['Id' => 1, 'ParentId' => null, 'Name' => '一级查件', 'Code' => null, 'Type' => null, 'Delete' => null], + ['Id' => 2, 'ParentId' => 1, 'Name' => '二级查件原因', 'Code' => null, 'Type' => null, 'Delete' => null], + ]); + $db->table('js_ekf_bookkeeping_surcharge')->insert([ + 'Id' => 1, 'CreationTime' => '2026-09-27 10:00:00', 'OrderNum' => 'SO-1', 'ExpressNum' => 'WB-1', + 'ExpressName' => '备用快递', 'ShopCode' => '备用店铺', 'SkuCode' => '备用编码', 'Payfee' => 3.2, + 'SecodaryCause' => 20, 'ReasonId' => 10, 'IsAgreeReason' => '同意', 'Svolume' => 1.1, + 'Skuweight' => 0.9, 'IfReturn' => '已退回', 'IfBuckle' => null, 'ShipExpressName' => null, + 'expressCause' => null, 'shopproid' => '备用商品', + ]); + $db->table('js_ekf_rcheck_piece')->insert([ + 'Id' => 1, 'CheckType' => 1, 'ExpressNum' => 'WB-1', 'OrderNum' => 'SO-1', 'CheckReason' => 2, + 'OmsSDName' => '查件物流', 'DHLName' => '不作为快递名称', 'SkuCode' => 'CHECK-SKU', 'shopproid' => 'CHECK-ITEM', + ]); + $db->table('js_express_original')->insert([ + 'Id' => 1, 'ExpressName' => '原始物流', 'OrderNum' => 'WB-1', 'Lenght' => 11, 'Width' => 22, 'Height' => 33, + 'Volume' => 7260, 'SkuCode' => 'ORIGINAL-SKU', 'JiaoYiNum' => 'TRADE-1', 'ShopName' => '原始店铺', + 'SetMoney' => 18.5, 'SetWeight' => 1.75, 'shopproid' => 'ORIGINAL-ITEM', + ]); + $db->table('js_jst_order_express_details')->insert([ + 'id' => 1, 'updatedAt' => '2026-09-27 10:01:00', 'so_id' => 'SO-1', 'logistic_id' => 'WB-1', + 'logistic_company_name' => '正式快递', 'shop_name' => '正式店铺', 'f_weight' => 0.8, + ]); + $db->table('js_jst_send_data_view_extension')->insert([ + 'Id' => 1, 'io_id' => 100, 'so_id' => 'SO-1', 'shop_i_id' => 'ITEM-1', 'sku_ids' => 'SKU-1', + 'shop_name' => '正式店铺', 'l' => 10, 'w' => 20, 'h' => 30, 'volume' => 6000, 'weight' => 0.7, + ]); + $db->table('js_jst_sales_outbound_info')->insert([ + 'Id' => 1, 'io_id' => 100, 'f_weight' => 1.25, 'weight' => 1.2, 'f_volume' => 0.5, 'volume' => 0.4, + ]); + + $result = $this->repository->paginate([], 1, 25, collect()); + $row = $result['data']->first(); + + $this->assertSame(1, $result['total']); + $this->assertSame('查件物流', $row['courier_name']); + $this->assertSame('ORIGINAL-ITEM', $row['platform_product_id']); + $this->assertSame('ORIGINAL-SKU', $row['product_code']); + $this->assertSame('原始店铺', $row['shop_name']); + $this->assertSame('TRADE-1', $row['online_order_no']); + $this->assertSame(11.0, $row['length']); + $this->assertSame(22.0, $row['width']); + $this->assertSame(33.0, $row['height']); + $this->assertSame('一级查件', $row['inspection_type']); + $this->assertSame('二级查件原因', $row['inspection_reason']); + $this->assertSame('退回费', $row['surcharge_type']); + $this->assertSame(1.75, $row['settlement_weight']); + $this->assertSame(7260.0, $row['volume']); + + $thresholdResult = $this->repository->paginate([], 1, 25, collect([[ + 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, + 'courier' => '查件物流', 'types' => ['退回费'], + 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 30 cm:8 元/单', + ]])); + $this->assertSame(8.0, $thresholdResult['data']->first()['settlement_amount']); + + $volumeResult = $this->repository->paginate([], 1, 25, collect([[ + 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, + 'courier' => '查件物流', 'types' => ['退回费'], + 'mode' => '按体积', 'standard' => '体积 ≤ 8000 cm³:50 元/单', + ]])); + $this->assertSame(50.0, $volumeResult['data']->first()['settlement_amount']); + } + + private function createMirrorTables($schema): void + { + $schema->create('js_ekf_bookkeeping_surcharge', function (Blueprint $table): void { + $table->integer('Id'); $table->dateTime('CreationTime')->nullable(); + foreach (['OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'] as $column) $table->string($column)->nullable(); + foreach (['Payfee', 'Svolume', 'Skuweight'] as $column) $table->decimal($column, 20, 4)->nullable(); + foreach (['SecodaryCause', 'ReasonId'] as $column) $table->integer($column)->nullable(); + }); + $schema->create('js_ekf_bookkeeping_config', function (Blueprint $table): void { $table->integer('Id'); $table->string('Name')->nullable(); $table->integer('ParentId')->nullable(); $table->dateTime('CreateTime')->nullable(); }); + $schema->create('js_ekf_rcheck_piece', function (Blueprint $table): void { $table->integer('Id'); foreach (['CheckType', 'CheckReason'] as $column) $table->integer($column)->nullable(); foreach (['ExpressNum', 'OrderNum', 'CheckSDType', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_ekf_rcheck_piece_config', function (Blueprint $table): void { $table->integer('Id'); $table->integer('ParentId')->nullable(); foreach (['Name', 'Code', 'Type', 'Delete', 'CreateTime'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_order_express_details', function (Blueprint $table): void { $table->integer('id'); foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'shop_name', 'f_weight'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_send_data_view_extension', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['so_id', 'shop_i_id', 'sku_ids', 'shop_name', 'l', 'w', 'h', 'volume', 'weight'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_jst_sales_outbound_info', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['f_weight', 'weight', 'f_volume', 'volume'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_ekf_return_pickup_management', function (Blueprint $table): void { $table->integer('Id'); foreach (['orderId', 'returnExpressNo', 'transactionNo', 'updateTime'] as $column) $table->string($column)->nullable(); }); + $schema->create('js_express_original', function (Blueprint $table): void { $table->integer('Id'); foreach (['ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'] as $column) $table->string($column)->nullable(); }); + } + + private function markReady($db): void + { + $now = now()->toDateTimeString(); + foreach (array_keys(config('upstream_mirrors.datasets')) as $dataset) { + $db->table('business_mirror_table_states')->insert([ + 'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-27 09:00:00', + 'status' => 'succeeded', 'last_succeeded_at' => $now, + ]); + } + } +} -- 2.47.2 From 680f646f9e3b3e63673d4080af3a1490d0cf742a Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Mon, 12 Oct 2026 09:16:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E9=99=84=E5=8A=A0=E8=B4=B9?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E6=8E=A5=E5=85=A5=E8=B6=85=E9=95=BF=E8=B4=B9?= =?UTF-8?q?/=E9=80=80=E5=9B=9E=E8=B4=B9/=E6=8B=A6=E6=88=AA=E8=B4=B9?= =?UTF-8?q?=E6=8B=89=E5=8F=96=E3=80=81=E5=AF=BC=E5=85=A5=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E6=9D=BF=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 超长费按报价规则从销售出库单拉取;退回费/拦截费从查件扣单拉取,并补出库时间、平台商品ID、线上订单号等字段 - 页面新增导入核验(单号+附加费类型+快递名称+可选金额),返回正常结/不结原因与差异金额,并提供导入模板下载 (cherry picked from commit 1fbb924924006a0813a6c30d6a9336259026902b) --- .../PullCourierSurchargeOverlength.php | 683 ++++++++ .../Commands/PullCourierSurchargeReturn.php | 727 +++++++++ app/Http/Controllers/Business/Pages.php | 280 ++++ config/upstream_mirrors.php | 6 +- ...ourier_surcharge_settlement_rows_table.php | 67 + ...ierSurchargeSettlementMirrorRepository.php | 1413 +++++++++++------ .../courier-surcharge-settlement.blade.php | 29 +- routes/admin.php | 13 + .../CourierSurchargeSettlementPageTest.php | 210 +++ ...ierSurchargeSettlementMirrorConfigTest.php | 45 +- ...urchargeSettlementMirrorRepositoryTest.php | 338 ++-- 11 files changed, 3082 insertions(+), 729 deletions(-) create mode 100644 app/Console/Commands/PullCourierSurchargeOverlength.php create mode 100644 app/Console/Commands/PullCourierSurchargeReturn.php create mode 100644 database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php diff --git a/app/Console/Commands/PullCourierSurchargeOverlength.php b/app/Console/Commands/PullCourierSurchargeOverlength.php new file mode 100644 index 00000000..887e72ed --- /dev/null +++ b/app/Console/Commands/PullCourierSurchargeOverlength.php @@ -0,0 +1,683 @@ +option('from') !== null && $this->option('from') !== '' + ? Carbon::parse((string) $this->option('from'))->startOfDay() + : now()->subDays(max((int) $this->option('days'), 1))->startOfDay(); + $to = $this->option('to') !== null && $this->option('to') !== '' + ? Carbon::parse((string) $this->option('to'))->startOfDay() + : now()->addDay()->startOfDay(); + $onlyRules = array_filter(array_map('intval', (array) $this->option('rule'))); + $chunkSize = min(max((int) $this->option('chunk'), 50), 500); + + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $schema = DB::connection($connection)->getSchemaBuilder(); + + if (! $schema->hasTable('courier_surcharge_settlement_rows')) { + $this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。'); + + return self::FAILURE; + } + + $rules = $this->overlengthRules($onlyRules); + + if ($rules === []) { + $this->warn('没有启用且可自动计算的超长费规则。'); + + return self::SUCCESS; + } + + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); + $totalWritten = 0; + + foreach ($rules as $rule) { + $threshold = $rule['threshold']; + $amount = $rule['amount']; + + $this->info(sprintf( + '规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s', + $rule['id'], + $rule['courier'], + $threshold, + $amount, + $from->toDateString(), + $to->toDateString(), + )); + + $orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize); + + if ($orders === []) { + $this->line(' 没有运单号非空的出库单,跳过。'); + continue; + } + + $written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to); + $totalWritten += $written; + + $this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。'); + } + + $this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。'); + + return self::SUCCESS; + } + + /** + * @param list $onlyRules + * @return list + */ + private function overlengthRules(array $onlyRules): array + { + $rules = []; + + foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) { + $types = json_decode((string) $rule->types, true) ?: []; + + if (! in_array('超长费', $types, true)) { + continue; + } + + if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) { + continue; + } + + if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) { + continue; + } + + $condition = (string) $rule->condition; + $standard = (string) $rule->standard; + + if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) { + $this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition); + continue; + } + + $threshold = (float) $matched[1]; + + if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) { + $this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard); + continue; + } + + $rules[] = [ + 'id' => (int) $rule->id, + 'courier' => trim((string) $rule->courier), + 'threshold' => $threshold, + 'amount' => (float) $priceMatched[1], + ]; + } + + return $rules; + } + + /** + * 拉取出库单(只取已分配运单号的单)。 + * + * @return list> + */ + private function pullOrders( + UpstreamReadOnlyQuery $upstream, + string $courier, + Carbon $from, + Carbon $to, + int $chunkSize, + ): array { + $orders = []; + $lastId = 0; + + while (true) { + $rows = $upstream->select( + 'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight ' + . 'FROM dbo.jst_salesOutboundInfo ' + . 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? ' + . "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? " + . 'ORDER BY Id', + [$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId], + ); + + if ($rows === []) { + break; + } + + foreach ($rows as $row) { + $row = (array) $row; + $orders[] = $row; + $lastId = max($lastId, (int) $row['Id']); + } + + if (count($rows) < $chunkSize) { + break; + } + } + + return $orders; + } + + /** + * @param list> $orders + */ + private function writeSettlementRows( + string $connection, + array $rule, + array $orders, + float $threshold, + float $amount, + Carbon $from, + Carbon $to, + ): int { + $ioIds = array_values(array_filter(array_map( + static fn (array $order): int => (int) ($order['io_id'] ?? 0), + $orders, + ), static fn (int $value): bool => $value > 0)); + + $items = $this->pullItems($ioIds); + $dimensions = $this->dimensionMap($items); + $platformProductIds = $this->platformProductIds($items, $orders); + $onlineOrderNumbers = $this->onlineOrderNumbers($orders); + $now = now(); + $written = 0; + $rows = []; + + foreach ($orders as $order) { + $ioId = (int) ($order['io_id'] ?? 0); + + if ($ioId <= 0) { + continue; + } + + $best = null; + + foreach ($items[$ioId] ?? [] as $item) { + $skuId = trim((string) ($item['sku_id'] ?? '')); + $dim = $dimensions[$skuId] ?? null; + + if ($dim === null) { + continue; + } + + $maxSide = max($dim['l'], $dim['w'], $dim['h']); + + if ($best === null || $maxSide > $best['max_side']) { + $best = [ + 'max_side' => $maxSide, + 'sku_id' => $skuId, + 'i_id' => trim((string) ($item['i_id'] ?? '')), + 'length' => $dim['l'], + 'width' => $dim['w'], + 'height' => $dim['h'], + 'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null), + ]; + } + } + + if ($best === null || $best['max_side'] <= $threshold) { + continue; + } + + $rows[] = [ + 'rule_id' => $rule['id'], + 'surcharge_type' => '超长费', + 'courier_name' => $rule['courier'], + 'waybill' => trim((string) ($order['l_id'] ?? '')), + 'outbound_io_id' => $ioId, + 'platform_product_id' => $best['platform_product_id'], + 'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null), + 'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null, + 'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null, + 'length' => $best['length'], + 'width' => $best['width'], + 'height' => $best['height'], + 'volume' => round($best['length'] * $best['width'] * $best['height'], 2), + 'inspection_type' => null, + 'inspection_reason' => null, + 'return_waybill_no' => null, + 'settlement_weight' => null, + 'settlement_amount' => $amount, + 'settlement_status' => '已计算', + 'evidence' => mb_substr(sprintf( + '条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm', + $best['sku_id'] !== '' ? $best['sku_id'] : '(无)', + $this->trimNumber($best['length']), + $this->trimNumber($best['width']), + $this->trimNumber($best['height']), + $this->trimNumber($best['max_side']), + $this->trimNumber($threshold), + ), 0, 500), + 'shipped_at' => $order['io_date'] ?? null, + 'pulled_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + $written++; + } + + $target = DB::connection($connection)->table('courier_surcharge_settlement_rows'); + + // 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。 + $target->where('rule_id', $rule['id']) + ->where('shipped_at', '>=', $from->toDateTimeString()) + ->where('shipped_at', '<', $to->toDateTimeString()) + ->delete(); + + foreach (array_chunk($rows, 500) as $chunk) { + $target->insert($chunk); + } + + return $written; + } + + /** + * @param list $ioIds + * @return array>> + */ + private function pullItems(array $ioIds): array + { + if ($ioIds === []) { + return []; + } + + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); + $items = []; + + foreach (array_chunk($ioIds, 500) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + $rows = $upstream->select( + 'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')', + $chunk, + ); + + foreach ($rows as $row) { + $row = (array) $row; + $items[(int) $row['io_id']][] = $row; + } + } + + return $items; + } + + /** + * 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。 + * + * 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表 + * jst_nosend_orders 里仍能按 o_id 找到线上单号。 + * + * @param list> $orders + * @return array + */ + private function onlineOrderNumbers(array $orders): array + { + $map = []; + $missing = []; + + foreach ($orders as $order) { + $oId = (int) ($order['o_id'] ?? 0); + $soId = trim((string) ($order['so_id'] ?? '')); + + if ($soId !== '') { + $map[$oId] = mb_substr($soId, 0, 191); + } elseif ($oId > 0) { + $missing[$oId] = true; + } + } + + if ($missing === []) { + return $map; + } + + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); + + foreach (array_chunk(array_keys($missing), 500) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + "SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')' + . " AND LEN(ISNULL(so_id, '')) > 0", + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); + } + } + + // 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底; + // 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。 + $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); + + foreach (array_chunk($remaining, 500) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + "SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')' + . " AND LEN(ISNULL(so_id, '')) > 0", + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); + } + } + + // 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id, + // 例如 2026-07-31 新佰广1688 已归档的出库单。 + $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); + + foreach (array_chunk($remaining, 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')' + . ' AND LEN(so_id) > 0', + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); + } + } + + // 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。 + $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); + + if ($remaining !== []) { + $monthById = []; + + foreach ($orders as $order) { + $oId = (int) ($order['o_id'] ?? 0); + $ioDate = trim((string) ($order['io_date'] ?? '')); + + if ($oId <= 0 || $ioDate === '') { + continue; + } + + try { + $monthById[$oId] = Carbon::parse($ioDate)->format('Ym'); + } catch (Throwable) { + // io_date 无法解析时只查当前表。 + } + } + + $tables = ['jst_qmcust_orderlist' => array_values($remaining)]; + + foreach ($remaining as $oId) { + $month = $monthById[$oId] ?? null; + + if ($month !== null) { + $tables['jst_qmcust_orderlist_' . $month][] = $oId; + } + } + + foreach ($tables as $table => $ids) { + foreach (array_chunk($ids, 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')' + . ' AND LEN(so_id) > 0', + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); + } + } + } + } + + return $map; + } + + /** + * 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。 + * 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。 + * + * @param array>> $items + * @param list> $orders + * @return array> + */ + private function platformProductIds(array $items, array $orders): array + { + $map = []; + $ioIds = array_keys($items); + + if ($ioIds === []) { + return $map; + } + + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); + + foreach (array_chunk($ioIds, 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')' + . ' AND LEN(shop_i_id) > 0', + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $ioId = (int) ($row['io_id'] ?? 0); + $skuId = trim((string) ($row['sku_id'] ?? '')); + $shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191); + + if ($ioId <= 0 || $shopIId === '') { + continue; + } + + if ($skuId !== '') { + $map[$ioId][$skuId] = $shopIId; + } + + $map[$ioId][''] ??= $shopIId; + } + } + + // 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。 + $oIdByIoId = []; + $monthByIoId = []; + + foreach ($orders as $order) { + $ioId = (int) ($order['io_id'] ?? 0); + $oId = (int) ($order['o_id'] ?? 0); + $ioDate = trim((string) ($order['io_date'] ?? '')); + + if ($ioId <= 0 || $oId <= 0) { + continue; + } + + $oIdByIoId[$ioId] = $oId; + + if ($ioDate === '') { + continue; + } + + try { + $monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym'); + } catch (Throwable) { + // io_date 无法解析时只查当前表。 + } + } + + $missing = []; + + foreach ($ioIds as $ioId) { + if (($map[$ioId] ?? []) !== []) { + continue; + } + + $oId = $oIdByIoId[$ioId] ?? 0; + + if ($oId > 0) { + $missing[$ioId] = $oId; + } + } + + if ($missing === []) { + return $map; + } + + $tables = ['jst_qmcust_orderlist_item' => $missing]; + + foreach ($missing as $ioId => $oId) { + $month = $monthByIoId[$ioId] ?? null; + + if ($month !== null) { + $tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId; + } + } + + foreach ($tables as $table => $idsByIo) { + $oIds = array_values(array_unique(array_values($idsByIo))); + + foreach (array_chunk($oIds, 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')' + . ' AND LEN(shop_i_id) > 0', + $chunk, + ); + } catch (Throwable) { + continue; + } + + $byOrder = []; + + foreach ($rows as $row) { + $row = (array) $row; + $skuId = trim((string) ($row['sku_id'] ?? '')); + $shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191); + + if ($shopIId === '') { + continue; + } + + $byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId]; + } + + foreach ($idsByIo as $ioId => $oId) { + foreach ($byOrder[$oId] ?? [] as $entry) { + if ($entry['sku_id'] !== '') { + $map[$ioId][$entry['sku_id']] = $entry['shop_i_id']; + } + + $map[$ioId][''] ??= $entry['shop_i_id']; + } + } + } + } + + return $map; + } + + /** + * @param array>> $items + * @return array + */ + private function dimensionMap(array $items): array + { + $skuIds = []; + + foreach ($items as $rows) { + foreach ($rows as $row) { + $skuId = trim((string) ($row['sku_id'] ?? '')); + + if ($skuId !== '') { + $skuIds[$skuId] = true; + } + } + } + + $skuIds = array_keys($skuIds); + $dimensions = []; + + // 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。 + foreach (array_chunk($skuIds, 500) as $chunk) { + try { + $rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('ops_jst_sku') + ->whereIn('sku_id', $chunk) + ->get(['sku_id', 'l', 'w', 'h']); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $l = (float) $row->l; + $w = (float) $row->w; + $h = (float) $row->h; + + if ($l > 0 && $w > 0 && $h > 0) { + $dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h]; + } + } + } + + return $dimensions; + } + + private function trimNumber(float $value): string + { + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/app/Console/Commands/PullCourierSurchargeReturn.php b/app/Console/Commands/PullCourierSurchargeReturn.php new file mode 100644 index 00000000..a7c1b031 --- /dev/null +++ b/app/Console/Commands/PullCourierSurchargeReturn.php @@ -0,0 +1,727 @@ +option('from') !== null && $this->option('from') !== '' + ? Carbon::parse((string) $this->option('from'))->startOfDay() + : now()->subDays(max((int) $this->option('days'), 1))->startOfDay(); + $to = $this->option('to') !== null && $this->option('to') !== '' + ? Carbon::parse((string) $this->option('to'))->startOfDay() + : now()->addDay()->startOfDay(); + $onlyRules = array_filter(array_map('intval', (array) $this->option('rule'))); + $onlyTypes = array_values(array_filter( + array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')), + static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true), + )); + $limit = max((int) $this->option('limit'), 0); + + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $schema = DB::connection($connection)->getSchemaBuilder(); + + if (! $schema->hasTable('courier_surcharge_settlement_rows')) { + $this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。'); + + return self::FAILURE; + } + + if (! $schema->hasTable('js_jst_work_order_info')) { + $this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。'); + + return self::FAILURE; + } + + $rules = $this->rules($onlyRules, $onlyTypes); + + if ($rules === []) { + $this->warn('没有启用且可自动计算的退回费/拦截费规则。'); + + return self::SUCCESS; + } + + $orders = $this->pullDeductOrders($connection, $from, $to, $limit); + $this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders))); + + if ($orders === []) { + return self::SUCCESS; + } + + $ordersByCourier = []; + + foreach ($orders as $order) { + $ordersByCourier[$order['courier']][] = $order; + } + + $totalWritten = 0; + + foreach ($rules as $rule) { + $matched = $ordersByCourier[$rule['courier']] ?? []; + + if ($matched === []) { + $this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。'); + continue; + } + + $written = $this->writeSettlementRows($connection, $rule, $matched); + $totalWritten += $written; + $this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。'); + } + + $this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。'); + + return self::SUCCESS; + } + + /** + * @param list $onlyRules + * @param list $onlyTypes + * @return list + */ + private function rules(array $onlyRules, array $onlyTypes): array + { + $rules = []; + + foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) { + $types = json_decode((string) $rule->types, true) ?: []; + $candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types)); + + if ($candidateTypes === []) { + continue; + } + + if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) { + continue; + } + + $condition = trim((string) $rule->condition); + + if (! str_contains($condition, '扣单')) { + $this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition); + continue; + } + + $mode = trim((string) $rule->mode); + $standard = trim((string) $rule->standard); + + foreach ($candidateTypes as $type) { + if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) { + continue; + } + + if ($type === '退回费') { + if ($mode === '固定单价') { + $value = $this->parseFixedAmount($standard); + } elseif ($mode === '按重量') { + $value = $this->parseWeightRate($standard); + } else { + $this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。'); + continue; + } + } else { + if ($mode === '固定单价') { + $value = $this->parseFixedAmount($standard); + } else { + $this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。'); + continue; + } + } + + if ($value === null) { + $this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard); + continue; + } + + $rules[] = [ + 'id' => (int) $rule->id, + 'type' => $type, + 'courier' => trim((string) $rule->courier), + 'mode' => $mode, + 'value' => $value, + 'effective_at' => $rule->effective_at !== null && $rule->effective_at !== '' + ? (string) $rule->effective_at + : null, + ]; + } + } + + return $rules; + } + + private function parseFixedAmount(string $standard): ?float + { + return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1 + ? (float) $matched[1] + : null; + } + + private function parseWeightRate(string $standard): ?float + { + return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1 + ? (float) $matched[1] + : null; + } + + /** + * 从查件工单镜像按创建时间拉取扣单工单。 + * + * @return list> + */ + private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array + { + $orders = []; + $lastId = 0; + $batchSize = 500; + + while (true) { + $rows = DB::connection($connection)->table('js_jst_work_order_info') + ->where('title', '查件') + ->where('businessFieldList', 'like', '%"value":"扣单/%') + ->where('created', '>=', $from->toDateTimeString()) + ->where('created', '<', $to->toDateTimeString()) + ->where('Id', '>', $lastId) + ->orderBy('Id') + ->limit($batchSize) + ->get(['Id', 'woNo', 'created', 'businessFieldList']); + + if ($rows->isEmpty()) { + break; + } + + foreach ($rows as $row) { + $lastId = max($lastId, (int) $row->Id); + $order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created); + + if ($order !== null) { + $orders[] = $order; + } + + if ($limit > 0 && count($orders) >= $limit) { + break 2; + } + } + + if ($rows->count() < $batchSize) { + break; + } + } + + return $orders; + } + + /** + * @return array|null + */ + private function parseWorkOrder(string $json, string $woNo, string $created): ?array + { + $fields = json_decode($json, true); + + if (! is_array($fields)) { + return null; + } + + $flat = []; + + foreach ($fields as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $flat[(string) $field['fieldId']] = $field; + } + } + + $waybill = trim((string) ($flat['lIdField']['value'] ?? '')); + $oId = (int) ($flat['o_id']['value'] ?? 0); + $courier = trim((string) ($flat['logisticsField']['value'] ?? '')); + $cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? '')); + + if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) { + return null; + } + + [$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, ''); + $items = []; + + foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) { + if (! is_array($group)) { + continue; + } + + $item = []; + + foreach ($group as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $item[(string) $field['fieldId']] = $field['value'] ?? null; + } + } + + $skuId = strtoupper(trim((string) ($item['skuId'] ?? ''))); + + if ($skuId === '') { + continue; + } + + $items[] = [ + 'sku_id' => $skuId, + 'qty' => (float) ($item['qty'] ?? 0), + 'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191), + 'i_id' => trim((string) ($item['iid'] ?? '')), + 'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191), + ]; + } + + $createdAt = null; + + try { + $createdAt = Carbon::parse($created); + } catch (Throwable) { + // created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。 + } + + return [ + 'wo_no' => $woNo, + 'waybill' => $waybill, + 'o_id' => $oId, + 'courier' => $courier, + 'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191), + 'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191), + 'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60), + 'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120), + 'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')), + 'created_at' => $createdAt, + 'items' => $items, + ]; + } + + /** + * @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule + * @param list> $orders + */ + private function writeSettlementRows(string $connection, array $rule, array $orders): int + { + $effectiveAt = null; + + if (($rule['effective_at'] ?? null) !== null) { + try { + $effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay(); + } catch (Throwable) { + // 规则生效时间无法解析时不做下界过滤。 + } + } + + if ($effectiveAt !== null) { + $orders = array_values(array_filter( + $orders, + static fn (array $order): bool => ! $order['created_at'] instanceof Carbon + || $order['created_at']->greaterThanOrEqualTo($effectiveAt), + )); + } + + if ($orders === []) { + return 0; + } + + $oIds = []; + $waybills = []; + $skuIds = []; + + foreach ($orders as $order) { + if ((int) ($order['o_id'] ?? 0) > 0) { + $oIds[(int) $order['o_id']] = true; + } + + $waybills[(string) $order['waybill']] = true; + + foreach ($order['items'] as $item) { + $skuIds[(string) $item['sku_id']] = true; + } + } + + $type = (string) $rule['type']; + $returns = $type === '退回费' ? $this->pullReturns($orders) : []; + $weights = $this->pullWeights($connection, array_keys($skuIds)); + $now = now(); + $rows = []; + $hasReturnByWaybill = []; + + foreach ($orders as $order) { + $matched = []; + $receiveDates = []; + $returnWaybills = []; + $hasReturn = false; + $settlementWeight = 0.0; + + $orderWeight = 0.0; + + foreach ($order['items'] as $item) { + $weight = $weights[(string) $item['sku_id']] ?? null; + + if ($weight !== null) { + $orderWeight += (float) $item['qty'] * $weight; + } + } + + if ($type === '拦截费') { + // 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。 + $amount = (float) $rule['value']; + $status = '已计算'; + } else { + foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) { + $rQty = (float) ($return['r_qty'] ?? 0); + $receiveDate = trim((string) ($return['receive_date'] ?? '')); + + if ($rQty <= 0 || $receiveDate === '') { + continue; + } + + $hasReturn = true; + $matched[] = $return; + $receiveDates[] = $receiveDate; + + $returnWaybill = trim((string) ($return['l_id'] ?? '')); + + if ($returnWaybill !== '') { + $returnWaybills[$returnWaybill] = true; + } + } + + $returnedWeight = 0.0; + + foreach ($matched as $return) { + $weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null; + + if ($weight !== null) { + $returnedWeight += (float) $return['r_qty'] * $weight; + } + } + + $settlementWeight = $hasReturn + ? ($returnedWeight > 0 ? $returnedWeight : $orderWeight) + : $orderWeight; + + if ($rule['mode'] === '固定单价') { + $amount = $hasReturn ? (float) $rule['value'] : 0.0; + $status = '已计算'; + } elseif (! $hasReturn) { + $amount = 0.0; + $status = '已计算'; + } elseif ($settlementWeight > 0) { + $amount = round($settlementWeight * (float) $rule['value'], 2); + $status = '已计算'; + } else { + $amount = 0.0; + $status = '待核价'; + } + } + + $primary = $this->primaryItem($order['items'], $matched); + $waybill = (string) $order['waybill']; + + // 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。 + if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) { + continue; + } + + $rows[$waybill] = [ + 'rule_id' => $rule['id'], + 'surcharge_type' => $type, + 'courier_name' => $rule['courier'], + 'waybill' => $waybill, + 'outbound_io_id' => null, + 'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null, + 'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null), + 'shop_name' => $order['shop_name'] !== '' ? $order['shop_name'] : null, + 'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null, + 'length' => null, + 'width' => null, + 'height' => null, + 'volume' => null, + 'inspection_type' => $order['inspection_type'], + 'inspection_reason' => $order['inspection_reason'], + 'return_waybill_no' => $returnWaybills === [] + ? null + : mb_substr(implode(',', array_keys($returnWaybills)), 0, 191), + 'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null, + 'settlement_amount' => $amount, + 'settlement_status' => $status, + 'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500), + 'shipped_at' => $this->shippedAt($order), + 'pulled_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $hasReturnByWaybill[$waybill] = $hasReturn; + } + + foreach (array_chunk(array_keys($waybills), 500) as $chunk) { + DB::connection($connection)->table('courier_surcharge_settlement_rows') + ->where('rule_id', $rule['id']) + ->where('surcharge_type', $type) + ->whereIn('waybill', $chunk) + ->delete(); + } + + foreach (array_chunk(array_values($rows), 500) as $chunk) { + DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk); + } + + return count($rows); + } + + /** + * 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。 + * + * @param list> $orders + * @return array>> + */ + private function pullReturns(array $orders): array + { + $map = []; + $oIds = []; + $oIdByWaybill = []; + + foreach ($orders as $order) { + $oId = (int) ($order['o_id'] ?? 0); + $waybill = trim((string) ($order['waybill'] ?? '')); + + if ($oId > 0) { + $oIds[$oId] = true; + } + + if ($oId > 0 && $waybill !== '') { + $oIdByWaybill[$waybill] = $oId; + } + } + + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica'); + + foreach (array_chunk(array_keys($oIds), 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) { + $oId = (int) ($row['o_id'] ?? 0); + + if ($oId > 0) { + $map[$oId][] = $row; + } + } + } + + // 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。 + $remainingWaybills = []; + + foreach ($oIdByWaybill as $waybill => $oId) { + if (($map[$oId] ?? []) === []) { + $remainingWaybills[] = $waybill; + } + } + + foreach (array_chunk($remainingWaybills, 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) { + $oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0; + + if ($oId > 0) { + $map[$oId][] = $row; + } + } + } + + return $map; + } + + /** + * @param list $bindings + * @return list> + */ + private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array + { + try { + $rows = $upstream->select( + 'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, ' + . 'ri.sku_id, ri.qty, ri.r_qty, ' + . 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date ' + . 'FROM rpt.jst_returnAndRefundInfo a ' + . 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id ' + . 'WHERE ' . $where, + $bindings, + ); + } catch (Throwable) { + return []; + } + + return array_map(static fn ($row): array => (array) $row, $rows); + } + + /** + * @param list $skuIds + * @return array + */ + private function pullWeights(string $connection, array $skuIds): array + { + $map = []; + + foreach (array_chunk($skuIds, 500) as $chunk) { + $rows = DB::connection($connection)->table('ops_jst_sku') + ->whereIn('sku_id', $chunk) + ->get(['sku_id', 'weight']); + + foreach ($rows as $row) { + $skuId = strtoupper(trim((string) $row->sku_id)); + $weight = $row->weight; + + if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) { + continue; + } + + $map[$skuId] = (float) $weight; + } + } + + return $map; + } + + /** + * @param list> $items + * @param list> $returns + * @return array{sku_id:string,i_id:string,platform_product_id:string,name:string} + */ + private function primaryItem(array $items, array $returns): array + { + $returnSkus = []; + + foreach ($returns as $return) { + $skuId = strtoupper(trim((string) ($return['sku_id'] ?? ''))); + + if ($skuId !== '') { + $returnSkus[$skuId] = true; + } + } + + foreach ($items as $item) { + if (isset($returnSkus[$item['sku_id']])) { + return $item; + } + } + + foreach ($items as $item) { + if ($item['platform_product_id'] !== '') { + return $item; + } + } + + return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => '']; + } + + /** + * @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule + * @param array $order + * @param list> $matched + * @param list $receiveDates + */ + private function evidence( + array $rule, + array $order, + bool $hasReturn, + array $matched, + array $receiveDates, + float $settlementWeight, + float $amount, + ): string { + $parts = ['查件扣单/' . $order['inspection_reason']]; + + if (($rule['type'] ?? '退回费') === '拦截费') { + $parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'; + $parts[] = '结算金额 ' . number_format($amount, 2, '.', ''); + + return implode(';', $parts); + } + + if ($hasReturn) { + $returnQty = 0.0; + $confirmDates = []; + + foreach ($matched as $return) { + $returnQty += (float) ($return['r_qty'] ?? 0); + $confirmDate = trim((string) ($return['confirm_date'] ?? '')); + + if ($confirmDate !== '') { + $confirmDates[] = $confirmDate; + } + } + + if ($confirmDates !== []) { + $parts[] = '退款确认 ' . max($confirmDates); + } + + $parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates)); + $parts[] = '实退 ' . $this->trimNumber($returnQty); + } else { + $parts[] = '售后退货退款无进仓/实退记录'; + } + + $parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg'; + $parts[] = $rule['mode'] === '固定单价' + ? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单' + : '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg'; + $parts[] = '结算金额 ' . number_format($amount, 2, '.', ''); + + return implode(';', $parts); + } + + /** + * @param array $order + */ + private function shippedAt(array $order): ?string + { + $sendTime = trim((string) ($order['send_time'] ?? '')); + + if ($sendTime !== '') { + try { + return Carbon::parse($sendTime)->toDateTimeString(); + } catch (Throwable) { + // 发货时间无法解析时回退到查件工单创建时间。 + } + } + + return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null; + } + + private function trimNumber(float $value): string + { + $text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + + return $text === '' ? '0' : $text; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 3946330b..6138d35d 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3794,6 +3794,286 @@ class Pages extends Controller ]; } + public function exportCourierSurchargeSettlement(Request $request): StreamedResponse + { + $filters = $this->courierSurchargeSettlementFilters($request); + $repository = app(\Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository::class); + $limit = 50000; + + return response()->streamDownload(function () use ($filters, $repository, $limit): void { + $writer = new \App\Support\StreamingXlsxWriter('附加费结算'); + + $writer->appendRow([ + '快递名称', '出库时间', '单号', '平台商品ID', '商品编码', '店铺名称', '线上订单号', + '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号', + '结算重量', '结算金额', '附加费类型', + ]); + + $exported = 0; + + foreach ($repository->exportRows($filters, $limit) as $row) { + $writer->appendRow([ + $this->xlsxText($row->courier_name ?? ''), + $this->xlsxText($row->shipped_at ?? ''), + $this->xlsxText($row->waybill ?? ''), + $this->xlsxText($row->platform_product_id ?? ''), + $this->xlsxText($row->product_code ?? ''), + $this->xlsxText($row->shop_name ?? ''), + $this->xlsxText($row->online_order_no ?? ''), + $row->length !== null ? (float) $row->length : null, + $row->width !== null ? (float) $row->width : null, + $row->height !== null ? (float) $row->height : null, + $row->volume !== null ? (float) $row->volume : null, + $this->xlsxText($row->inspection_type ?? ''), + $this->xlsxText($row->inspection_reason ?? ''), + $this->xlsxText($row->return_waybill_no ?? ''), + $row->settlement_weight !== null ? (float) $row->settlement_weight : null, + $row->settlement_amount !== null ? (float) $row->settlement_amount : null, + $this->xlsxText($row->surcharge_type ?? ''), + ]); + $exported++; + } + + if ($exported >= $limit) { + $writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']); + } + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建附加费结算导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_' . now()->format('Ymd_His') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function importCourierSurchargeSettlement(Request $request): StreamedResponse|\Illuminate\Http\RedirectResponse + { + $request->validate([ + 'file' => ['required', 'file', 'max:10240'], + ], [], ['file' => '导入文件']); + + try { + $path = (string) $request->file('file')?->getRealPath(); + $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path); + $sheet = $spreadsheet->getActiveSheet(); + $highestRow = min($sheet->getHighestDataRow(), 5001); + $data = $sheet->rangeToArray( + 'A1:' . $sheet->getHighestColumn() . $highestRow, + null, + false, + true, + false, + ); + } catch (\Throwable $exception) { + return back()->with('error', '导入失败:' . $exception->getMessage()); + } + + $headers = []; + + foreach ((array) ($data[0] ?? []) as $index => $name) { + $name = trim((string) $name); + + if ($name !== '') { + $headers[$name] = (int) $index; + } + } + + $waybillColumn = $this->findImportColumn($headers, ['单号', '运单号', '快递单号', 'waybill', 'express_no']); + $typeColumn = $this->findImportColumn($headers, ['附加费类型', '附加费类型名称', '类型', 'surcharge_type']); + $courierColumn = $this->findImportColumn($headers, ['快递名称', '快递公司', '物流公司', '快递', 'courier_name']); + $amountColumn = $this->findImportColumn($headers, ['金额', '导入金额', '费用金额', 'amount']); + + if ($waybillColumn === null || $typeColumn === null) { + return back()->with('error', '导入失败:文件首行需包含「单号」和「附加费类型」两列。'); + } + + $pairs = []; + + foreach (array_slice($data, 1) as $offset => $row) { + $waybill = $this->importCellText($row[$waybillColumn] ?? null); + $type = $this->importCellText($row[$typeColumn] ?? null); + + if ($waybill === '' && $type === '') { + continue; + } + + $pairs[] = [ + 'row_number' => $offset + 2, + 'waybill' => mb_substr($waybill, 0, 191), + 'surcharge_type' => mb_substr($type, 0, 40), + 'courier_name' => $courierColumn === null + ? '' + : mb_substr($this->importCellText($row[$courierColumn] ?? null), 0, 191), + 'amount' => $amountColumn === null + ? null + : $this->importCellAmount($row[$amountColumn] ?? null), + ]; + } + + if ($pairs === []) { + return back()->with('error', '导入文件没有可核验的数据行。'); + } + + if (count($pairs) > 5000) { + return back()->with('error', '导入失败:单次最多核验 5000 行。'); + } + + $amountByRow = []; + + foreach ($pairs as $pair) { + $amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null; + } + + $results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs); + + return response()->streamDownload(function () use ($results, $amountByRow): void { + $writer = new \App\Support\StreamingXlsxWriter('导入核验'); + + $writer->appendRow([ + '序号', '单号', '附加费类型', '结算结论', '不结原因', '快递名称', '出库时间', '平台商品ID', '商品编码', + '店铺名称', '线上订单号', '长', '宽', '高', '体积', '查件类型', '查件原因', '退回单号', + '结算重量', '结算金额', '导入金额', '差异金额', '结算状态', '判定依据', + ]); + + foreach ($results as $row) { + $importedAmount = $amountByRow[(int) ($row['row_number'] ?? 0)] ?? null; + $systemAmount = $row['settlement_amount'] !== null && $row['settlement_amount'] !== '' + ? (float) $row['settlement_amount'] + : null; + $difference = $importedAmount !== null && $systemAmount !== null + ? round($systemAmount - $importedAmount, 2) + : null; + + $writer->appendRow([ + (int) ($row['row_number'] ?? 0), + $this->xlsxText($row['waybill'] ?? ''), + $this->xlsxText($row['surcharge_type'] ?? ''), + $this->xlsxText($row['decision'] ?? ''), + $this->xlsxText($row['reason'] ?? ''), + $this->xlsxText($row['courier_name'] ?? ''), + $this->xlsxText($row['shipped_at'] ?? ''), + $this->xlsxText($row['platform_product_id'] ?? ''), + $this->xlsxText($row['product_code'] ?? ''), + $this->xlsxText($row['shop_name'] ?? ''), + $this->xlsxText($row['online_order_no'] ?? ''), + $row['length'] !== null ? (float) $row['length'] : null, + $row['width'] !== null ? (float) $row['width'] : null, + $row['height'] !== null ? (float) $row['height'] : null, + $row['volume'] !== null ? (float) $row['volume'] : null, + $this->xlsxText($row['inspection_type'] ?? ''), + $this->xlsxText($row['inspection_reason'] ?? ''), + $this->xlsxText($row['return_waybill_no'] ?? ''), + $row['settlement_weight'] !== null ? (float) $row['settlement_weight'] : null, + $systemAmount, + $importedAmount, + $difference, + $this->xlsxText($row['settlement_status'] ?? ''), + $this->xlsxText($row['evidence'] ?? ''), + ]); + } + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建导入核验导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_导入核验_' . now()->format('Ymd_His') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function downloadCourierSurchargeSettlementImportTemplate(): StreamedResponse + { + return response()->streamDownload(function (): void { + $writer = new \App\Support\StreamingXlsxWriter('导入模板'); + + $writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']); + $writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]); + $writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]); + + $output = fopen('php://output', 'wb'); + + if ($output === false) { + throw new RuntimeException('无法创建导入模板导出流。'); + } + + $writer->writeTo($output); + fclose($output); + }, '附加费结算_导入模板.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** + * @param array $headers + * @param list $candidates + */ + private function findImportColumn(array $headers, array $candidates): ?int + { + foreach ($headers as $name => $index) { + foreach ($candidates as $candidate) { + if (mb_strtolower($name) === mb_strtolower($candidate)) { + return $index; + } + } + } + + foreach ($headers as $name => $index) { + foreach ($candidates as $candidate) { + if (str_contains(mb_strtolower($name), mb_strtolower($candidate))) { + return $index; + } + } + } + + return null; + } + + private function importCellText(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_int($value)) { + return (string) $value; + } + + if (is_float($value)) { + return abs($value - round($value)) < 0.000001 && abs($value) >= 1e11 + ? number_format($value, 0, '', '') + : rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.'); + } + + return trim((string) $value); + } + + private function importCellAmount(mixed $value): ?float + { + if ($value === null) { + return null; + } + + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + $text = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim((string) $value)); + + return $text !== '' && is_numeric($text) ? (float) $text : null; + } + private function freightReconciliationFilters(): array { $request = request(); diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 95579053..db579fb9 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -264,7 +264,7 @@ return [ 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid', ], 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], - 'indexes' => [['ExpressNum', 'Id'], ['OrderNum', 'Id'], ['shopproid', 'Id'], ['CreationTime', 'Id']], + 'indexes' => [['ExpressNum', 'Id'], ['OrderNum(100)', 'Id'], ['shopproid(100)', 'Id'], ['CreationTime', 'Id']], ], 'jiansun.dbo.express_Original' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', @@ -274,7 +274,7 @@ return [ 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName', 'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime', ], - 'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid', 'Id']], + 'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid(100)', 'Id']], ], 'jiansun.dbo.ekf_BookkeepingConfig' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', @@ -290,7 +290,7 @@ return [ 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid', ], - 'indexes' => [['ExpressNum', 'OrderNum', 'Id'], ['shopproid', 'Id']], + 'indexes' => [['ExpressNum', 'OrderNum(100)', 'Id'], ['shopproid(100)', 'Id']], ], 'jiansun.dbo.ekf_rCheckPieceConfig' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', diff --git a/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php b/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php new file mode 100644 index 00000000..acf20dd2 --- /dev/null +++ b/database/migrations/2026_10_09_000000_create_courier_surcharge_settlement_rows_table.php @@ -0,0 +1,67 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('courier_surcharge_settlement_rows')) { + return; + } + + $schema->create('courier_surcharge_settlement_rows', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('rule_id'); + $table->string('surcharge_type', 40); + $table->string('courier_name', 191); + $table->string('waybill', 191)->nullable(); + $table->unsignedBigInteger('outbound_io_id')->nullable(); + $table->string('platform_product_id', 191)->nullable(); + $table->string('product_code', 191)->nullable(); + $table->string('shop_name', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->decimal('length', 12, 2)->nullable(); + $table->decimal('width', 12, 2)->nullable(); + $table->decimal('height', 12, 2)->nullable(); + $table->decimal('volume', 16, 2)->nullable(); + $table->string('inspection_type', 60)->nullable(); + $table->string('inspection_reason', 120)->nullable(); + $table->string('return_waybill_no', 191)->nullable(); + $table->decimal('settlement_weight', 12, 2)->nullable(); + $table->decimal('settlement_amount', 12, 2)->default(0); + $table->string('settlement_status', 40)->default('已计算'); + // 判定依据(如:最长边 168cm > 160cm;条形码 XXX 尺寸 168×20×10)。 + $table->string('evidence', 500)->nullable(); + $table->dateTime('shipped_at')->nullable(); + $table->dateTime('pulled_at'); + $table->timestamps(); + + // 一个报价规则下,同一张出库单只保留一行,重复拉取直接覆盖。 + $table->unique(['rule_id', 'outbound_io_id'], 'surcharge_settlement_rule_order_unique'); + $table->index(['courier_name', 'id'], 'surcharge_settlement_courier_index'); + $table->index(['waybill', 'id'], 'surcharge_settlement_waybill_index'); + $table->index(['shipped_at', 'id'], 'surcharge_settlement_shipped_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('courier_surcharge_settlement_rows'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php index 0707bfd7..97c49952 100644 --- a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php @@ -2,610 +2,993 @@ namespace Modules\EcommerceFinance\Repositories; -use App\Models\Business\CourierSurchargeQuote; +use Carbon\CarbonInterface; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; -use Illuminate\Support\Collection; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; +use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory; +use RuntimeException; +use Throwable; /** - * 附加费结算的只读业务镜像入口。 + * 「快递应付 → 附加费报价维护 → 附加费结算」的只读仓库。 * - * 聚水潭原表只在共享镜像刷新任务中读取;页面请求永远不连接上游。 - * 类型和审核条件在这里固定,避免用户输入进入 SQL 或绕过业务范围。 + * 结算行由 `courier-surcharge:pull-overlength` 按启用的附加费报价规则 + * (第一期只处理“超长费 + 按尺寸/最长边”)从销售出库单拉取后写入 + * courier_surcharge_settlement_rows;页面只读该表,不在请求中回源上游。 */ class CourierSurchargeSettlementMirrorRepository { - public const PRIMARY_DATASET = 'jiansun.dbo.ekf_Bookkeeping'; - public const EXPRESS_ORIGINAL_DATASET = 'jiansun.dbo.express_Original'; - public const BOOKKEEPING_CONFIG_DATASET = 'jiansun.dbo.ekf_BookkeepingConfig'; - public const CHECK_DATASET = 'jiansun.dbo.ekf_rCheck_Piece'; - public const CHECK_CONFIG_DATASET = 'jiansun.dbo.ekf_rCheckPieceConfig'; - public const SHIPPING_DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details'; - public const SHIPPING_EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension'; - public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo'; - public const RETURN_DATASET = 'jiansun.dbo.ekf_ReturnPickupManagement'; + public const TABLE = 'courier_surcharge_settlement_rows'; - /** @var list */ - private const REQUIRED_DATASETS = [ - self::PRIMARY_DATASET, - self::EXPRESS_ORIGINAL_DATASET, - self::BOOKKEEPING_CONFIG_DATASET, - self::CHECK_DATASET, - self::CHECK_CONFIG_DATASET, - self::SHIPPING_DETAILS_DATASET, - self::SHIPPING_EXTENSION_DATASET, - self::OUTBOUND_DATASET, - self::RETURN_DATASET, - ]; - - /** @var list */ + /** 页面附加费类型下拉使用的固定类型。 */ public const SURCHARGE_TYPES = [ - '退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', + '退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', '超长费', ]; - /** - * 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。 - * 其它说明、退回原因、空值都不能被当作审核通过。 - * - * @var list - */ - private const APPROVED_VALUES = ['通过', '同意', '同意记账']; + /** 超出多久没有重新拉取就标记为“稍有延迟”。 */ + private const STALE_AFTER_MINUTES = 1440; + + /** 查件工单里「查件类型/原因」级联字段。 */ + private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526'; + + /** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */ + private const IMPORT_RETURN_CONTEXT_LIMIT = 100; private readonly ConnectionInterface $connection; - public function __construct( - DatabaseManager $database, - private readonly BusinessMirrorDatasetRegistry $datasets, - private readonly GenericBusinessMirrorRepository $mirror, - ) { + public function __construct(DatabaseManager $database) + { $this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); } public function available(): bool { - $schema = Schema::connection($this->connection->getName()); - - if (! $schema->hasTable('business_mirror_table_states')) { + try { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } catch (Throwable) { return false; } - - foreach (self::REQUIRED_DATASETS as $key) { - if (! $schema->hasTable($this->table($key))) { - return false; - } - } - - return true; } public function ready(): bool { - foreach (self::REQUIRED_DATASETS as $key) { - $dataset = $this->datasets->get($key); - if (! $this->mirror->ready($dataset)) { - return false; - } - } - - return true; + return $this->available() && $this->connection->table(self::TABLE)->exists(); } /** @return array */ public function state(): array { - $states = []; - foreach (self::REQUIRED_DATASETS as $key) { - $dataset = $this->datasets->get($key); - $states[$key] = $this->mirror->state($dataset); + if (! $this->available()) { + return [ + 'code' => 'pending', + 'status' => 'pending', + 'last_succeeded_at' => null, + 'mirror_row_count' => 0, + ]; } - $required = array_intersect_key($states, array_flip(self::REQUIRED_DATASETS)); - $failed = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'failed'); - $pending = collect($required)->first(fn (?array $state): bool => ($state['initial_completed_at'] ?? null) === null); - $running = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'running'); - // The page is only as fresh as its oldest required dataset. Using the - // newest timestamp here would hide a lagging lookup table behind a - // recently completed fact-table sync. - $lastSucceeded = collect($required) - ->map(fn (?array $state): ?string => $state['last_succeeded_at'] ?? null) - ->filter() - ->sort() - ->first(); - $staleMinutes = max((int) config('upstream_mirrors.courier_surcharge_settlement.stale_after_minutes', 60), 1); - $code = $failed !== null - ? 'failed' - : ($pending !== null ? 'pending' : ($running !== null ? 'syncing' : 'fresh')); - if ($code === 'fresh' && $lastSucceeded !== null && now()->diffInMinutes($lastSucceeded) > $staleMinutes) { - $code = 'stale'; + try { + $lastPulledAt = $this->connection->table(self::TABLE)->max('pulled_at'); + $rowCount = (int) $this->connection->table(self::TABLE)->count(); + } catch (Throwable) { + return [ + 'code' => 'pending', + 'status' => 'pending', + 'last_succeeded_at' => null, + 'mirror_row_count' => 0, + ]; } return [ - 'code' => $code, - 'last_succeeded_at' => $lastSucceeded, - 'failure_stage' => $failed === null ? null : $this->datasetKeyForState($required, $failed), - 'error_code' => $failed['last_error_code'] ?? null, - 'required' => $required, + 'code' => $lastPulledAt === null ? 'pending' : 'fresh', + 'status' => $lastPulledAt === null ? 'pending' : 'succeeded', + 'last_succeeded_at' => $lastPulledAt, + 'mirror_row_count' => $rowCount, + ]; + } + + /** @param array|null $state @return array{code:string,lag_minutes:?int} */ + public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array + { + $state ??= $this->state(); + $lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null); + + if ($lastSucceeded === null) { + return ['code' => 'pending', 'lag_minutes' => null]; + } + + $lagMinutes = (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60); + + return [ + 'code' => $lagMinutes > self::STALE_AFTER_MINUTES ? 'stale' : 'fresh', + 'lag_minutes' => $lagMinutes, ]; } /** * @param array $filters - * @param Collection $quotes - * @return array{data:Collection>,has_more:bool,total:int,summary:array,sync:array,state:array} + * @return array{data:list>,total:int,has_more:bool,summary:array,sync:array,state:array} */ - public function paginate(array $filters, int $page, int $limit, Collection $quotes): array + public function paginate(array $filters, int $page, int $limit, mixed $quotes = null): array { - $state = $this->state(); if (! $this->ready()) { - return [ - 'data' => collect(), 'has_more' => false, 'total' => 0, - 'summary' => ['count' => 0, 'settlement_amount' => 0.0], - 'sync' => $state, 'state' => $state, - ]; + throw new RuntimeException('surcharge_settlement_rows_not_ready'); } - $query = $this->baseQuery(); - $this->applyFilters($query, $filters); + $page = max($page, 1); $limit = min(max($limit, 1), 100); - if (($filters['status'] ?? '') !== '') { - // 结算状态来自本地报价匹配,不能下推到镜像 SQL。按稳定顺序 - // 流式读取并只保留当前页,避免大镜像表一次性 get() 造成内存峰值。 - $offset = (max($page, 1) - 1) * $limit; - $matched = collect(); - $total = 0; - foreach ($query->orderBy('b.CreationTime')->orderBy('b.Id')->cursor() as $sourceRow) { - $row = $this->mapRow($sourceRow, $quotes); - if ($row['settlement_status'] !== $filters['status']) { - continue; - } + $query = $this->filteredQuery($filters); + $total = (int) (clone $query)->count(); + $rows = $query + ->orderByDesc('shipped_at') + ->orderByDesc('id') + ->forPage($page, $limit) + ->get(); - if ($total >= $offset && $matched->count() < $limit) { - $matched->push($row); - } - $total++; - } - $rows = $matched; - } else { - $total = (clone $query)->count('b.Id'); - $rows = $query - ->orderBy('b.CreationTime') - ->orderBy('b.Id') - ->forPage(max($page, 1), $limit + 1) - ->get() - ->map(fn (object $row): array => $this->mapRow($row, $quotes)) - ->values(); + $data = []; + $amount = 0.0; + + foreach ($rows as $row) { + $mapped = $this->mapRow($row); + $amount += (float) ($mapped['settlement_amount'] ?? 0); + $data[] = $mapped; } - $hasMore = ($filters['status'] ?? '') !== '' - ? $total > ((max($page, 1) - 1) * $limit) + $limit - : $rows->count() > $limit; - $rows = $rows->take($limit)->values(); + + $state = $this->state(); return [ - 'data' => $rows, - 'has_more' => $hasMore, + 'data' => $data, 'total' => $total, - 'summary' => [ - 'count' => $total, - 'settlement_amount' => round((float) $rows->sum(fn (array $row): float => (float) ($row['settlement_amount'] ?? 0)), 2), - ], - 'sync' => $state, + 'has_more' => ($page * $limit) < $total, + 'summary' => ['count' => $total, 'settlement_amount' => round($amount, 2)], + 'sync' => ['code' => $state['code'], 'last_succeeded_at' => $state['last_succeeded_at']], 'state' => $state, ]; } - private function baseQuery(): Builder - { - $bookkeeping = $this->table(self::PRIMARY_DATASET); - $bookkeepingConfig = $this->table(self::BOOKKEEPING_CONFIG_DATASET); - $check = $this->table(self::CHECK_DATASET); - $checkConfig = $this->table(self::CHECK_CONFIG_DATASET); - - $query = $this->connection->table($bookkeeping . ' as b') - ->leftJoin($bookkeepingConfig . ' as bm', 'bm.Id', '=', 'b.ReasonId') - ->leftJoin($bookkeepingConfig . ' as bs', 'bs.Id', '=', 'b.SecodaryCause') - ->leftJoinSub($this->latestCheck(), 'r', function ($join): void { - $join->on('r.ExpressNum', '=', 'b.ExpressNum') - ->on('r.OrderNum', '=', 'b.OrderNum'); - }) - ->leftJoin($checkConfig . ' as rt', 'rt.Id', '=', 'r.CheckType') - ->leftJoin($checkConfig . ' as rr', function ($join): void { - $join->on('rr.Id', '=', 'r.CheckReason') - // 查件原因必须是所选查件类型的二级选项,避免同名配置串层。 - ->on('rr.ParentId', '=', 'r.CheckType'); - }); - - // 镜像同步使用同一固定条件;这里再次约束,避免旧镜像中残留未审核行。 - $query->whereIn('b.IsAgreeReason', $this->approvedValues()); - - // 物流公司、商品、店铺、尺寸和重量复用已经上线的物流业务镜像。 - // 先按单号+线上订单号精确关联,再用单号的最新记录补齐历史数据, - // 避免新增依赖旧的 fys_omsDataDay 快照表。 - $query->leftJoinSub($this->latestShippingDetailByOrder(), 'd', function ($join): void { - $join->on('d.logistic_id', '=', 'b.ExpressNum') - ->on('d.so_id', '=', 'b.OrderNum'); - })->leftJoinSub($this->latestShippingDetailByWaybill(), 'd0', function ($join): void { - $join->on('d0.logistic_id', '=', 'b.ExpressNum'); - })->leftJoinSub($this->latestShippingExtension(), 'x', function ($join): void { - $join->on('x.so_id', '=', 'b.OrderNum'); - })->leftJoinSub($this->latestOutbound(), 'o', function ($join): void { - $join->on('o.io_id', '=', 'x.io_id'); - })->leftJoinSub($this->latestExpressOriginal(), 'e', function ($join): void { - // express_Original.OrderNum 是附加费记录的物流单号;保留记账表 - // 字段作为后备,避免历史记录没有对应原始物流行时整行消失。 - $join->on('e.OrderNum', '=', 'b.ExpressNum'); - }); - - if ($this->hasTable(self::RETURN_DATASET)) { - $query->leftJoinSub($this->latestReturn(), 'ret', function ($join): void { - $join->where(function ($condition): void { - $condition->on('ret.transactionNo', '=', 'b.OrderNum') - ->orOn('ret.orderId', '=', 'b.OrderNum') - ->orOn('ret.returnExpressNo', '=', 'b.ExpressNum'); - }); - }); - } - - $type = $this->surchargeTypeExpression(); - $select = [ - 'b.Id as source_id', 'b.CreationTime as created_at', - $this->connection->raw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) as courier_name"), - $this->connection->raw("COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum) as waybill"), - $this->connection->raw("COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), '')) as platform_product_id"), - $this->connection->raw("COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), '')) as product_code"), - $this->connection->raw("COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode) as shop_name"), - $this->connection->raw("COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum) as online_order_no"), - $this->connection->raw("COALESCE(e.Lenght, x.l) as length"), - $this->connection->raw("COALESCE(e.Width, x.w) as width"), - $this->connection->raw("COALESCE(e.Height, x.h) as height"), - $this->connection->raw("COALESCE(e.Volume, NULLIF(o.f_volume, ''), NULLIF(o.volume, ''), NULLIF(x.volume, ''), b.Svolume) as volume"), - $this->connection->raw('COALESCE(rt.Name, \'\') as inspection_type'), - // “查件原因”必须来自查件配置的二级选项;记账次要原因 - // 不是同一业务字段,不能在这里兜底混入。 - $this->connection->raw("COALESCE(rr.Name, '') as inspection_reason"), - $this->connection->raw('ret.returnExpressNo as return_waybill_no'), - $this->connection->raw("COALESCE(e.SetWeight, NULLIF(o.f_weight, ''), NULLIF(o.weight, ''), NULLIF(d.f_weight, ''), NULLIF(d0.f_weight, ''), NULLIF(x.weight, ''), b.Skuweight) as settlement_weight"), - $this->connection->raw("COALESCE(e.SetMoney, b.Payfee) as source_amount"), - $this->connection->raw("{$type} as surcharge_type"), - 'b.IsAgreeReason as approval_status', 'bm.Name as primary_reason', 'bs.Name as secondary_reason', - ]; - - return $query->select($select); - } - - private function latestCheck(): Builder - { - $table = $this->table(self::CHECK_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.ExpressNum', '=', 'candidate.ExpressNum') - ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') - ->whereColumn('newer.Id', '>', 'candidate.Id'); - }) - ->select([ - 'candidate.ExpressNum', 'candidate.OrderNum', 'candidate.CheckType', 'candidate.CheckReason', - 'candidate.OmsSDName', 'candidate.DHLName', 'candidate.SkuCode', 'candidate.shopproid', - ]); - } - - private function latestExpressOriginal(): Builder - { - $table = $this->table(self::EXPRESS_ORIGINAL_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') - ->whereColumn('newer.Id', '>', 'candidate.Id'); - }) - ->select([ - 'candidate.Id', 'candidate.ExpressName', 'candidate.OrderNum', 'candidate.Lenght', - 'candidate.Width', 'candidate.Height', 'candidate.Volume', 'candidate.SkuCode', - 'candidate.JiaoYiNum', 'candidate.ShopName', 'candidate.SetMoney', 'candidate.SetWeight', - 'candidate.shopproid', - ]); - } - - private function latestShippingDetailByOrder(): Builder - { - $table = $this->table(self::SHIPPING_DETAILS_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') - ->whereColumn('newer.so_id', '=', 'candidate.so_id') - ->whereColumn('newer.id', '>', 'candidate.id'); - }) - ->select(['candidate.id', 'candidate.logistic_id', 'candidate.so_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); - } - - private function latestShippingDetailByWaybill(): Builder - { - $table = $this->table(self::SHIPPING_DETAILS_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') - ->whereColumn('newer.id', '>', 'candidate.id'); - }) - ->select(['candidate.id', 'candidate.logistic_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); - } - - private function latestShippingExtension(): Builder - { - $table = $this->table(self::SHIPPING_EXTENSION_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.so_id', '=', 'candidate.so_id') - ->whereColumn('newer.Id', '>', 'candidate.Id'); - }) - ->select(['candidate.Id', 'candidate.io_id', 'candidate.so_id', 'candidate.shop_i_id', 'candidate.sku_ids', 'candidate.shop_name', 'candidate.l', 'candidate.w', 'candidate.h', 'candidate.volume', 'candidate.weight']); - } - - private function latestOutbound(): Builder - { - $table = $this->table(self::OUTBOUND_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->whereColumn('newer.io_id', '=', 'candidate.io_id') - ->whereColumn('newer.Id', '>', 'candidate.Id'); - }) - ->select(['candidate.Id', 'candidate.io_id', 'candidate.f_weight', 'candidate.weight', 'candidate.f_volume', 'candidate.volume']); - } - - private function latestReturn(): Builder - { - $table = $this->table(self::RETURN_DATASET); - - return $this->connection->table($table . ' as candidate') - ->whereNotExists(function ($query) use ($table): void { - $query->selectRaw('1')->from($table . ' as newer') - ->where(function ($condition): void { - $condition->whereColumn('newer.transactionNo', '=', 'candidate.transactionNo') - ->orWhereColumn('newer.orderId', '=', 'candidate.orderId') - ->orWhereColumn('newer.returnExpressNo', '=', 'candidate.returnExpressNo'); - }) - ->whereColumn('newer.Id', '>', 'candidate.Id'); - }) - ->select(['candidate.orderId', 'candidate.transactionNo', 'candidate.returnExpressNo']); - } - /** @param array $filters */ - private function applyFilters(Builder $query, array $filters): void + private function filteredQuery(array $filters): Builder { - if (($filters['courier_name'] ?? '') !== '') { - $courier = trim((string) $filters['courier_name']); - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $courier) . '%'; - $query->whereRaw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) LIKE ? ESCAPE '!'", [$needle]); + $query = $this->connection->table(self::TABLE); + + foreach ([ + 'courier_name' => 'courier_name', + 'waybill' => 'waybill', + 'platform_product_id' => 'platform_product_id', + 'product_code' => 'product_code', + 'shop_name' => 'shop_name', + 'online_order_no' => 'online_order_no', + 'inspection_type' => 'inspection_type', + 'inspection_reason' => 'inspection_reason', + ] as $filter => $column) { + $value = trim((string) ($filters[$filter] ?? '')); + + if ($value !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]); + } } - $waybillColumn = "COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum)"; - if (($filters['waybill'] ?? '') !== '') { - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['waybill'])) . '%'; - $query->whereRaw($waybillColumn . " LIKE ? ESCAPE '!'", [$needle]); + + if (trim((string) ($filters['surcharge_type'] ?? '')) !== '') { + $query->where('surcharge_type', trim((string) $filters['surcharge_type'])); } - $onlineOrderColumn = "COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum)"; - if (($filters['online_order_no'] ?? '') !== '') { - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['online_order_no'])) . '%'; - $query->whereRaw($onlineOrderColumn . " LIKE ? ESCAPE '!'", [$needle]); + + if (trim((string) ($filters['status'] ?? '')) !== '') { + $query->where('settlement_status', trim((string) $filters['status'])); } - $platformProductColumn = "COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), ''))"; - $productCodeColumn = "COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), ''))"; - if (($filters['platform_product_id'] ?? '') !== '') { - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['platform_product_id'])) . '%'; - $query->whereRaw($platformProductColumn . " LIKE ? ESCAPE '!'", [$needle]); + + if (trim((string) ($filters['created_from'] ?? '')) !== '') { + $query->where('shipped_at', '>=', $filters['created_from'] . ' 00:00:00'); } - if (($filters['product_code'] ?? '') !== '') { - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['product_code'])) . '%'; - $query->whereRaw($productCodeColumn . " LIKE ? ESCAPE '!'", [$needle]); + + if (trim((string) ($filters['created_to'] ?? '')) !== '') { + $query->where('shipped_at', '<', Carbon::parse($filters['created_to'])->addDay()->toDateTimeString()); } - if (($filters['shop_name'] ?? '') !== '') { - $shop = trim((string) $filters['shop_name']); - $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $shop) . '%'; - $shopColumn = "COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode)"; - $query->whereRaw($shopColumn . " LIKE ? ESCAPE '!'", [$needle]); - } - if (($filters['created_from'] ?? '') !== '') { - $query->where('b.CreationTime', '>=', $filters['created_from'] . ' 00:00:00'); - } - $type = $this->surchargeTypeExpression(); - if (($filters['surcharge_type'] ?? '') !== '') $query->whereRaw("{$type} = ?", [$filters['surcharge_type']]); - if (($filters['inspection_type'] ?? '') !== '') $query->where('rt.Name', '=', $filters['inspection_type']); - if (($filters['inspection_reason'] ?? '') !== '') $query->where('rr.Name', '=', $filters['inspection_reason']); - if (($filters['created_to'] ?? '') !== '') $query->where('b.CreationTime', '<', date('Y-m-d 00:00:00', strtotime($filters['created_to'] . ' +1 day'))); - $query->whereRaw("{$type} IN (?, ?, ?, ?, ?, ?, ?, ?, ?)", self::SURCHARGE_TYPES); + + return $query; } - private function surchargeTypeExpression(): string + /** + * 导出用流式游标:按筛选条件顺序读取结算行,默认最多 5 万行。 + * + * @param array $filters + * @return \Generator + */ + public function exportRows(array $filters, int $maxRows = 50000): \Generator { - return "CASE - WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfReturn, '')))) IN ('已退回', '1', '是', 'true') THEN '退回费' - WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfBuckle, '')))) IN ('已扣单', '1', '是', 'true') THEN '拦截费' - WHEN COALESCE(bm.Name, '') LIKE '%退回%' OR COALESCE(bs.Name, '') LIKE '%退回%' - OR COALESCE(bm.Name, '') LIKE '%退货%' OR COALESCE(bs.Name, '') LIKE '%退货%' - OR COALESCE(rt.Name, '') LIKE '%退回%' OR COALESCE(rr.Name, '') LIKE '%退回%' THEN '退回费' - WHEN COALESCE(bm.Name, '') LIKE '%拦截%' OR COALESCE(bs.Name, '') LIKE '%拦截%' - OR COALESCE(b.expressCause, '') LIKE '%拦截%' - OR COALESCE(rt.Name, '') LIKE '%拦截%' OR COALESCE(rr.Name, '') LIKE '%拦截%' - OR COALESCE(rt.Name, '') LIKE '%扣单%' OR COALESCE(rr.Name, '') LIKE '%扣单%' THEN '拦截费' - WHEN COALESCE(bm.Name, '') LIKE '%改地址%' OR COALESCE(bs.Name, '') LIKE '%改地址%' - OR COALESCE(b.expressCause, '') LIKE '%改地址%' - OR COALESCE(rt.Name, '') LIKE '%改地址%' OR COALESCE(rr.Name, '') LIKE '%改地址%' THEN '改地址' - WHEN COALESCE(bm.Name, '') LIKE '%上楼%' OR COALESCE(bs.Name, '') LIKE '%上楼%' - OR COALESCE(b.expressCause, '') LIKE '%上楼%' - OR COALESCE(rt.Name, '') LIKE '%上楼%' OR COALESCE(rr.Name, '') LIKE '%上楼%' THEN '上楼费' - WHEN COALESCE(bm.Name, '') LIKE '%送货%' OR COALESCE(bs.Name, '') LIKE '%送货%' - OR COALESCE(b.expressCause, '') LIKE '%送货%' - OR COALESCE(rt.Name, '') LIKE '%送货%' OR COALESCE(rr.Name, '') LIKE '%送货%' THEN '送货费' - WHEN COALESCE(bm.Name, '') LIKE '%超区%' OR COALESCE(bs.Name, '') LIKE '%超区%' - OR COALESCE(b.expressCause, '') LIKE '%超区%' - OR COALESCE(rt.Name, '') LIKE '%超区%' OR COALESCE(rr.Name, '') LIKE '%超区%' THEN '超区费' - WHEN COALESCE(bm.Name, '') LIKE '%安装%' OR COALESCE(bs.Name, '') LIKE '%安装%' - OR COALESCE(b.expressCause, '') LIKE '%安装%' - OR COALESCE(rt.Name, '') LIKE '%安装%' OR COALESCE(rr.Name, '') LIKE '%安装%' THEN '安装费' - WHEN COALESCE(bm.Name, '') LIKE '%保费%' OR COALESCE(bs.Name, '') LIKE '%保费%' - OR COALESCE(bm.Name, '') LIKE '%保丢保损%' OR COALESCE(bs.Name, '') LIKE '%保丢保损%' - OR COALESCE(b.expressCause, '') LIKE '%保费%' - OR COALESCE(rt.Name, '') LIKE '%保费%' OR COALESCE(rr.Name, '') LIKE '%保费%' THEN '保费' - WHEN COALESCE(bm.Name, '') LIKE '%进仓%' OR COALESCE(bs.Name, '') LIKE '%进仓%' - OR COALESCE(b.expressCause, '') LIKE '%进仓%' - OR COALESCE(rt.Name, '') LIKE '%进仓%' OR COALESCE(rr.Name, '') LIKE '%进仓%' THEN '进仓费' - ELSE NULL END"; + if (! $this->ready()) { + throw new RuntimeException('surcharge_settlement_rows_not_ready'); + } + + $query = $this->filteredQuery($filters) + ->orderByDesc('shipped_at') + ->orderByDesc('id') + ->limit(min(max($maxRows, 1), 50000)); + + foreach ($query->cursor() as $row) { + yield $row; + } } - /** @return list */ - private function approvedValues(): array + /** + * 导入核验:按「单号 + 附加费类型」匹配结算行,给出正常结/不结结论与原因。 + * + * @param list $pairs + * @return list> + */ + public function decideImportRows(array $pairs): array { - $configured = (array) config('upstream_mirrors.courier_surcharge_settlement.approved_values', self::APPROVED_VALUES); - $values = array_values(array_filter(array_map('strval', $configured), static fn (string $value): bool => trim($value) !== '')); + if (! $this->ready()) { + throw new RuntimeException('surcharge_settlement_rows_not_ready'); + } - return $values === [] ? self::APPROVED_VALUES : $values; + $waybills = []; + + foreach ($pairs as $pair) { + $waybill = trim((string) ($pair['waybill'] ?? '')); + + if ($waybill !== '') { + $waybills[$waybill] = true; + } + } + + $rowsByWaybill = []; + + foreach (array_chunk(array_keys($waybills), 300) as $chunk) { + $rows = $this->connection->table(self::TABLE) + ->whereIn('waybill', $chunk) + ->orderByDesc('settlement_amount') + ->orderByDesc('id') + ->get(); + + foreach ($rows as $row) { + $waybill = trim((string) $row->waybill); + + if ($waybill !== '') { + $rowsByWaybill[$waybill][] = $row; + } + } + } + + $normalized = []; + + foreach ($pairs as $pair) { + $normalized[] = [ + 'row_number' => (int) ($pair['row_number'] ?? 0), + 'waybill' => trim((string) ($pair['waybill'] ?? '')), + 'surcharge_type' => $this->normalizeImportType((string) ($pair['surcharge_type'] ?? '')), + 'courier_name' => trim((string) ($pair['courier_name'] ?? '')), + ]; + } + + $unmatchedDeductWaybills = []; + + foreach ($normalized as $pair) { + if ($pair['waybill'] === '' || ! in_array($pair['surcharge_type'], ['退回费', '拦截费'], true)) { + continue; + } + + if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) { + $unmatchedDeductWaybills[$pair['waybill']] = true; + } + } + + $contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT; + $returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills)); + $ruleModes = $this->activeRuleModes(); + + $fallbackRows = []; + + foreach ($rowsByWaybill as $waybill => $rows) { + if ($rows !== []) { + $fallbackRows[$waybill] = $rows[0]; + } + } + + $contextSkuIds = []; + + foreach ($returnContext as $context) { + foreach ($context['items'] ?? [] as $item) { + $skuId = trim((string) ($item['sku_id'] ?? '')); + + if ($skuId !== '') { + $contextSkuIds[$skuId] = true; + } + } + } + + $contextWeights = $this->importWeights(array_keys($contextSkuIds)); + $contextReturns = $this->importReturns($returnContext); + $results = []; + + foreach ($normalized as $pair) { + $matched = null; + $mismatchedCourier = ''; + + if ($pair['waybill'] !== '') { + $candidates = $rowsByWaybill[$pair['waybill']] ?? []; + $matched = $this->matchImportRow($candidates, $pair['surcharge_type'], $pair['courier_name']); + + if ($matched === null && $pair['courier_name'] !== '') { + $other = $this->matchImportRow($candidates, $pair['surcharge_type']); + + if ($other !== null) { + $mismatchedCourier = trim((string) $other->courier_name); + } + } + } + + $results[] = $matched !== null + ? $this->importMatchedRow($pair, $matched) + : $this->importMissingRow( + $pair, + $returnContext, + $ruleModes, + $contextComplete, + $mismatchedCourier, + $fallbackRows[$pair['waybill']] ?? null, + $contextWeights, + $contextReturns, + ); + } + + return $results; } - /** @param Collection|CourierSurchargeQuote> $quotes */ - private function mapRow(object $row, Collection $quotes): array + private function normalizeImportType(string $type): string { - $type = trim((string) ($row->surcharge_type ?? '')); - $quote = $quotes->first(function (array|CourierSurchargeQuote $quote) use ($row, $type): bool { - $status = is_array($quote) ? ($quote['status_code'] ?? $quote['status'] ?? null) : $quote->status; - $courier = is_array($quote) ? ($quote['courier'] ?? '') : $quote->courier; - $types = is_array($quote) ? ($quote['types'] ?? []) : $quote->types; - - return $status === CourierSurchargeQuote::STATUS_ACTIVE - && (string) $courier === (string) ($row->courier_name ?? '') - && in_array($type, (array) $types, true); - }); - $weight = $this->number($row->settlement_weight ?? null); - $volume = $this->number($row->volume ?? null); - $length = $this->number($row->length ?? null); - $width = $this->number($row->width ?? null); - $height = $this->number($row->height ?? null); - $amount = $quote === null ? null : $this->calculate($quote, $weight, $volume, $row->source_amount ?? null, $length, $width, $height); + $type = trim($type); return [ - 'source_id' => (string) ($row->source_id ?? ''), - 'created_at' => (string) ($row->created_at ?? ''), - 'courier_name' => (string) ($row->courier_name ?? ''), - 'waybill' => (string) ($row->waybill ?? ''), - 'platform_product_id' => (string) ($row->platform_product_id ?? ''), - 'product_code' => (string) ($row->product_code ?? ''), - 'shop_name' => (string) ($row->shop_name ?? ''), - 'online_order_no' => (string) ($row->online_order_no ?? ''), - 'length' => $length, - 'width' => $width, - 'height' => $height, - 'volume' => $volume, - 'inspection_type' => (string) ($row->inspection_type ?? ''), - 'inspection_reason' => (string) ($row->inspection_reason ?? ''), - 'return_waybill_no' => (string) ($row->return_waybill_no ?? ''), - 'settlement_weight' => $weight, - 'settlement_amount' => $amount, - 'surcharge_type' => $type, - 'approval_status' => (string) ($row->approval_status ?? ''), - 'quote_standard' => is_array($quote) ? ($quote['standard'] ?? null) : $quote?->standard, - 'settlement_status' => $quote === null ? '未匹配报价' : ($amount === null ? '待核价' : '已计算'), + '退回件' => '退回费', + '退货费' => '退回费', + '退货件' => '退回费', + '超长' => '超长费', + '改地址费' => '改地址', + ][$type] ?? $type; + } + + /** + * @param iterable $rows + */ + private function matchImportRow(iterable $rows, string $type, string $courier = ''): ?object + { + $fallback = null; + + foreach ($rows as $row) { + if (trim((string) $row->surcharge_type) !== $type) { + continue; + } + + if ($courier !== '' && trim((string) $row->courier_name) !== $courier) { + continue; + } + + $fallback ??= $row; + + if ((float) ($row->settlement_amount ?? 0) > 0) { + return $row; + } + } + + return $fallback; + } + + /** + * @param array{row_number:int,waybill:string,surcharge_type:string} $pair + * @return array + */ + private function importMatchedRow(array $pair, object $row): array + { + $amount = (float) ($row->settlement_amount ?? 0); + $status = trim((string) ($row->settlement_status ?? '')); + $evidence = trim((string) ($row->evidence ?? '')); + $reason = ''; + + if ($amount > 0) { + $decision = '正常结'; + } else { + $decision = '不结'; + + if ($status === '未匹配报价') { + $reason = '未匹配到启用的报价规则'; + } elseif ($status === '待核价') { + $reason = '待核价:商品资料缺少重量或规则金额无法计算'; + } elseif (str_contains($evidence, '无进仓') || str_contains($evidence, '无退货')) { + $reason = '售后退货退款无进仓时间/实退数量'; + } else { + $reason = '结算金额为 0'; + } + } + + $mapped = $this->mapRow($row); + $mapped['settlement_status'] = $status; + $mapped['evidence'] = $evidence; + + return array_merge([ + 'row_number' => $pair['row_number'], + 'waybill' => $pair['waybill'], + 'surcharge_type' => $pair['surcharge_type'], + 'decision' => $decision, + 'reason' => $reason, + ], $mapped); + } + + /** + * @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair + * @param array> $returnContext + * @param array>> $ruleModes + * @param array $contextWeights + * @param array $contextReturns + * @return array + */ + private function importMissingRow( + array $pair, + array $returnContext, + array $ruleModes, + bool $contextComplete, + string $mismatchedCourier, + ?object $fallbackRow, + array $contextWeights, + array $contextReturns, + ): array { + $context = $returnContext[$pair['waybill']] ?? null; + $courier = $pair['courier_name']; + + if ($courier === '') { + $courier = trim((string) ($fallbackRow->courier_name ?? ($context['courier'] ?? ''))); + } + + $platformProductId = null; + $productCode = null; + $shopName = null; + $onlineOrderNo = null; + $inspectionType = null; + $inspectionReason = null; + $returnWaybillNo = null; + $settlementWeight = null; + $shippedAt = null; + $evidence = null; + + if ($fallbackRow !== null) { + // 同一运单已有其他类型的结算行时,直接复用其来源字段。 + $platformProductId = $fallbackRow->platform_product_id; + $productCode = $fallbackRow->product_code; + $shopName = $fallbackRow->shop_name; + $onlineOrderNo = $fallbackRow->online_order_no; + $inspectionType = $fallbackRow->inspection_type; + $inspectionReason = $fallbackRow->inspection_reason; + $returnWaybillNo = $fallbackRow->return_waybill_no; + $settlementWeight = $fallbackRow->settlement_weight; + $shippedAt = $fallbackRow->shipped_at; + $evidence = '未生成「' . $pair['surcharge_type'] . '」结算行;同运单已有「' . $fallbackRow->surcharge_type . '」结算记录'; + } elseif ($context !== null) { + // 没有任何结算行时,从查件扣单工单补齐快递、商品、店铺、线上下单等信息。 + $primary = $this->primaryImportItem($context['items'] ?? []); + $platformProductId = $primary['platform_product_id']; + $productCode = $primary['sku_id'] !== '' ? $primary['sku_id'] : $primary['i_id']; + $shopName = trim((string) ($context['shop_name'] ?? '')); + $onlineOrderNo = trim((string) ($context['online_order_no'] ?? '')); + $inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null; + $inspectionReason = trim((string) ($context['inspection_reason'] ?? '')); + $returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null; + $settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights); + $sendTime = trim((string) ($context['send_time'] ?? '')); + $shippedAt = $sendTime !== '' ? $sendTime : null; + $evidence = ($inspectionType ?? '查件') . ';未生成「' . $pair['surcharge_type'] . '」结算行'; + + $platformProductId = $platformProductId !== '' ? $platformProductId : null; + $productCode = $productCode !== '' ? $productCode : null; + $shopName = $shopName !== '' ? $shopName : null; + $onlineOrderNo = $onlineOrderNo !== '' ? $onlineOrderNo : null; + $inspectionReason = $inspectionReason !== '' ? $inspectionReason : null; + } + + return [ + 'row_number' => $pair['row_number'], + 'waybill' => $pair['waybill'], + 'surcharge_type' => $pair['surcharge_type'], + 'decision' => '不结', + 'reason' => $this->missingReason($pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier), + 'courier_name' => $courier !== '' ? $courier : null, + 'shipped_at' => $shippedAt, + 'platform_product_id' => $platformProductId, + 'product_code' => $productCode, + 'shop_name' => $shopName, + 'online_order_no' => $onlineOrderNo, + 'length' => null, + 'width' => null, + 'height' => null, + 'volume' => null, + 'inspection_type' => $inspectionType, + 'inspection_reason' => $inspectionReason, + 'return_waybill_no' => $returnWaybillNo !== null && $returnWaybillNo !== '' + ? mb_substr((string) $returnWaybillNo, 0, 191) + : null, + 'settlement_weight' => $settlementWeight !== null && (float) $settlementWeight > 0 + ? round((float) $settlementWeight, 2) + : null, + 'settlement_amount' => null, + 'settlement_status' => null, + 'evidence' => $evidence, ]; } - private function calculate(array|CourierSurchargeQuote $quote, ?float $weight, ?float $volume, mixed $sourceAmount, ?float $length = null, ?float $width = null, ?float $height = null): ?float - { - $mode = is_array($quote) ? (string) ($quote['mode'] ?? '') : (string) $quote->mode; - $standard = is_array($quote) ? ($quote['standard'] ?? null) : $quote->standard; - if ($mode === '固定单价') return $this->firstNumber($standard); - if ($mode === '按尺寸/最长边') { - // 尺寸报价以最长边和标准中的比较符匹配,例如: - // “最长边 > 160 cm:8 元/单”。没有明确阈值时不猜测金额。 - $dimensions = array_filter([$length, $width, $height], static fn (?float $dimension): bool => $dimension !== null); + /** + * @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair + * @param array $returnContext + * @param array>> $ruleModes + */ + private function missingReason( + array $pair, + array $returnContext, + array $ruleModes, + bool $contextComplete, + string $mismatchedCourier = '', + ): string { + $courier = $pair['courier_name']; + $type = $pair['surcharge_type']; - return $this->thresholdAmount($standard, $dimensions === [] ? null : max($dimensions)); - } - if ($mode === '按重量' && $weight !== null && $this->hasComparator($standard)) { - return $this->thresholdAmount($standard, $weight); - } - if ($mode === '按体积' && $volume !== null) { - $threshold = $this->thresholdAmount($standard, $volume); - if ($threshold !== null || $this->hasComparator($standard)) return $threshold; - } - $unit = $this->firstNumber($standard); - if ($unit !== null && $mode === '按重量' && $weight !== null) return round($unit * $weight, 2); - if ($unit !== null && $mode === '按体积' && $volume !== null) return round($unit * $volume, 2); - if ($mode === '发出运费倍数' && $sourceAmount !== null) { - $multiplier = $this->firstNumber($standard); - $amount = $this->number($sourceAmount); - - return $multiplier === null || $amount === null ? null : round($amount * $multiplier, 2); - } - if (in_array($mode, ['取发出运费', '按账单'], true)) { - return $this->number($sourceAmount); + if ($pair['waybill'] === '') { + return '单号为空'; } - return null; + if ($type === '') { + return '附加费类型为空'; + } + + if ($mismatchedCourier !== '') { + return '不结:该运单+类型匹配到快递「' . $mismatchedCourier . '」,与导入的快递「' . $courier . '」不一致'; + } + + if ($courier !== '') { + $modes = array_values(array_unique($ruleModes[$courier][$type] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $courier . '」没有启用的「' . $type . '」报价规则'; + } + + if ($type === '退回费') { + if (array_intersect($modes, ['固定单价', '按重量']) === []) { + return '不结:快递「' . $courier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + $context = $returnContext[$pair['waybill']] ?? null; + $inspectionType = trim((string) ($context['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + if ($context !== null) { + return '不结:快递「' . $courier . '」有启用的退回费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)'; + } + + return '不结:查件工单中没有该运单的扣单记录'; + } + + if ($type === '拦截费') { + if (array_intersect($modes, ['固定单价']) === []) { + return '不结:快递「' . $courier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + $context = $returnContext[$pair['waybill']] ?? null; + $inspectionType = trim((string) ($context['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + if ($context !== null) { + return '不结:快递「' . $courier . '」有启用的拦截费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)'; + } + + return '不结:查件工单中没有该运单的扣单记录'; + } + + if ($type === '超长费') { + return '不结:快递「' . $courier . '」有启用的超长费规则,但该运单未达条件或不在已拉取范围'; + } + + return '不结:快递「' . $courier . '」有启用规则(' . implode('、', $modes) . '),但「' . $type . '」暂未接入自动核算'; + } + + // 未提供快递名称时,沿用查件工单兜底原因。 + if ($type === '退回费') { + if (! $contextComplete) { + return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围'; + } + + $contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? '')); + + if ($contextCourier === '') { + return '不结:查件工单中没有该运单的扣单记录'; + } + + $inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + $modes = array_values(array_unique($ruleModes[$contextCourier]['退回费'] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $contextCourier . '」没有启用的退回费报价规则'; + } + + if (array_intersect($modes, ['固定单价', '按重量']) === []) { + return '不结:快递「' . $contextCourier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + return '不结:该运单未生成退回费结算行(不在已拉取范围或结算数据尚未同步)'; + } + + if ($type === '拦截费') { + if (! $contextComplete) { + return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围'; + } + + $contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? '')); + + if ($contextCourier === '') { + return '不结:查件工单中没有该运单的扣单记录'; + } + + $inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? '')); + + if ($inspectionType !== '' && $inspectionType !== '扣单') { + return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件'; + } + + $modes = array_values(array_unique($ruleModes[$contextCourier]['拦截费'] ?? [])); + + if ($modes === []) { + return '不结:快递「' . $contextCourier . '」没有启用的拦截费报价规则'; + } + + if (array_intersect($modes, ['固定单价']) === []) { + return '不结:快递「' . $contextCourier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算'; + } + + return '不结:该运单未生成拦截费结算行(不在已拉取范围或结算数据尚未同步)'; + } + + if ($type === '超长费') { + return '不结:未找到超长费结算行,单边未超报价阈值或不在已拉取范围'; + } + + return '不结:该附加费类型暂未接入自动核算'; } - private function thresholdAmount(?string $standard, ?float $value): ?float + /** + * 未匹配到结算行时,回查查件工单补齐快递、商品、店铺、线上下单号等信息。 + * + * @param list $waybills + * @return array> + */ + private function deductOrderContext(array $waybills): array { - if ($value === null) return null; - $text = trim((string) $standard); - if (preg_match('/(<=|>=|≤|≥|<|>|=)\s*(-?\d+(?:\.\d+)?)[^\d]{0,30}[::]\s*(-?\d+(?:\.\d+)?)/u', $text, $match) !== 1) { + $waybills = array_values(array_unique(array_filter( + array_map(static fn ($waybill): string => trim((string) $waybill), $waybills), + static fn (string $waybill): bool => $waybill !== '', + ))); + + if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) { + return []; + } + + $context = []; + + foreach (array_chunk($waybills, 50) as $chunk) { + try { + $rows = $this->connection->table('js_jst_work_order_info') + ->where('title', '查件') + ->where(function ($query) use ($chunk): void { + foreach ($chunk as $waybill) { + $query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%'); + } + }) + ->orderByDesc('Id') + ->get(['businessFieldList']); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $fields = json_decode((string) $row->businessFieldList, true); + + if (! is_array($fields)) { + continue; + } + + $flat = []; + + foreach ($fields as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $flat[(string) $field['fieldId']] = $field; + } + } + + $waybill = trim((string) ($flat['lIdField']['value'] ?? '')); + $cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? '')); + + if ($waybill === '') { + continue; + } + + if (isset($context[$waybill]) && ($context[$waybill]['inspection_type'] ?? '') === '扣单') { + continue; + } + + [$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, ''); + $items = []; + + foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) { + if (! is_array($group)) { + continue; + } + + $item = []; + + foreach ($group as $field) { + if (is_array($field) && isset($field['fieldId'])) { + $item[(string) $field['fieldId']] = $field['value'] ?? null; + } + } + + $skuId = strtoupper(trim((string) ($item['skuId'] ?? ''))); + + if ($skuId === '') { + continue; + } + + $items[] = [ + 'sku_id' => $skuId, + 'qty' => (float) ($item['qty'] ?? 0), + 'platform_product_id' => trim((string) ($item['platformItemId'] ?? '')), + 'i_id' => trim((string) ($item['iid'] ?? '')), + ]; + } + + $context[$waybill] = [ + 'courier' => trim((string) ($flat['logisticsField']['value'] ?? '')), + 'o_id' => (int) ($flat['o_id']['value'] ?? 0), + 'online_order_no' => trim((string) ($flat['orderField']['value'] ?? '')), + 'shop_name' => trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), + 'inspection_type' => trim((string) $inspectionType), + 'inspection_reason' => trim((string) $inspectionReason), + 'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')), + 'items' => $items, + ]; + } + } + + return $context; + } + + /** + * @param list> $items + * @return array{sku_id:string,i_id:string,platform_product_id:string} + */ + private function primaryImportItem(array $items): array + { + foreach ($items as $item) { + if (trim((string) ($item['platform_product_id'] ?? '')) !== '') { + return [ + 'sku_id' => strtoupper(trim((string) ($item['sku_id'] ?? ''))), + 'i_id' => trim((string) ($item['i_id'] ?? '')), + 'platform_product_id' => trim((string) $item['platform_product_id']), + ]; + } + } + + $first = $items[0] ?? []; + + return [ + 'sku_id' => strtoupper(trim((string) ($first['sku_id'] ?? ''))), + 'i_id' => trim((string) ($first['i_id'] ?? '')), + 'platform_product_id' => trim((string) ($first['platform_product_id'] ?? '')), + ]; + } + + /** + * @param list> $items + * @param array $weights + */ + private function importWeightSum(array $items, array $weights): ?float + { + $total = 0.0; + $found = false; + + foreach ($items as $item) { + $skuId = strtoupper(trim((string) ($item['sku_id'] ?? ''))); + $weight = $skuId !== '' ? ($weights[$skuId] ?? null) : null; + + if ($weight !== null) { + $found = true; + $total += (float) ($item['qty'] ?? 0) * $weight; + } + } + + return $found ? round($total, 2) : null; + } + + /** + * @param list $skuIds + * @return array + */ + private function importWeights(array $skuIds): array + { + $map = []; + + foreach (array_chunk($skuIds, 500) as $chunk) { + try { + $rows = $this->connection->table('ops_jst_sku') + ->whereIn('sku_id', $chunk) + ->get(['sku_id', 'weight']); + } catch (Throwable) { + return []; + } + + foreach ($rows as $row) { + $skuId = strtoupper(trim((string) $row->sku_id)); + $weight = $row->weight; + + if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) { + continue; + } + + $map[$skuId] = (float) $weight; + } + } + + return $map; + } + + /** + * @param array> $returnContext + * @return array + */ + private function importReturns(array $returnContext): array + { + $oIds = []; + + foreach ($returnContext as $context) { + $oId = (int) ($context['o_id'] ?? 0); + + if ($oId > 0) { + $oIds[$oId] = true; + } + } + + if ($oIds === []) { + return []; + } + + try { + $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica'); + } catch (Throwable) { + return []; + } + + $byOrder = []; + + foreach (array_chunk(array_keys($oIds), 200) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + try { + $rows = $upstream->select( + 'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date ' + . 'FROM rpt.jst_returnAndRefundInfo a ' + . 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id ' + . 'WHERE a.o_id IN (' . $placeholders . ')', + $chunk, + ); + } catch (Throwable) { + continue; + } + + foreach ($rows as $row) { + $row = (array) $row; + $oId = (int) ($row['o_id'] ?? 0); + $waybill = trim((string) ($row['l_id'] ?? '')); + + if ($oId <= 0 + || $waybill === '' + || (float) ($row['r_qty'] ?? 0) <= 0 + || trim((string) ($row['receive_date'] ?? '')) === '') { + continue; + } + + $byOrder[$oId][$waybill] = true; + } + } + + $map = []; + + foreach ($byOrder as $oId => $waybills) { + $map[(int) $oId] = implode(',', array_keys($waybills)); + } + + return $map; + } + + /** @return array>> */ + private function activeRuleModes(): array + { + try { + $rules = DB::table('business_courier_surcharge_quotes') + ->where('status', 'active') + ->get(['courier', 'types', 'mode']); + } catch (Throwable) { + return []; + } + + $map = []; + + foreach ($rules as $rule) { + $types = json_decode((string) $rule->types, true) ?: []; + $courier = trim((string) $rule->courier); + + if ($courier === '') { + continue; + } + + foreach ($types as $type) { + $type = trim((string) $type); + + if ($type !== '') { + $map[$courier][$type][] = trim((string) $rule->mode); + } + } + } + + return $map; + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } + + /** @return array */ + private function mapRow(object $row): array + { + return [ + 'courier_name' => $row->courier_name, + 'waybill' => $row->waybill, + 'shipped_at' => $row->shipped_at, + 'platform_product_id' => $row->platform_product_id, + 'product_code' => $row->product_code, + 'shop_name' => $row->shop_name, + 'online_order_no' => $row->online_order_no, + 'length' => $row->length, + 'width' => $row->width, + 'height' => $row->height, + 'volume' => $row->volume, + 'inspection_type' => $row->inspection_type, + 'inspection_reason' => $row->inspection_reason, + 'return_waybill_no' => $row->return_waybill_no, + 'settlement_weight' => $row->settlement_weight, + 'settlement_amount' => $row->settlement_amount, + 'surcharge_type' => $row->surcharge_type, + ]; + } + + private function timestamp(mixed $value): ?int + { + if (! is_string($value) || $value === '') { return null; } - $operator = $match[1]; - $threshold = (float) $match[2]; - $amount = (float) $match[3]; - $matched = match ($operator) { - '<' => $value < $threshold, - '<=', '≤' => $value <= $threshold, - '>' => $value > $threshold, - '>=', '≥' => $value >= $threshold, - '=' => abs($value - $threshold) < 0.000001, - default => false, - }; - - return $matched ? round($amount, 2) : null; - } - - private function hasComparator(?string $standard): bool - { - return preg_match('/(?:<=|>=|≤|≥|<|>|=)/u', (string) $standard) === 1; - } - - private function firstNumber(?string $value): ?float - { - return preg_match('/-?\d+(?:\.\d+)?/u', (string) $value, $match) === 1 ? (float) $match[0] : null; - } - - private function number(mixed $value): ?float - { - return is_numeric($value) ? (float) $value : null; - } - - private function table(string $datasetKey): string - { - return $this->datasets->get($datasetKey)->targetTable(); - } - - private function hasTable(string $datasetKey): bool - { - return Schema::connection($this->connection->getName())->hasTable($this->table($datasetKey)); - } - - /** @param array $states */ - private function datasetKeyForState(array $states, ?array $needle): ?string - { - foreach ($states as $key => $state) { - if ($state === $needle) return $key; + try { + return Carbon::parse($value)->getTimestamp(); + } catch (Throwable) { + return null; } - - return null; } } diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php index ce07f435..b2b6e67f 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -7,6 +7,7 @@ $error = $courierSurchargeSettlementError ?? null; $columns = [ ['key' => 'courier_name', 'label' => '快递名称', 'fixed' => 'true'], + ['key' => 'shipped_at', 'label' => '出库时间'], ['key' => 'waybill', 'label' => '单号'], ['key' => 'platform_product_id', 'label' => '平台商品ID'], ['key' => 'product_code', 'label' => '商品编码'], @@ -137,6 +138,10 @@ .settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-height: 2.2rem; padding: 0 .75rem; border: 1px solid var(--settlement-border); border-radius: .45rem; background: #fff; color: var(--settlement-ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; } .settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; } .settlement-button .material-icons-outlined { font-size: 1rem; } + .settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem 1rem; margin-top: .75rem; padding: .85rem 1.15rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } + .settlement-import-bar .settlement-field { flex: 0 1 22rem; } + .settlement-import-bar input[type="file"] { width: 100%; height: 2.2rem; padding: .35rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .45rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; } + .settlement-import-bar .settlement-table-hint { align-self: center; } .settlement-table-surface { overflow: visible; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } .settlement-table-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: .75rem 1rem; padding: .85rem 1.15rem; border-bottom: 1px solid var(--settlement-border); } @@ -171,6 +176,13 @@ } + @if (session('error')) +
+ +
导入核验失败
{{ session('error') }}
+
+ @endif + @if ($error !== null || ($sync['code'] ?? null) !== 'fresh')
@@ -200,11 +212,24 @@
-
+
-
重置
+ + + +
+ @csrf +
+ + +
+
+ + 下载导入模板 + 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。 +
diff --git a/routes/admin.php b/routes/admin.php index 7a9e9cd9..4de1c554 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -352,6 +352,19 @@ Route::get('workspace/courier-payables/return-pickup-management/export', 'Busine ->middleware('business.page:courier-payables,return-pickup-management') ->name('business.courier-payables.return-pickup-management.export'); +// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。 +Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.export'); + +Route::post('workspace/courier-payables/courier-surcharge/settlement/import', 'Business\Pages@importCourierSurchargeSettlement') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.import'); + +Route::get('workspace/courier-payables/courier-surcharge/settlement/import-template', 'Business\Pages@downloadCourierSurchargeSettlementImportTemplate') + ->middleware('business.page:courier-payables,courier-surcharge') + ->name('business.courier-surcharge.settlement.import-template'); + Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage') ->middleware('business.page:courier-payables,third-party-warehouse-storage') ->name('business.courier-payables.third-party-warehouse-storage.export'); diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index 4eb366fc..7e9686f0 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -3,6 +3,13 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use Illuminate\Http\UploadedFile; +use Mockery; +use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; +use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\RichText\RichText; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use Tests\Feature\FeatureTestCase; class CourierSurchargeSettlementPageTest extends FeatureTestCase @@ -23,6 +30,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ->assertSee('data-finance-fixed="true"', false) ->assertSee('data-finance-fixed="end"', false) ->assertSeeText('快递名称') + ->assertSeeText('出库时间') ->assertSeeText('平台商品ID') ->assertSeeText('查件原因') ->assertSeeText('退回单号') @@ -91,4 +99,206 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase 'waybill' => '0000123456789', ])); } + + public function test_settlement_export_streams_filtered_rows_as_xlsx(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class); + $repository->shouldReceive('exportRows') + ->once() + ->withArgs(static fn (array $filters): bool => ($filters['courier_name'] ?? '') === '曹县德邦') + ->andReturn((function (): \Generator { + yield (object) [ + 'courier_name' => '曹县德邦', + 'waybill' => 'DPK317249507921', + 'shipped_at' => '2026-10-09 08:39:40', + 'platform_product_id' => null, + 'product_code' => 'DNZ032912N887V01', + 'shop_name' => '拼多多-满富油家具旗舰店', + 'online_order_no' => '261009-325069063762746', + 'length' => 194, + 'width' => 83, + 'height' => 12.5, + 'volume' => 201305, + 'inspection_type' => null, + 'inspection_reason' => null, + 'return_waybill_no' => null, + 'settlement_weight' => null, + 'settlement_amount' => 8, + 'surcharge_type' => '超长费', + ]; + })()); + $this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.courier-surcharge.settlement.export', [ + 'courier_name' => '曹县德邦', + ])); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'surcharge_settlement_export_'); + file_put_contents($path, $response->streamedContent()); + $sheet = IOFactory::load($path)->getActiveSheet(); + + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + $headers = []; + + foreach ($sheet->getRowIterator(1, 1) as $row) { + foreach ($row->getCellIterator() as $cell) { + $headers[$cell->getColumn()] = $plain($cell->getValue()); + } + } + + $column = static fn (string $label): string => (string) array_search($label, $headers, true); + $value = static fn (string $label): mixed => $plain($sheet->getCell($column($label) . '2')->getValue()); + + try { + $this->assertSame('快递名称', $headers['A']); + $this->assertSame('出库时间', $headers['B']); + $this->assertSame('单号', $headers['C']); + $this->assertSame('附加费类型', $headers['Q']); + $this->assertSame('曹县德邦', $value('快递名称')); + $this->assertSame('DPK317249507921', $value('单号')); + $this->assertSame('2026-10-09 08:39:40', $value('出库时间')); + $this->assertSame('DNZ032912N887V01', $value('商品编码')); + $this->assertSame('拼多多-满富油家具旗舰店', $value('店铺名称')); + $this->assertEquals(194, $value('长')); + $this->assertEquals(12.5, $value('高')); + $this->assertEquals(201305, $value('体积')); + $this->assertEquals(8, $value('结算金额')); + $this->assertSame('超长费', $value('附加费类型')); + $this->assertNull($value('查件类型')); + } finally { + unlink($path); + } + } + + public function test_settlement_import_streams_decision_workbook(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class); + $repository->shouldReceive('decideImportRows') + ->once() + ->withArgs(static function (array $pairs): bool { + return count($pairs) === 2 + && $pairs[0]['waybill'] === 'DPK317138931411' + && $pairs[0]['surcharge_type'] === '退回费' + && $pairs[0]['courier_name'] === '曹县德邦小件' + && $pairs[0]['amount'] === 10.0 + && $pairs[1]['waybill'] === 'DPK1' + && $pairs[1]['surcharge_type'] === '超长费' + && $pairs[1]['courier_name'] === '' + && $pairs[1]['amount'] === null; + }) + ->andReturn([ + [ + 'row_number' => 2, 'waybill' => 'DPK317138931411', 'surcharge_type' => '退回费', + 'decision' => '正常结', 'reason' => '', 'courier_name' => '曹县德邦小件', + 'shipped_at' => '2026-07-09 13:25:51', 'platform_product_id' => '968882879362', + 'product_code' => 'YMJ000904N722', 'shop_name' => '拼多多-卓禾住宅家具旗舰店', + 'online_order_no' => '260709-594595049040320', 'length' => null, 'width' => null, + 'height' => null, 'volume' => null, 'inspection_type' => '扣单', + 'inspection_reason' => '买家-不想要', 'return_waybill_no' => 'DPK317138931411', + 'settlement_weight' => 2.4, 'settlement_amount' => 20, 'settlement_status' => '已计算', + 'evidence' => '查件扣单/买家-不想要;退款确认 2026-07-15 15:00:09;售后退货退款已进仓 2026-07-18 09:53:32;实退 1', + ], + [ + 'row_number' => 3, 'waybill' => 'DPK1', 'surcharge_type' => '超长费', + 'decision' => '不结', 'reason' => '未找到超长费结算行:单边未超报价阈值或不在已拉取范围', + 'courier_name' => null, 'shipped_at' => null, 'platform_product_id' => null, + 'product_code' => null, 'shop_name' => null, 'online_order_no' => null, + 'length' => null, 'width' => null, 'height' => null, 'volume' => null, + 'inspection_type' => null, 'inspection_reason' => null, 'return_waybill_no' => null, + 'settlement_weight' => null, 'settlement_amount' => null, 'settlement_status' => null, + 'evidence' => null, + ], + ]); + $this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository); + + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray([ + ['单号', '附加费类型', '快递名称', '金额'], + ['DPK317138931411', '退回费', '曹县德邦小件', 10], + ['DPK1', '超长费'], + ]); + $importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_'); + (new Xlsx($spreadsheet))->save($importPath); + + $response = $this->loginAs()->post(route('business.courier-surcharge.settlement.import'), [ + 'file' => new UploadedFile( + $importPath, + 'surcharge-import.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true, + ), + ]); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $resultPath = tempnam(sys_get_temp_dir(), 'surcharge_import_result_'); + file_put_contents($resultPath, $response->streamedContent()); + $sheet = IOFactory::load($resultPath)->getActiveSheet(); + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + + try { + $this->assertSame('序号', $plain($sheet->getCell('A1')->getValue())); + $this->assertSame('结算结论', $plain($sheet->getCell('D1')->getValue())); + $this->assertSame('不结原因', $plain($sheet->getCell('E1')->getValue())); + $this->assertSame('正常结', $plain($sheet->getCell('D2')->getValue())); + $this->assertSame('DPK317138931411', $plain($sheet->getCell('B2')->getValue())); + $this->assertSame('退回费', $plain($sheet->getCell('C2')->getValue())); + $this->assertSame('导入金额', $plain($sheet->getCell('U1')->getValue())); + $this->assertSame('差异金额', $plain($sheet->getCell('V1')->getValue())); + $this->assertEquals(10, $plain($sheet->getCell('U2')->getValue())); + $this->assertEquals(10, $plain($sheet->getCell('V2')->getValue())); + $this->assertSame('不结', $plain($sheet->getCell('D3')->getValue())); + $this->assertStringContainsString('未找到超长费', (string) $plain($sheet->getCell('E3')->getValue())); + $this->assertNull($plain($sheet->getCell('V3')->getValue())); + } finally { + @unlink($importPath); + @unlink($resultPath); + } + } + + public function test_settlement_import_template_streams_workbook(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $response = $this->loginAs()->get(route('business.courier-surcharge.settlement.import-template')); + + $response->assertOk(); + $this->assertStringContainsString( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + (string) $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'surcharge_import_template_'); + file_put_contents($path, $response->streamedContent()); + $sheet = IOFactory::load($path)->getActiveSheet(); + $plain = static fn (mixed $value): mixed => $value instanceof RichText ? $value->getPlainText() : $value; + + try { + $this->assertSame('单号', $plain($sheet->getCell('A1')->getValue())); + $this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue())); + $this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue())); + $this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue())); + $this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue())); + $this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue())); + $this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue())); + $this->assertEquals(20, $plain($sheet->getCell('D2')->getValue())); + } finally { + @unlink($path); + } + } } diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php index ce123d97..72136511 100644 --- a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorConfigTest.php @@ -3,57 +3,20 @@ namespace Tests\Unit\EcommerceFinance; use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; -use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use Tests\TestCase; class CourierSurchargeSettlementMirrorConfigTest extends TestCase { - public function test_settlement_sources_are_registered_with_stable_keys_and_fixed_approval_filter(): void + public function test_settlement_rows_table_contract(): void { - $registry = new BusinessMirrorDatasetRegistry(); - $dataset = $registry->get(CourierSurchargeSettlementMirrorRepository::PRIMARY_DATASET); - - $this->assertSame('jiansun', $dataset->sourceKey()); - $this->assertSame('dbo', $dataset->sourceSchema()); - $this->assertSame('ekf_Bookkeeping', $dataset->sourceObject()); - $this->assertSame('js_ekf_bookkeeping_surcharge', $dataset->targetTable()); - $this->assertSame(['Id'], $dataset->keyColumns()); - $this->assertSame('CreationTime', $dataset->watermarkColumn()); - $this->assertSame(['通过', '同意', '同意记账'], $dataset->fixedFilters()['IsAgreeReason']); - $this->assertSame(['通过', '同意', '同意记账'], config('upstream_mirrors.courier_surcharge_settlement.approved_values')); - - foreach ([ - CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET, - CourierSurchargeSettlementMirrorRepository::BOOKKEEPING_CONFIG_DATASET, - CourierSurchargeSettlementMirrorRepository::CHECK_DATASET, - CourierSurchargeSettlementMirrorRepository::CHECK_CONFIG_DATASET, - CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET, - CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET, - CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET, - CourierSurchargeSettlementMirrorRepository::RETURN_DATASET, - ] as $key) { - $this->assertSame('jiansun', $registry->get($key)->sourceKey()); - $this->assertNotSame('', $registry->get($key)->targetTable()); - } - - $expressOriginal = $registry->get(CourierSurchargeSettlementMirrorRepository::EXPRESS_ORIGINAL_DATASET); - $this->assertSame('express_Original', $expressOriginal->sourceObject()); - $this->assertSame('js_express_original', $expressOriginal->targetTable()); - $this->assertSame('DeliveryTime', $expressOriginal->watermarkColumn()); - - $this->assertSame('js_jst_order_express_details', $registry - ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_DETAILS_DATASET)->targetTable()); - $this->assertSame('js_jst_send_data_view_extension', $registry - ->get(CourierSurchargeSettlementMirrorRepository::SHIPPING_EXTENSION_DATASET)->targetTable()); - $this->assertSame('js_jst_sales_outbound_info', $registry - ->get(CourierSurchargeSettlementMirrorRepository::OUTBOUND_DATASET)->targetTable()); + $this->assertSame('courier_surcharge_settlement_rows', CourierSurchargeSettlementMirrorRepository::TABLE); } - public function test_surcharge_type_contract_has_only_the_requested_nine_types(): void + public function test_surcharge_type_contract_includes_overlength_alongside_the_nine_types(): void { $this->assertSame([ '退回费', '拦截费', '改地址', '上楼费', '送货费', - '超区费', '安装费', '保费', '进仓费', + '超区费', '安装费', '保费', '进仓费', '超长费', ], CourierSurchargeSettlementMirrorRepository::SURCHARGE_TYPES); } } diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php index cdcd9984..2ed8a50c 100644 --- a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php @@ -2,13 +2,10 @@ namespace Tests\Unit\EcommerceFinance; -use App\Models\Business\CourierSurchargeQuote; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; -use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; -use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use Tests\TestCase; class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase @@ -24,175 +21,180 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', - 'upstream_mirrors.courier_surcharge_settlement.approved_values' => ['通过', '同意', '同意记账'], - 'upstream_mirrors.datasets' => [ - 'jiansun.dbo.ekf_Bookkeeping' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', - 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', - 'columns' => ['Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'], - 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], - ], - 'jiansun.dbo.express_Original' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', - 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', - 'columns' => ['Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'], - ], - 'jiansun.dbo.ekf_BookkeepingConfig' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', - 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', - 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], - ], - 'jiansun.dbo.ekf_rCheck_Piece' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', - 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', - 'columns' => ['Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'], - ], - 'jiansun.dbo.ekf_rCheckPieceConfig' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', - 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', - 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], - ], - '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', - '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, - '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', - 'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'], - ], - 'jiansun.dbo.ekf_ReturnPickupManagement' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', - 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', - 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], - ], - ], ]); + DB::purge('business_mirror'); - $schema = Schema::connection('business_mirror'); - $schema->create('business_mirror_table_states', function (Blueprint $table): void { - $table->string('dataset_key')->primary(); - $table->dateTime('initial_completed_at')->nullable(); - $table->string('status')->nullable(); - $table->dateTime('last_succeeded_at')->nullable(); - $table->string('last_error_code')->nullable(); - }); - $this->createMirrorTables($schema); + $this->repository = new CourierSurchargeSettlementMirrorRepository(app('db')); + } - $this->repository = new CourierSurchargeSettlementMirrorRepository( - app('db'), - new BusinessMirrorDatasetRegistry(), - new GenericBusinessMirrorRepository(app('db')), + public function test_it_reports_unavailable_until_the_settlement_table_exists(): void + { + $this->assertFalse($this->repository->available()); + $this->assertFalse($this->repository->ready()); + $this->assertSame('pending', $this->repository->state()['code']); + } + + public function test_it_filters_maps_and_summarises_settlement_rows(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01', + 'shop_name' => '拼多多-满富油家具旗舰店', 'online_order_no' => '261009-325069063762746', + 'length' => 194, 'width' => 83, 'height' => 12.5, 'volume' => 201305, + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 194cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦', + 'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A', + 'shop_name' => '京东-HX旗舰店104', 'online_order_no' => null, + 'length' => 244, 'width' => 7.5, 'height' => 8, 'volume' => 14640, + 'settlement_amount' => 7, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-08 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 244cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $this->assertTrue($this->repository->available()); + $this->assertTrue($this->repository->ready()); + + $result = $this->repository->paginate([], 1, 25); + + $this->assertSame(2, $result['total']); + $this->assertSame(15.0, $result['summary']['settlement_amount']); + $this->assertSame('曹县德邦', $result['data'][0]['courier_name']); + $this->assertSame('DPK1', $result['data'][0]['waybill']); + $this->assertSame('2026-10-09 08:41:48', $result['data'][0]['shipped_at']); + $this->assertSame('超长费', $result['data'][0]['surcharge_type']); + $this->assertSame(194.0, (float) $result['data'][0]['length']); + $this->assertSame(8.0, (float) $result['data'][0]['settlement_amount']); + $this->assertSame('fresh', $result['sync']['code']); + } + + public function test_it_applies_page_filters(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'DNZ032912N887V01', + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 42, 'surcharge_type' => '超长费', 'courier_name' => '常州德邦', + 'waybill' => 'DPK2', 'outbound_io_id' => 1002, 'product_code' => 'GBGJ-ZWJ024903A', + 'settlement_amount' => 7, 'settlement_status' => '待核价', + 'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $byCourier = $this->repository->paginate(['courier_name' => '常州', 'status' => '待核价'], 1, 25); + $this->assertSame(1, $byCourier['total']); + $this->assertSame('常州德邦', $byCourier['data'][0]['courier_name']); + $this->assertSame(7.0, $byCourier['summary']['settlement_amount']); + + $byDate = $this->repository->paginate(['created_from' => '2026-10-01', 'created_to' => '2026-10-09'], 1, 25); + $this->assertSame(1, $byDate['total']); + $this->assertSame('DPK1', $byDate['data'][0]['waybill']); + + $byWaybill = $this->repository->paginate(['waybill' => 'DPK2'], 1, 25); + $this->assertSame(1, $byWaybill['total']); + $this->assertSame('常州德邦', $byWaybill['data'][0]['courier_name']); + } + + public function test_import_decisions_report_settlement_and_reasons(): void + { + $this->createTable(); + + DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([ + [ + 'rule_id' => 6, 'surcharge_type' => '超长费', 'courier_name' => '曹县德邦', + 'waybill' => 'DPK1', 'outbound_io_id' => 1001, 'product_code' => 'SKU-1', + 'settlement_amount' => 8, 'settlement_status' => '已计算', + 'shipped_at' => '2026-10-09 08:41:48', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '最长边 194cm > 160cm', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + [ + 'rule_id' => 7, 'surcharge_type' => '退回费', 'courier_name' => '曹县德邦小件', + 'waybill' => 'DPK2', 'outbound_io_id' => null, 'product_code' => 'SKU-2', + 'settlement_amount' => 0, 'settlement_status' => '已计算', + 'shipped_at' => '2026-09-01 10:00:00', 'pulled_at' => '2026-10-09 12:00:00', + 'evidence' => '查件扣单/买家-不想要;售后退货退款无进仓/实退记录;结算金额 0.00', + 'created_at' => '2026-10-09 12:00:00', 'updated_at' => '2026-10-09 12:00:00', + ], + ]); + + $result = $this->repository->decideImportRows([ + ['row_number' => 2, 'waybill' => 'DPK1', 'surcharge_type' => '超长费'], + ['row_number' => 3, 'waybill' => 'DPK2', 'surcharge_type' => '退回件'], + ['row_number' => 4, 'waybill' => 'DPK3', 'surcharge_type' => '退回费'], + ['row_number' => 5, 'waybill' => 'DPK4', 'surcharge_type' => '退回费', 'courier_name' => '恒思德邦'], + ['row_number' => 6, 'waybill' => 'DPK1', 'surcharge_type' => '超长费', 'courier_name' => '常州德邦'], + ]); + + $this->assertSame('正常结', $result[0]['decision']); + $this->assertSame('', $result[0]['reason']); + $this->assertSame('曹县德邦', $result[0]['courier_name']); + $this->assertSame(8.0, (float) $result[0]['settlement_amount']); + + $this->assertSame('不结', $result[1]['decision']); + $this->assertSame('退回费', $result[1]['surcharge_type']); + $this->assertStringContainsString('无进仓', $result[1]['reason']); + + $this->assertSame('不结', $result[2]['decision']); + $this->assertNotSame('', $result[2]['reason']); + $this->assertNull($result[2]['settlement_amount']); + + $this->assertSame('不结', $result[3]['decision']); + $this->assertStringContainsString('恒思德邦', $result[3]['reason']); + $this->assertSame('恒思德邦', $result[3]['courier_name']); + + $this->assertSame('不结', $result[4]['decision']); + $this->assertStringContainsString('不一致', $result[4]['reason']); + } + + private function createTable(): void + { + Schema::connection('business_mirror')->create( + CourierSurchargeSettlementMirrorRepository::TABLE, + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('rule_id'); + $table->string('surcharge_type', 40); + $table->string('courier_name', 191); + $table->string('waybill', 191)->nullable(); + $table->unsignedBigInteger('outbound_io_id')->nullable(); + $table->string('platform_product_id', 191)->nullable(); + $table->string('product_code', 191)->nullable(); + $table->string('shop_name', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->decimal('length', 12, 2)->nullable(); + $table->decimal('width', 12, 2)->nullable(); + $table->decimal('height', 12, 2)->nullable(); + $table->decimal('volume', 16, 2)->nullable(); + $table->string('inspection_type', 60)->nullable(); + $table->string('inspection_reason', 120)->nullable(); + $table->string('return_waybill_no', 191)->nullable(); + $table->decimal('settlement_weight', 12, 2)->nullable(); + $table->decimal('settlement_amount', 12, 2)->default(0); + $table->string('settlement_status', 40)->default('已计算'); + $table->string('evidence', 500)->nullable(); + $table->dateTime('shipped_at')->nullable(); + $table->dateTime('pulled_at'); + $table->timestamps(); + }, ); } - - public function test_it_maps_approved_bookkeeping_to_requested_fields_and_uses_outbound_weight(): void - { - $db = DB::connection('business_mirror'); - $this->markReady($db); - - $db->table('js_ekf_bookkeeping_config')->insert([ - ['Id' => 10, 'Name' => '退回', 'ParentId' => null], - ['Id' => 20, 'Name' => '二级原因', 'ParentId' => 1], - ]); - $db->table('js_ekf_rcheck_piece_config')->insert([ - ['Id' => 1, 'ParentId' => null, 'Name' => '一级查件', 'Code' => null, 'Type' => null, 'Delete' => null], - ['Id' => 2, 'ParentId' => 1, 'Name' => '二级查件原因', 'Code' => null, 'Type' => null, 'Delete' => null], - ]); - $db->table('js_ekf_bookkeeping_surcharge')->insert([ - 'Id' => 1, 'CreationTime' => '2026-09-27 10:00:00', 'OrderNum' => 'SO-1', 'ExpressNum' => 'WB-1', - 'ExpressName' => '备用快递', 'ShopCode' => '备用店铺', 'SkuCode' => '备用编码', 'Payfee' => 3.2, - 'SecodaryCause' => 20, 'ReasonId' => 10, 'IsAgreeReason' => '同意', 'Svolume' => 1.1, - 'Skuweight' => 0.9, 'IfReturn' => '已退回', 'IfBuckle' => null, 'ShipExpressName' => null, - 'expressCause' => null, 'shopproid' => '备用商品', - ]); - $db->table('js_ekf_rcheck_piece')->insert([ - 'Id' => 1, 'CheckType' => 1, 'ExpressNum' => 'WB-1', 'OrderNum' => 'SO-1', 'CheckReason' => 2, - 'OmsSDName' => '查件物流', 'DHLName' => '不作为快递名称', 'SkuCode' => 'CHECK-SKU', 'shopproid' => 'CHECK-ITEM', - ]); - $db->table('js_express_original')->insert([ - 'Id' => 1, 'ExpressName' => '原始物流', 'OrderNum' => 'WB-1', 'Lenght' => 11, 'Width' => 22, 'Height' => 33, - 'Volume' => 7260, 'SkuCode' => 'ORIGINAL-SKU', 'JiaoYiNum' => 'TRADE-1', 'ShopName' => '原始店铺', - 'SetMoney' => 18.5, 'SetWeight' => 1.75, 'shopproid' => 'ORIGINAL-ITEM', - ]); - $db->table('js_jst_order_express_details')->insert([ - 'id' => 1, 'updatedAt' => '2026-09-27 10:01:00', 'so_id' => 'SO-1', 'logistic_id' => 'WB-1', - 'logistic_company_name' => '正式快递', 'shop_name' => '正式店铺', 'f_weight' => 0.8, - ]); - $db->table('js_jst_send_data_view_extension')->insert([ - 'Id' => 1, 'io_id' => 100, 'so_id' => 'SO-1', 'shop_i_id' => 'ITEM-1', 'sku_ids' => 'SKU-1', - 'shop_name' => '正式店铺', 'l' => 10, 'w' => 20, 'h' => 30, 'volume' => 6000, 'weight' => 0.7, - ]); - $db->table('js_jst_sales_outbound_info')->insert([ - 'Id' => 1, 'io_id' => 100, 'f_weight' => 1.25, 'weight' => 1.2, 'f_volume' => 0.5, 'volume' => 0.4, - ]); - - $result = $this->repository->paginate([], 1, 25, collect()); - $row = $result['data']->first(); - - $this->assertSame(1, $result['total']); - $this->assertSame('查件物流', $row['courier_name']); - $this->assertSame('ORIGINAL-ITEM', $row['platform_product_id']); - $this->assertSame('ORIGINAL-SKU', $row['product_code']); - $this->assertSame('原始店铺', $row['shop_name']); - $this->assertSame('TRADE-1', $row['online_order_no']); - $this->assertSame(11.0, $row['length']); - $this->assertSame(22.0, $row['width']); - $this->assertSame(33.0, $row['height']); - $this->assertSame('一级查件', $row['inspection_type']); - $this->assertSame('二级查件原因', $row['inspection_reason']); - $this->assertSame('退回费', $row['surcharge_type']); - $this->assertSame(1.75, $row['settlement_weight']); - $this->assertSame(7260.0, $row['volume']); - - $thresholdResult = $this->repository->paginate([], 1, 25, collect([[ - 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, - 'courier' => '查件物流', 'types' => ['退回费'], - 'mode' => '按尺寸/最长边', 'standard' => '最长边 > 30 cm:8 元/单', - ]])); - $this->assertSame(8.0, $thresholdResult['data']->first()['settlement_amount']); - - $volumeResult = $this->repository->paginate([], 1, 25, collect([[ - 'status_code' => CourierSurchargeQuote::STATUS_ACTIVE, - 'courier' => '查件物流', 'types' => ['退回费'], - 'mode' => '按体积', 'standard' => '体积 ≤ 8000 cm³:50 元/单', - ]])); - $this->assertSame(50.0, $volumeResult['data']->first()['settlement_amount']); - } - - private function createMirrorTables($schema): void - { - $schema->create('js_ekf_bookkeeping_surcharge', function (Blueprint $table): void { - $table->integer('Id'); $table->dateTime('CreationTime')->nullable(); - foreach (['OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', 'State', 'Delete', 'IsAgreeReason', 'ProcessingState', 'BookkeepingName', 'PayTime', 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid'] as $column) $table->string($column)->nullable(); - foreach (['Payfee', 'Svolume', 'Skuweight'] as $column) $table->decimal($column, 20, 4)->nullable(); - foreach (['SecodaryCause', 'ReasonId'] as $column) $table->integer($column)->nullable(); - }); - $schema->create('js_ekf_bookkeeping_config', function (Blueprint $table): void { $table->integer('Id'); $table->string('Name')->nullable(); $table->integer('ParentId')->nullable(); $table->dateTime('CreateTime')->nullable(); }); - $schema->create('js_ekf_rcheck_piece', function (Blueprint $table): void { $table->integer('Id'); foreach (['CheckType', 'CheckReason'] as $column) $table->integer($column)->nullable(); foreach (['ExpressNum', 'OrderNum', 'CheckSDType', 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_ekf_rcheck_piece_config', function (Blueprint $table): void { $table->integer('Id'); $table->integer('ParentId')->nullable(); foreach (['Name', 'Code', 'Type', 'Delete', 'CreateTime'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_jst_order_express_details', function (Blueprint $table): void { $table->integer('id'); foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'shop_name', 'f_weight'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_jst_send_data_view_extension', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['so_id', 'shop_i_id', 'sku_ids', 'shop_name', 'l', 'w', 'h', 'volume', 'weight'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_jst_sales_outbound_info', function (Blueprint $table): void { $table->integer('Id'); $table->integer('io_id')->nullable(); foreach (['f_weight', 'weight', 'f_volume', 'volume'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_ekf_return_pickup_management', function (Blueprint $table): void { $table->integer('Id'); foreach (['orderId', 'returnExpressNo', 'transactionNo', 'updateTime'] as $column) $table->string($column)->nullable(); }); - $schema->create('js_express_original', function (Blueprint $table): void { $table->integer('Id'); foreach (['ExpressName', 'OrderNum', 'DeliveryTime', 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'ShopName', 'SetMoney', 'SetWeight', 'shopproid'] as $column) $table->string($column)->nullable(); }); - } - - private function markReady($db): void - { - $now = now()->toDateTimeString(); - foreach (array_keys(config('upstream_mirrors.datasets')) as $dataset) { - $db->table('business_mirror_table_states')->insert([ - 'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-27 09:00:00', - 'status' => 'succeeded', 'last_succeeded_at' => $now, - ]); - } - } } -- 2.47.2 From b1122d5c731eaaf8db64c6b685c41db69ebd22ca Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Mon, 12 Oct 2026 09:30:59 +0800 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=E9=99=84=E5=8A=A0=E8=B4=B9?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E6=95=B4=E7=90=86=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E6=96=B9=E6=A1=88=E9=95=9C=E5=83=8F=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E4=B8=8E=E6=9C=AA=E4=BD=BF=E7=94=A8=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新方案不需要 ekf_Bookkeeping / express_Original / ekf_rCheck 等旧镜像数据集,恢复 main 配置,避免生产无谓同步 - 去掉重复的 ekf_ReturnPickupManagement 旧注册与未使用的 JushuitanShopBalanceRepository 依赖 --- app/Http/Controllers/Business/Pages.php | 1 - config/business_data.php | 6 --- config/upstream_mirrors.php | 68 +------------------------ docs/上游业务镜像清单.md | 2 - 4 files changed, 1 insertion(+), 76 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 6138d35d..d03eaaa5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -242,7 +242,6 @@ class Pages extends Controller private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly WithdrawalManagementReport $withdrawalManagementReport, - private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, private readonly CourierSurchargeSettlementMirrorRepository $courierSurchargeSettlements, private readonly ShippingOrderRepository $shippingOrders, ) { diff --git a/config/business_data.php b/config/business_data.php index 5b85eb08..da05a165 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -135,12 +135,6 @@ 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.ekf_Bookkeeping' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], - 'jiansun.dbo.express_Original' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], - 'jiansun.dbo.ekf_BookkeepingConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], - 'jiansun.dbo.ekf_rCheck_Piece' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], - 'jiansun.dbo.ekf_rCheckPieceConfig' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '快递附加费结算'], - 'jiansun.dbo.ekf_ReturnPickupManagement' => ['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' => '运费对账出库运费'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index db579fb9..5cbe08e6 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -167,15 +167,6 @@ return [ 'stale_after_minutes' => (int) env('RETURN_PICKUP_MIRROR_STALE_MINUTES', 180), ], - // 快递附加费结算要求所有必需镜像都完成首次同步;页面按最旧数据集 - // 的成功时间判断新鲜度,不把尚未核对的审核文本扩大为已通过数据。 - 'courier_surcharge_settlement' => [ - 'stale_after_minutes' => (int) env('COURIER_SURCHARGE_SETTLEMENT_MIRROR_STALE_MINUTES', 60), - // 聚水潭现有审核文本使用“同意”和“同意记账”;“通过”保留给 - // 后续上游版本,不能把任意非空文本当作审核通过。 - 'approved_values' => ['通过', '同意', '同意记账'], - ], - // 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。 'work_platform_prices' => [ 'dataset_key' => 'jiansun.dbo.pm_priceAdjustment', @@ -250,60 +241,6 @@ return [ 'indexes' => [['scmname', 'Id']], ], // 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。 - // 快递附加费结算:只同步上游审核字段已核对的“通过/同意/同意记账”明细; - // 页面通过共享镜像读取,不在请求中直连聚水潭。 - 'jiansun.dbo.ekf_Bookkeeping' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_Bookkeeping', - 'target' => 'js_ekf_bookkeeping_surcharge', 'keys' => ['Id'], 'watermark' => 'CreationTime', - 'columns' => [ - 'Id', 'CreationTime', 'OrderNum', 'ExpressNum', 'ExpressName', 'ShopCode', - 'Province', 'City', 'District', 'Address', 'SupplierName', 'SkuName', 'SkuCode', 'Result', 'UserName', - 'Payfee', 'SecodaryCause', 'ReasonId', 'State', 'Delete', 'IsAgreeReason', - 'ProcessingState', 'BookkeepingName', 'PayTime', 'Svolume', 'Skuweight', - 'IfReturn', 'IfBuckle', 'FuZhuMain', 'FuZhuMinor', 'ShipExpressName', - 'expressCause', 'receivingDepartment', 'returnCost', 'shopproid', - ], - 'fixed_filters' => ['IsAgreeReason' => ['通过', '同意', '同意记账']], - 'indexes' => [['ExpressNum', 'Id'], ['OrderNum(100)', 'Id'], ['shopproid(100)', 'Id'], ['CreationTime', 'Id']], - ], - 'jiansun.dbo.express_Original' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'express_Original', - 'target' => 'js_express_original', 'keys' => ['Id'], 'watermark' => 'DeliveryTime', - 'columns' => [ - 'Id', 'ExpressName', 'OrderNum', 'DeliveryTime', 'Weight', 'TotalFreight', - 'Lenght', 'Width', 'Height', 'Volume', 'SkuCode', 'JiaoYiNum', 'SkuName', - 'ShopName', 'SetMoney', 'SetWeight', 'ProCode', 'Reason', 'shopproid', 'paytime', - ], - 'indexes' => [['OrderNum', 'Id'], ['JiaoYiNum', 'Id'], ['shopproid(100)', 'Id']], - ], - 'jiansun.dbo.ekf_BookkeepingConfig' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_BookkeepingConfig', - 'target' => 'js_ekf_bookkeeping_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', - 'columns' => ['Id', 'Name', 'ParentId', 'CreateTime'], - 'indexes' => [['ParentId', 'Id']], - ], - 'jiansun.dbo.ekf_rCheck_Piece' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheck_Piece', - 'target' => 'js_ekf_rcheck_piece', 'keys' => ['Id'], 'watermark' => 'UpdateTime', - 'columns' => [ - 'Id', 'CheckType', 'ExpressNum', 'OrderNum', 'CheckSDType', 'CheckReason', - 'State', 'Accounting', 'ReturnState', 'CreationTime', 'UpdateTime', 'Delete', - 'OmsSDName', 'DHLName', 'SkuCode', 'shopproid', - ], - 'indexes' => [['ExpressNum', 'OrderNum(100)', 'Id'], ['shopproid(100)', 'Id']], - ], - 'jiansun.dbo.ekf_rCheckPieceConfig' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_rCheckPieceConfig', - 'target' => 'js_ekf_rcheck_piece_config', 'keys' => ['Id'], 'watermark' => 'CreateTime', - 'columns' => ['Id', 'ParentId', 'Name', 'Code', 'Type', 'Delete', 'CreateTime'], - 'indexes' => [['ParentId', 'Id']], - ], - 'jiansun.dbo.ekf_ReturnPickupManagement' => [ - 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'ekf_ReturnPickupManagement', - 'target' => 'js_ekf_return_pickup_management', 'keys' => ['Id'], 'watermark' => 'updateTime', - 'columns' => ['Id', 'orderId', 'returnExpressNo', 'transactionNo', 'updateTime'], - 'indexes' => [['transactionNo', 'Id'], ['orderId', 'Id'], ['returnExpressNo', 'Id']], - ], 'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], @@ -705,10 +642,7 @@ return [ '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'], ['id', 'logistic_id'], - ['logistic_id', 'so_id(64)', 'id'], ['logistic_id', 'id'], - ], + 'indexes' => [['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_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', diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index c2d7991f..587ff703 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -17,7 +17,6 @@ | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价。线上代发价格页面另读取 `Confirmed` / `Finished` 采购明细,以 `source_o_id` 关联 `ops_jst_orders.o_id`,展示线上单号、店铺、供应商、商品编码、数量、单价并标记“采购单管理”;页面请求不直连上游。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | | 本次接入 | 工作平台“客服系统 → 退款数据 → 退货取件管理”读取 `jiansun/dbo.ekf_ReturnPickupManagement`(主表)、`dbo.ekf_ReturnPickupManagement_Item`(明细分录)与 `dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`(实退快递单号映射),分别镜像为 `js_ekf_return_pickup_management`、`js_ekf_return_pickup_management_item`、`js_ekf_return_pickup_truth_express`;主表按 `Id` + `updateTime`、明细按 `id` + `updatedTime`、映射按 `Id` + `createTime` 增量,逆向物流运费核算与退件跟踪只读共享镜像,不在页面请求中直连工作平台。2026-10-02 已完成首次全量(296,707 / 378,705 / 173,346 行)。 明细 `warehouse` 是混合编码:4 位工作平台仓码(`dbo.wp_warehouse.code` / `dbo.wp_warehouseConfig.whCode`)镜像为 `js_wp_warehouse`(992 行,`id` + `modified`)与 `js_wp_warehouse_config`(132 行,无更新时间按主键全量校准),8 位聚水潭 `wms_co_id` 继续读 `ops_jst_warehouse_info`;页面与导出按名称展示,未命中时保留原始编码。 | -| 本次接入 | 快递附加费结算读取 `jiansun.dbo.ekf_Bookkeeping` 中审核字段为固定白名单“通过/同意/同意记账”的明细,并关联 `jiansun.dbo.express_Original` 的物流公司、尺寸、商品、订单和结算字段,以及记账原因、查件配置、物流明细、发货扩展、出库重量和退件管理镜像;查件类型/查件原因只取 `ekf_rCheckPieceConfig` 的一级/二级选项,记账次要原因不作回退。页面固定限制九种附加费类型,按附加费报价维护匹配并计算。首次同步完成前只显示镜像状态,不把空结果当成无数据。当前上游严格值“通过”没有记录,“同意/同意记账”是否等价于审核通过仍需业务确认;实际样本只直接证明退回/拦截标记,其余类型依赖原因文本关键词,正式上线前需补充类型字段口径。 | | 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 | | 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;财务经营报表的小额打款页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | @@ -78,7 +77,6 @@ | 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) | | 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) | | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | -| 快递附加费结算 | `dbo.ekf_Bookkeeping`、`dbo.express_Original`、`dbo.ekf_BookkeepingConfig`、`dbo.ekf_rCheck_Piece`、`dbo.ekf_rCheckPieceConfig`、既有物流镜像 `dbo.jst_order_express_details` / `dbo.Jst_SendData_View_extension` / `dbo.jst_salesOutboundInfo`、`dbo.ekf_ReturnPickupManagement`(审核字段固定为“通过/同意/同意记账”;查件原因只读二级选项;页面只读 `caiwu_business_mirror`) | | 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | | 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) | | 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) | -- 2.47.2