From c55c06026f18507527f79995d06d5662f7ad94f2 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Mon, 12 Oct 2026 17:49:10 +0800 Subject: [PATCH 01/44] =?UTF-8?q?=E5=BF=AB=E9=80=92=E5=BA=94=E4=BB=98?= =?UTF-8?q?=EF=BC=9A=E6=8A=A5=E4=BB=B7=E7=BB=B4=E6=8A=A4=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E9=A1=B5=E7=AD=BE=20+=20=E9=99=84=E5=8A=A0?= =?UTF-8?q?=E8=B4=B9=E7=BB=93=E7=AE=97=E5=AE=9A=E6=97=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=88=E6=AF=8F=E6=97=A57=E5=A4=A9/=E6=AF=8F=E5=91=A890?= =?UTF-8?q?=E5=A4=A9=EF=BC=89=20(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 14 +++-- config/business_navigation.php | 1 - deploy/activate-incremental-release.sh | 4 ++ deploy/activate-release.sh | 4 ++ .../courier-surcharge-workspace.blade.php | 60 +------------------ .../CourierSurchargeSettlementPageTest.php | 38 +++++++----- .../Deployment/BusinessMirrorScheduleTest.php | 12 ++++ 7 files changed, 53 insertions(+), 80 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index fdded647..a765dd60 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -381,6 +381,14 @@ class Pages extends Controller abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); + // 附加费结算已独立到快递应付页面,旧页签地址跳到新页面。 + if ($section === 'courier-payables' && $page === 'courier-surcharge' && $tab === 'settlement') { + return redirect()->route('business.pages.show', array_merge(request()->query(), [ + 'section' => $section, + 'page' => 'courier-surcharge-settlement', + ])); + } + $pages = collect($sectionConfig['pages'] ?? []); if ($pages->isEmpty()) { @@ -3782,7 +3790,7 @@ class Pages extends Controller 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(); + $quoteInput = request()->query(); $filters = [ 'keyword' => $clean($quoteInput['keyword'] ?? '', 40), 'courier' => $clean($quoteInput['courier'] ?? '', 40), @@ -3810,10 +3818,6 @@ class Pages extends Controller 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; - if ($tab === 'settlement') { - $content += $this->contentForCourierSurchargeSettlement(); - } - return $content; } diff --git a/config/business_navigation.php b/config/business_navigation.php index 74252365..e45a5bf5 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -713,7 +713,6 @@ $navigation = [ 'title' => '附加费报价维护', 'tabs' => [ ['slug' => 'quotes', 'title' => '附加费报价'], - ['slug' => 'settlement', 'title' => '附加费结算'], ], ], ], diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 1f26c98c..024c6291 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -204,6 +204,10 @@ CRON_TZ=Asia/Shanghai # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1 +# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次, +# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。 +30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1 +40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index b6bd03a5..1e437ae7 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -208,6 +208,10 @@ CRON_TZ=Asia/Shanghai # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1 +# 附加费结算:每天滚动重算最近 7 天;每周一再把最近 90 天全量重算一次, +# 补齐晚到的退货进仓与审批变化(超长费按出库日期、退回费/拦截费按查件工单创建时间)。 +30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet' >/dev/null 2>&1 +40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots 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 index 41ce8674..e6d9dbc0 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-workspace.blade.php @@ -1,63 +1,5 @@ -@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/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index e867ec5c..546af5a4 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -20,10 +20,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', - 'page' => 'courier-surcharge', - 'tab' => 'settlement', + 'page' => 'courier-surcharge-settlement', ]))->assertOk() - ->assertSee('data-surcharge-panel="quotes" hidden', false) ->assertSeeText('附加费结算') ->assertSeeText('共享业务镜像尚未建立,附加费结算暂时无法读取。') ->assertSee('data-finance-table-name="courier-surcharge-settlement"', false) @@ -45,8 +43,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', - 'page' => 'courier-surcharge', - 'tab' => 'settlement', + 'page' => 'courier-surcharge-settlement', 'courier_name' => '测试快递', 'waybill' => '001234', 'shop_name' => '测试店铺', @@ -61,7 +58,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算'); } - public function test_quote_page_exposes_quote_and_settlement_tabs(): void + public function test_quote_page_only_exposes_quote_tab(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); @@ -70,18 +67,29 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase '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', [ + ->assertDontSee(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'courier-surcharge', 'tab' => 'settlement', - ]), false); + ]), false) + ->assertDontSee('data-surcharge-panel="settlement"', false) + ->assertSee('data-surcharge-panel="quotes"', false); + } + + public function test_old_settlement_tab_url_redirects_to_standalone_page(): 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', + 'waybill' => '0000123456789', + ]))->assertRedirect(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge-settlement', + 'waybill' => '0000123456789', + ])); } public function test_settlement_page_renders_as_courier_payables_page(): void diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 256cf2f1..5da1595b 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -74,6 +74,18 @@ class BusinessMirrorScheduleTest extends TestCase $contents, $script, ); + $this->assertStringContainsString( + "30 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=7 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=7 --quiet'", + $contents, + $script, + ); + $this->assertStringContainsString( + "40 6 * * 1 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-overlength --days=90 --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan courier-surcharge:pull-return --days=90 --quiet'", + $contents, + $script, + ); + $this->assertSame(2, substr_count($contents, 'courier-surcharge:pull-overlength'), $script); + $this->assertSame(2, substr_count($contents, 'courier-surcharge:pull-return'), $script); $this->assertStringContainsString( 'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet', $contents, From e61e3b50fbe77c16d53b2725b498a4d86c285fc1 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 09:36:14 +0800 Subject: [PATCH 02/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E8=A1=A5=E5=85=85=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E5=90=8C=E6=AD=A5=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=EF=BC=88cron=20=E9=81=97=E6=BC=8F=EF=BC=89=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/activate-incremental-release.sh | 2 ++ deploy/activate-release.sh | 2 ++ tests/Unit/Deployment/BusinessMirrorScheduleTest.php | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 024c6291..3ad13a1f 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -197,6 +197,8 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 +30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 1e437ae7..110b1445 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -201,6 +201,8 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 +30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 5da1595b..48c8fbaf 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -58,6 +58,13 @@ class BusinessMirrorScheduleTest extends TestCase $this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script); $this->assertStringContainsString('0 0 * * * root', $contents, $script); $this->assertStringContainsString('0 5 * * * root', $contents, $script); + $this->assertStringContainsString('30 5 * * * root', $contents, $script); + $this->assertStringContainsString( + 'store-violations:sync --quiet', + $contents, + $script, + ); + $this->assertSame(1, substr_count($contents, 'store-violations:sync --quiet'), $script); $this->assertStringContainsString( '15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet', $contents, From 63f9e2a4180c06c6ec391a9404899f1823c78128 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 09:40:53 +0800 Subject: [PATCH 03/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E5=90=8C=E6=AD=A5=20SQL=20=E5=9C=A8?= =?UTF-8?q?=E6=BA=90=E7=AB=AF=E8=BD=AC=E6=8D=A2=20date=20=E5=88=97?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20FreeTDS=20=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/SyncStoreViolations.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/SyncStoreViolations.php b/app/Console/Commands/SyncStoreViolations.php index 6884eb64..f0491065 100644 --- a/app/Console/Commands/SyncStoreViolations.php +++ b/app/Console/Commands/SyncStoreViolations.php @@ -70,9 +70,14 @@ class SyncStoreViolations extends Command try { while (true) { + // FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"), + // 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。 $sql = 'SELECT TOP ' . $batchSize - . ' id, shop_name, violation_no, violation_date, deadline, violation_content,' - . ' status, window_name, platform, crawl_date' + . ' id, shop_name, violation_no,' + . ' CONVERT(varchar(10), violation_date, 23) AS violation_date,' + . ' CONVERT(varchar(10), deadline, 23) AS deadline,' + . ' violation_content, status, window_name, platform,' + . ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date' . ' FROM rpt.crawl_shop_violation WHERE id > ?'; $params = [$lastId]; From 7ecd5a59e6eccb269be51b5c5154be82fe7e11f3 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Tue, 13 Oct 2026 09:53:02 +0800 Subject: [PATCH 04/44] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE)?= =?UTF-8?q?:=20=E5=BF=AB=E9=80=92=E5=90=8D=E7=A7=B0=E3=80=81=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E6=8C=89=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=8F=96=E5=80=BC=EF=BC=8C=E4=B8=8E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9 月核对剩余 256 条快递名称、44 条仓库不一致;抽样 14/14 工作平台显示的就是发货数据视图 (Jst_SendData_View_extension) 的 expressName / wareHoursName。视图有值时以视图为准, 视图没有该出库单时保留出库单/发货明细推断的值。 - 发货数据视图、出库单头按 300 个出库单一批查询(原 800),减少副本库超时导致整月重跑中断。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 48 +++++++++++++++++-- .../ShippingReplicaSynchronizerTest.php | 44 +++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index b93884eb..edcc74ae 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -135,6 +135,12 @@ class ShippingReplicaSynchronizer /** @var array 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */ private array $ioShop = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 视图快递名称 expressName(工作平台显示的快递名称) */ + private array $ioExpress = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 视图发货仓 wareHoursName(工作平台显示的仓库) */ + private array $ioWare = []; /** @var array 已查询订单元数据缓存 o_id=>字段 */ private array $orderMetadata = []; @@ -489,6 +495,7 @@ class ShippingReplicaSynchronizer ?? $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null); $pkg = $this->pkgFromExpress($r, $tracking, $shipTime); $pkg = $this->withOutboundCarrier($io, $r, $pkg, $pkgId); + $pkg = $this->withViewCarrier($io, $pkg, $pkgId); $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now); @@ -705,6 +712,7 @@ class ShippingReplicaSynchronizer $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $pkgId = self::AUTOSHIP_OFFSET + (int) $oid; + $pkg = $this->withViewCarrier($io, $pkg, $pkgId); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true); } unset($rows, $byOid, $bySo, $dws, $skus, $freight); @@ -900,8 +908,11 @@ class ShippingReplicaSynchronizer } $pkg = $this->pkgFromOutbound($r, $tracking !== '' ? $tracking : null, $this->ts($r['io_date'] ?? null), $h, $so); $pkg = $this->withViewAddr($io, $pkg); + $pkg = $this->withViewCarrier($io, $pkg, self::OUTBOUND_OFFSET + (int) $io); if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) { - $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; + if (empty($this->ioWare[$io])) { + $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; + } $virtualEmitted++; } $total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking !== '' ? $tracking : null, $now, true); @@ -1030,7 +1041,7 @@ class ShippingReplicaSynchronizer private function loadViewHeads(array $ioIds): array { $out = []; - foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) { + foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } @@ -1260,6 +1271,26 @@ class ShippingReplicaSynchronizer return $pkg; } + /** + * 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致): + * 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。 + */ + private function withViewCarrier(string $io, array $pkg, int $pkgId): array + { + if ($io === '') { + return $pkg; + } + if (! empty($this->ioExpress[$io])) { + $pkg['express_name1'] = $this->ioExpress[$io]; + } + if (! empty($this->ioWare[$io])) { + $pkg['warehouse_name'] = $this->ioWare[$io]; + unset($this->pendingWarehouse[$pkgId]); + } + + return $pkg; + } + private function wmsName(string $wms): ?string { if (empty($this->wmsNames[$wms])) { @@ -1723,12 +1754,15 @@ class ShippingReplicaSynchronizer $this->ioSite = []; $this->ioAddr = []; $this->ioShop = []; - foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) { + $this->ioExpress = []; + $this->ioWare = []; + // 发货数据视图是计算视图,IN 列表过长时副本库容易超时:按 300 个出库单一批 + foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); - $sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM ' + $sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, expressName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM ' . self::VIEW . " WHERE io_id IN ({$ph})"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $io = (string) ($r['io_id'] ?? ''); @@ -1738,6 +1772,12 @@ class ShippingReplicaSynchronizer if ($io !== '' && ! isset($this->ioShop[$io]) && trim((string) ($r['shop_name'] ?? '')) !== '') { $this->ioShop[$io] = trim((string) $r['shop_name']); } + if ($io !== '' && ! isset($this->ioExpress[$io]) && trim((string) ($r['expressName'] ?? '')) !== '') { + $this->ioExpress[$io] = trim((string) $r['expressName']); + } + if ($io !== '' && ! isset($this->ioWare[$io]) && trim((string) ($r['wareHoursName'] ?? '')) !== '') { + $this->ioWare[$io] = trim((string) $r['wareHoursName']); + } if ($io !== '') { foreach (['st' => 'receiver_state', 'ci' => 'receiver_city', 'di' => 'receiver_district'] as $ak => $ac) { $av = trim((string) ($r[$ac] ?? '')); diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 213392af..85da9083 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1271,6 +1271,50 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame(1, $skipped->getValue($sync)); } + public function test_view_express_and_warehouse_override_inferred_carrier_like_the_work_platform(): void + { + $chunks = []; + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$chunks): array { + $this->assertStringContainsString('expressName', $sql); + $chunks[] = count($bindings); + + return [ + ['io_id' => '12', 'sku_ids' => 'a', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '常州德邦', 'wareHoursName' => '工厂-河南千优代发仓'], + ['io_id' => '12', 'sku_ids' => 'b', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '', 'wareHoursName' => ''], + ['io_id' => '41', 'sku_ids' => 'c', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '无需物流', 'wareHoursName' => '简尚-自动发货仓'], + ]; + }); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($upstream); + $sync = new ShippingReplicaSynchronizer($factory); + $load = new \ReflectionMethod($sync, 'loadViewItems'); + $load->setAccessible(true); + $load->invoke($sync, array_merge(['12', '41', '99'], array_map('strval', range(1000, 1400)))); + $this->assertSame([300, 104], $chunks); // 视图按 300 个出库单一批 + + $pending = new \ReflectionProperty($sync, 'pendingWarehouse'); + $pending->setAccessible(true); + $pending->setValue($sync, [1 => '14138566', 2 => '15170678']); + $carrier = new \ReflectionMethod($sync, 'withViewCarrier'); + $carrier->setAccessible(true); + + // 视图有值:快递名称、仓库按视图(工作平台口径),不再按发货主体回填 + $pkg = $carrier->invoke($sync, '12', ['express_name1' => '崔桥顺丰干配', 'warehouse_name' => '工厂-河北浩浦代发仓'], 1); + $this->assertSame('常州德邦', $pkg['express_name1']); + $this->assertSame('工厂-河南千优代发仓', $pkg['warehouse_name']); + $this->assertArrayNotHasKey(1, $pending->getValue($sync)); + + $none = $carrier->invoke($sync, '41', ['express_name1' => '无需物流', 'warehouse_name' => '工厂-山东恒思代发仓'], 4); + $this->assertSame('简尚-自动发货仓', $none['warehouse_name']); + + // 视图没有该出库单:保留出库单/发货明细推断值和待回填仓库 + $keep = $carrier->invoke($sync, '99', ['express_name1' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'], 2); + $this->assertSame('外采快递', $keep['express_name1']); + $this->assertSame('常州简尚家居有限公司', $keep['warehouse_name']); + $this->assertSame('15170678', $pending->getValue($sync)[2]); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property); From f663ddc51f6d378a455e3b8a77b04c67f8b4b751 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 09:56:41 +0800 Subject: [PATCH 05/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E6=98=8E=E7=BB=86=E8=A1=8C=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=EF=BC=8C=E6=96=B9=E4=BE=BF=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E6=97=B6=E6=A0=87=E8=AE=B0=E5=BD=93=E5=89=8D=E8=A1=8C=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-surcharge-settlement.blade.php | 40 +++++++++++++++- .../CourierSurchargeSettlementPageTest.php | 47 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) 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 b2b6e67f..34db4e04 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 @@ -161,6 +161,14 @@ .settlement-table td[data-finance-fixed="end"] { background: #fffdf8; } .settlement-table td { overflow: hidden; text-overflow: ellipsis; } .settlement-table .settlement-empty td { min-width: 22rem; padding: 3rem 1rem; text-align: center; color: var(--settlement-muted); white-space: normal; } + .settlement-table tbody tr.is-clickable { cursor: pointer; } + .settlement-table tbody tr.is-clickable:not(.is-active):hover td, + .settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="true"], + .settlement-table tbody tr.is-clickable:not(.is-active):hover td[data-finance-fixed="end"] { background: #fcfaf5; } + .settlement-table tbody tr.is-active td, + .settlement-table tbody tr.is-active td[data-finance-fixed="true"], + .settlement-table tbody tr.is-active td[data-finance-fixed="end"] { background: var(--settlement-soft); border-bottom-color: #e4d7ba; } + .settlement-table tbody tr.is-active td:first-child { box-shadow: inset 3px 0 0 var(--settlement-accent); } @media (max-width: 900px) { .settlement-summary { grid-template-columns: 1fr 1fr; } @@ -233,7 +241,7 @@
-
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整。
列设置按当前用户和本页面保存
+
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整;点击行可高亮标记。
列设置按当前用户和本页面保存
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
@@ -244,7 +252,7 @@ @forelse ($rows as $row) - + @foreach ($columns as $column) @endforeach @@ -261,3 +269,31 @@
{{ $rows->withQueryString()->links() }}
@endif + + diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index 546af5a4..db6b6f00 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -111,6 +111,53 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ->assertSeeText('附加费结算'); } + public function test_settlement_rows_support_click_highlight(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(CourierSurchargeSettlementMirrorRepository::class); + $repository->shouldReceive('available')->andReturn(true); + $repository->shouldReceive('ready')->andReturn(true); + $repository->shouldReceive('paginate')->andReturn([ + 'data' => [[ + 'courier_name' => '曹县德邦', + 'shipped_at' => '2026-10-12 18:38:33', + 'waybill' => 'DPK317248316615', + 'platform_product_id' => '993815011271', + 'product_code' => 'MKHDNZ0409260601380', + 'shop_name' => '拼多多-HX旗舰店101', + 'online_order_no' => '260912-325069063762746', + 'length' => 168, + 'width' => 30, + 'height' => 20, + 'volume' => 100800, + 'inspection_type' => null, + 'inspection_reason' => null, + 'return_waybill_no' => null, + 'settlement_weight' => null, + 'settlement_amount' => 8, + 'surcharge_type' => '超长费', + ]], + 'total' => 1, + 'has_more' => false, + 'summary' => ['count' => 1, 'settlement_amount' => 8.0], + 'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'], + 'state' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'], + ]); + $this->app->instance(CourierSurchargeSettlementMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'courier-surcharge-settlement', + ]))->assertOk() + ->assertSee('data-settlement-row', false) + ->assertSee('is-clickable', false) + ->assertSee('is-active', false) + ->assertSeeText('点击行可高亮标记') + ->assertSeeText('曹县德邦') + ->assertSeeText('超长费'); + } + public function test_settlement_export_streams_filtered_rows_as_xlsx(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); From 03fea599fbc9b5297b9b9591a6b4dc469e930a72 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 10:29:37 +0800 Subject: [PATCH 06/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E6=94=B6=E7=B4=A7=E9=A1=B6=E9=83=A8=E6=A6=82?= =?UTF-8?q?=E8=A7=88/=E7=AD=9B=E9=80=89/=E5=AF=BC=E5=85=A5=E5=8C=BA=20(#31?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-surcharge-settlement.blade.php | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) 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 34db4e04..ece05030 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 @@ -60,7 +60,7 @@ --settlement-soft: var(--vip-content-accent-soft, #f7f0de); --settlement-border: var(--vip-border, #d8d2c7); display: grid; - gap: 1rem; + gap: .65rem; min-width: 0; color: var(--settlement-ink); font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace; @@ -97,36 +97,36 @@ } .settlement-summary__intro, - .settlement-summary__metric { min-width: 0; padding: 1rem 1.15rem; } - .settlement-summary__intro { display: flex; align-items: center; gap: .75rem; } - .settlement-summary__icon { display: grid; flex: none; width: 2.4rem; height: 2.4rem; place-items: center; border-radius: .6rem; background: var(--settlement-soft); color: var(--settlement-accent); } - .settlement-summary__title { font-size: .95rem; font-weight: 700; } - .settlement-summary__subtitle { margin-top: .2rem; color: var(--settlement-muted); font-size: .72rem; } + .settlement-summary__metric { min-width: 0; padding: .55rem .9rem; } + .settlement-summary__intro { display: flex; align-items: center; gap: .6rem; } + .settlement-summary__icon { display: grid; flex: none; width: 1.9rem; height: 1.9rem; place-items: center; border-radius: .5rem; background: var(--settlement-soft); color: var(--settlement-accent); } + .settlement-summary__title { font-size: .875rem; font-weight: 700; } + .settlement-summary__subtitle { margin-top: .1rem; color: var(--settlement-muted); font-size: .68rem; } .settlement-summary__metric { border-left: 1px solid var(--settlement-border); } - .settlement-summary__label { color: var(--settlement-muted); font-size: .72rem; } - .settlement-summary__value { margin-top: .25rem; font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; } + .settlement-summary__label { color: var(--settlement-muted); font-size: .68rem; } + .settlement-summary__value { margin-top: .15rem; font-size: .95rem; font-weight: 700; font-variant-numeric: tabular-nums; } .settlement-filter-bar { display: grid; grid-template-columns: repeat(4, minmax(10rem, 1fr)); - gap: .7rem; + gap: .45rem .6rem; align-items: end; - padding: 1rem 1.15rem; + padding: .55rem .9rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fffdf8; } - .settlement-field { display: grid; gap: .3rem; min-width: 0; } - .settlement-field label { color: var(--settlement-muted); font-size: .72rem; font-weight: 600; } + .settlement-field { display: grid; gap: .2rem; min-width: 0; } + .settlement-field label { color: var(--settlement-muted); font-size: .68rem; font-weight: 600; } .settlement-field input, .settlement-field select { width: 100%; - height: 2.2rem; + height: 1.9rem; min-width: 0; - padding: 0 .6rem; + padding: 0 .5rem; border: 1px solid var(--settlement-border); - border-radius: .45rem; + border-radius: .4rem; background: #fff; color: var(--settlement-ink); font: inherit; @@ -134,14 +134,15 @@ .settlement-field input:focus, .settlement-field select:focus { border-color: var(--settlement-accent); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); } - .settlement-actions { display: flex; flex-wrap: wrap; gap: .45rem; align-items: end; } - .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-actions { display: flex; flex-wrap: wrap; gap: .4rem; align-items: end; } + .settlement-filter-bar .settlement-actions { grid-column: 1 / -1; } + .settlement-button { display: inline-flex; align-items: center; justify-content: center; gap: .3rem; min-height: 1.9rem; padding: 0 .65rem; border: 1px solid var(--settlement-border); border-radius: .4rem; 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-button .material-icons-outlined { font-size: .95rem; } + .settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .45rem .75rem; margin-top: .45rem; padding: .5rem .9rem; 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-import-bar input[type="file"] { width: 100%; height: 1.9rem; padding: .25rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .4rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; } + .settlement-import-bar .settlement-table-hint { align-self: center; flex-basis: 100%; } .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); } @@ -236,8 +237,8 @@
下载导入模板 - 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。
+ 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。
From bbf8c1f16c84de7e0680bace2d5eed9c60c691b5 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 10:41:33 +0800 Subject: [PATCH 07/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E5=88=86=E9=A1=B5=E6=94=B9=E4=B8=BA=E9=80=80?= =?UTF-8?q?=E8=B4=A7=E5=8F=96=E4=BB=B6=E7=AE=A1=E7=90=86=E5=90=8C=E6=AC=BE?= =?UTF-8?q?=E7=B4=A7=E5=87=91=E6=A0=B7=E5=BC=8F=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-surcharge-settlement.blade.php | 20 +++++++++++++++++-- .../CourierSurchargeSettlementPageTest.php | 7 +++++-- 2 files changed, 23 insertions(+), 4 deletions(-) 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 ece05030..12667448 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 @@ -170,6 +170,12 @@ .settlement-table tbody tr.is-active td[data-finance-fixed="true"], .settlement-table tbody tr.is-active td[data-finance-fixed="end"] { background: var(--settlement-soft); border-bottom-color: #e4d7ba; } .settlement-table tbody tr.is-active td:first-child { box-shadow: inset 3px 0 0 var(--settlement-accent); } + .settlement-footer { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .35rem .15rem 0; color: var(--settlement-muted); font-size: .72rem; } + .settlement-pagination { display: flex; align-items: center; gap: .35rem; } + .settlement-page-link { display: inline-flex; align-items: center; justify-content: center; min-width: 1.7rem; height: 1.6rem; border: 1px solid var(--settlement-border); border-radius: .4rem; background: #fff; color: var(--settlement-ink); text-decoration: none; } + .settlement-page-link:hover { border-color: var(--settlement-accent); background: var(--settlement-soft); } + .settlement-page-link.is-disabled { opacity: .4; pointer-events: none; } + .settlement-page-link .material-icons-outlined { font-size: 1rem; } @media (max-width: 900px) { .settlement-summary { grid-template-columns: 1fr 1fr; } @@ -266,8 +272,18 @@
- @if (method_exists($rows, 'links') && $rows->hasPages()) -
{{ $rows->withQueryString()->links() }}
+ @if (method_exists($rows, 'hasPages') && $rows->hasPages()) + @endif diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index db6b6f00..770f222c 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -138,9 +138,9 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase 'settlement_amount' => 8, 'surcharge_type' => '超长费', ]], - 'total' => 1, + 'total' => 30, 'has_more' => false, - 'summary' => ['count' => 1, 'settlement_amount' => 8.0], + 'summary' => ['count' => 30, 'settlement_amount' => 8.0], 'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'], 'state' => ['code' => 'fresh', 'last_succeeded_at' => '2026-10-13 06:31:34'], ]); @@ -153,6 +153,9 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ->assertSee('data-settlement-row', false) ->assertSee('is-clickable', false) ->assertSee('is-active', false) + ->assertSee('settlement-page-link', false) + ->assertDontSee('page-item', false) + ->assertSeeText('第 1 / 2 页,本页 1 条,共 30 条') ->assertSeeText('点击行可高亮标记') ->assertSeeText('曹县德邦') ->assertSeeText('超长费'); From 8b62ba05042d51e0ed30bb010ec0cf5d5b4320f3 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 10:59:29 +0800 Subject: [PATCH 08/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E7=AD=9B=E9=80=89=E5=AD=97=E6=AE=B5=E5=8F=AF?= =?UTF-8?q?=E8=87=AA=E9=80=89=E6=98=BE=E7=A4=BA=EF=BC=88=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=89=20(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-surcharge-settlement.blade.php | 96 ++++++++++++++++--- .../CourierSurchargeSettlementPageTest.php | 4 + 2 files changed, 87 insertions(+), 13 deletions(-) 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 12667448..5fef4a64 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 @@ -118,6 +118,7 @@ } .settlement-field { display: grid; gap: .2rem; min-width: 0; } + .settlement-field[hidden] { display: none; } .settlement-field label { color: var(--settlement-muted); font-size: .68rem; font-weight: 600; } .settlement-field input, .settlement-field select { @@ -219,19 +220,19 @@ -
-
-
-
-
-
-
-
-
-
-
-
- +
+
+
+
+
+
+
+
+
+
+
+
+
重置导出
@@ -312,5 +313,74 @@ activate(row); } }); + +})(); + +(() => { + const filterBar = document.querySelector('.settlement-filter-bar'); + const filterPicker = document.querySelector('[data-settlement-filter-picker]'); + + if (filterBar && filterPicker) { + const toggle = filterPicker.querySelector('button[aria-expanded]'); + const panel = filterPicker.querySelector('.finance-table-field-picker__panel'); + const checks = [...panel.querySelectorAll('input[data-settlement-filter-check]')]; + const fields = [...filterBar.querySelectorAll('[data-settlement-filter-field]')]; + const storageKey = 'caiwu:courier-surcharge-settlement:filter-fields:' + (filterPicker.dataset.user || 'anonymous'); + const defaultKeys = checks.map((check) => check.value); + + const apply = (selected, save) => { + fields.forEach((field) => { + const visible = selected.has(field.dataset.settlementFilterField); + field.hidden = !visible; + field.querySelectorAll('input, select').forEach((control) => { + if (visible) { + control.removeAttribute('disabled'); + } else { + control.setAttribute('disabled', 'disabled'); + } + }); + }); + checks.forEach((check) => { check.checked = selected.has(check.value); }); + if (save) { + try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch (_) {} + } + }; + + let stored = null; + try { + const parsed = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); + if (Array.isArray(parsed)) { + stored = new Set(parsed.filter((key) => defaultKeys.includes(key))); + } + } catch (_) {} + + apply(stored ?? new Set(defaultKeys), false); + + toggle.addEventListener('click', () => { + const open = panel.hidden; + panel.hidden = !open; + toggle.setAttribute('aria-expanded', String(open)); + }); + + document.addEventListener('click', (event) => { + if (!panel.hidden && !filterPicker.contains(event.target)) { + panel.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + }); + + checks.forEach((check) => check.addEventListener('change', () => { + apply(new Set(checks.filter((item) => item.checked).map((item) => item.value)), true); + })); + + panel.querySelector('[data-settlement-filters-all]')?.addEventListener('click', () => { + apply(new Set(defaultKeys), true); + }); + + panel.querySelector('[data-settlement-filters-reset]')?.addEventListener('click', () => { + try { localStorage.removeItem(storageKey); } catch (_) {} + apply(new Set(defaultKeys), true); + }); + } })(); diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index 770f222c..dff9bbc6 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -55,6 +55,10 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase ->assertSee('value="测试店铺"', false) ->assertSee('value="SO-001"', false) ->assertSee('value="已计算" selected', false) + ->assertSee('data-settlement-filter-picker', false) + ->assertSee('data-settlement-filter-check', false) + ->assertSee('data-settlement-filter-field="waybill"', false) + ->assertSeeText('筛选设置') ->assertViewHas('content', fn (array $content) => $content['courierSurchargeSettlementFilters']['status'] === '已计算'); } From 53b082138476faa5f5376a0e6c6218a9c9d126b4 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Tue, 13 Oct 2026 10:59:30 +0800 Subject: [PATCH 09/44] =?UTF-8?q?=E5=BF=AB=E9=80=92=E5=BA=94=E4=BB=98?= =?UTF-8?q?=EF=BC=9A=E4=BB=93=E5=82=A8=E8=BF=90=E8=B4=B9=E6=9B=B4=E5=90=8D?= =?UTF-8?q?=E4=B8=BA=E4=BB=93=E5=82=A8=E8=B4=B9=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/business_navigation.php | 2 +- .../pages/courier-payables/partials/warehouse-freight.blade.php | 2 +- tests/Feature/Business/WarehouseFreightPageTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/business_navigation.php b/config/business_navigation.php index e45a5bf5..03661324 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -721,7 +721,7 @@ $navigation = [ ['slug' => 'shipping-data', 'title' => '发货数据'], ['slug' => 'return-pickup-management', 'title' => '退货取件管理'], ['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'], - ['slug' => 'warehouse-freight', 'title' => '仓储运费'], + ['slug' => 'warehouse-freight', 'title' => '仓储费'], ['slug' => 'bookkeeping-details', 'title' => '记账明细'], ['slug' => 'compensation-details', 'title' => '赔偿明细'], ['slug' => 'advance-details', 'title' => '借支明细'], diff --git a/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php b/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php index 4f69a773..8251716d 100644 --- a/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php +++ b/resources/views/business/pages/courier-payables/partials/warehouse-freight.blade.php @@ -15,7 +15,7 @@
-

仓储运费

+

仓储费

选择结算月份,一键按当期生效报价核算并存档,可查看和导出每日商品仓储费
diff --git a/tests/Feature/Business/WarehouseFreightPageTest.php b/tests/Feature/Business/WarehouseFreightPageTest.php index a0ec7790..23a44483 100644 --- a/tests/Feature/Business/WarehouseFreightPageTest.php +++ b/tests/Feature/Business/WarehouseFreightPageTest.php @@ -28,7 +28,7 @@ class WarehouseFreightPageTest extends FeatureTestCase $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'warehouse-freight'])) ->assertOk() - ->assertSeeText('仓储运费') + ->assertSeeText('仓储费') ->assertSeeText('2026-08') ->assertSeeText('查看明细') ->assertSeeText('导出核算') From d1ffaf773c2dfdf0a9f40e538798c66193eb1964 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 11:07:46 +0800 Subject: [PATCH 10/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E5=BE=97=E7=89=A9=E8=B4=9F=E5=B7=AE=E9=A2=9D=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E=E4=BB=98=E6=9C=AA?= =?UTF-8?q?=E5=87=8F=E8=81=94=E5=90=88=E8=90=A5=E9=94=80=E8=B4=B9=E3=80=8D?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=EF=BC=9B=E6=8A=96=E9=9F=B3=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E=E4=BB=98?= =?UTF-8?q?=E5=90=AB=E5=B7=B2=E9=80=80=E6=AC=BE=E9=83=A8=E5=88=86=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 得物:负差额 = 联合营销费未让利尾数(该单营销费支出 + 差额恰为整数)时标注;规则排在通用「未减冲减/优惠」之前,避免被笼统备注抢先;证据子查询仅在得物负差额行计算。抖音:备注名统一加「订单」前缀。导航说明同步更新。 --- app/Http/Controllers/Business/Pages.php | 60 +++++++++++++++++++++---- config/business_navigation.php | 2 +- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a765dd60..d878f2a5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -154,7 +154,7 @@ class Pages extends Controller } /** - * 「实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。 + * 「订单实付含已退款部分」的聚水潭证据 SQL(仅抖音负差额行计算,CASE 守卫保证其它行不触发子查询)。 * * 退款单:经订单 o_id 关联(so_id 列没有索引,订单侧走 so_id 索引、退款单侧走 o_id 索引), * 只统计 SUCCESS 且非 Split 父单的叶子退款;分别取合计与单笔最大值。 @@ -182,6 +182,28 @@ class Pages extends Controller ]; } + /** + * 得物「联合营销费未让利」证据 SQL(仅得物负差额行计算,CASE 守卫保证其它行不触发子查询)。 + * + * 得物联合营销费 = 让利买家部分(整数)+ 商家自担尾数;订单实付只扣了让利部分, + * 未让利尾数 = 营销费支出 − 让利,即「营销费支出 + 差额」恰为整数; + * 对不上(差值非整)时不标注,避免误标。 + * + * @return array{guard:string, marketing_expense:string} + */ + private static function receiptDewuMarketingEvidenceSql(): array + { + $guard = "platform = '得物' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) < -0.005"; + + return [ + 'guard' => $guard, + 'marketing_expense' => "(SELECT COALESCE(SUM(b.expense_amount), 0) + FROM dewu_settlement_bill_rows b + WHERE b.online_order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '联合营销费')", + ]; + } + /** * 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。 * 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0; @@ -196,19 +218,26 @@ class Pages extends Controller $label = $basis['label']; $rules = []; + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; $rules[$label . '未匹配'] = "match_status = '未匹配'"; $rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005"; $rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005"; + // 得物「订单实付未减联合营销费」:负差额 = 联合营销费未让利尾数(营销费支出 + 差额恰为整数)才标注。 + // 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。 + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $dewuNeat = '(' . $dewuEvidence['marketing_expense'] . ' + ' . $effective . ')'; + $rules['订单实付未减联合营销费'] = 'CASE WHEN (' . $dewuEvidence['guard'] + . ' AND (' . $dewuEvidence['marketing_expense'] . ') > 0.005' + . ' AND ABS(' . $dewuNeat . ' - ROUND(' . $dewuNeat . ', 0)) < 0.005) THEN 1 ELSE 0 END = 1'; $rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005"; $rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005"; $rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005"; - $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; - // 抖音「实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上(≈差额)才标注。 + // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上(≈差额)才标注。 $refundEvidence = self::receiptRefundEvidenceSql(); $differenceAbs = 'ABS(' . $effective . ')'; $refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END'; - $rules['实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' + $rules['订单实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' . $refundHit($refundEvidence['refund_sum']) . ' OR ' . $refundHit($refundEvidence['refund_max']) . ' OR ' . $refundHit($refundEvidence['cancelled_sum']) @@ -252,6 +281,17 @@ class Pages extends Controller if (abs($product) < 0.005) { return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0'; } + // 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。 + // 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。 + if (($row->platform ?? '') === '得物' && $difference < -0.005) { + $marketingExpense = (float) ($row->order_dewu_marketing_expense ?? 0); + if ($marketingExpense > 0.005) { + $neat = $marketingExpense + $difference; + if (abs($neat - round($neat)) < 0.005) { + return '订单实付未减联合营销费'; + } + } + } if (abs($product - ($income + $discount)) < 0.005) { return $label . '未减冲减'; } @@ -262,12 +302,12 @@ class Pages extends Controller return $label . '未减优惠和冲减'; } - // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上(≈差额)→「实付含已退款部分」。 + // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上(≈差额)→「订单实付含已退款部分」。 if (($row->platform ?? '') === '抖音' && $difference < 0) { $refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null]; foreach ($refunds as $refund) { if ($refund !== null && (float) $refund > 0.005 && abs((float) $refund - abs($difference)) < 0.02) { - return '实付含已退款部分'; + return '订单实付含已退款部分'; } } } @@ -927,11 +967,13 @@ class Pages extends Controller $itemsQuery = $base(); $applyFilters($itemsQuery); $refundEvidence = self::receiptRefundEvidenceSql(); + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); $items = $itemsQuery ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') + ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense') ->orderBy('occurred_date') ->orderBy('store_name') ->orderBy('online_order_no') @@ -1303,13 +1345,15 @@ class Pages extends Controller $this->applyReceiptRemarkFilter($query, $remarkValues); } - // 「实付含已退款部分」判定所需证据列(与页面同一口径;仅抖音负差额行会真正计算子查询)。 + // 「订单实付含已退款部分」「订单实付未减联合营销费」判定所需证据列(与页面同一口径;仅对应平台负差额行会真正计算子查询)。 $refundEvidence = self::receiptRefundEvidenceSql(); + $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); $query ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') - ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum'); + ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') + ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense'); $selected = array_values(array_intersect( array_filter(explode(',', (string) $request->query('columns', ''))), diff --git a/config/business_navigation.php b/config/business_navigation.php index 03661324..33125b63 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From f8a83cf684ee14c970b95b9651679cb115f2d94d Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 13 Oct 2026 11:15:21 +0800 Subject: [PATCH 11/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E4=BA=AC=E5=96=9C=E6=AD=A3=E5=B7=AE=E9=A2=9D=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E5=B7=AE=E9=A2=9D=3D=E7=9B=B4=E8=90=A5?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E8=B4=B9=EF=BC=88=E5=B7=B2=E8=AE=A1=E9=94=80?= =?UTF-8?q?=E5=94=AE=E8=B4=B9=E7=94=A8=EF=BC=89=E3=80=8D=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 9 +++++++++ config/business_navigation.php | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index d878f2a5..d3a84d7c 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -243,6 +243,10 @@ class Pages extends Controller . ' OR ' . $refundHit($refundEvidence['cancelled_sum']) . ') ELSE 0 END = 1'; + // 京喜正差额:差额=直营服务费(京喜账单把直营服务费并入「货款(收)」再以费用行扣回, + // 该服务费已在科目映射表按「销售费用—平台服务费」入账);正差额行逐单 97.8% 与直营服务费精确相等。 + $rules['差额=直营服务费(已计销售费用)'] = "match_status = '已匹配' AND platform = '京喜账单' AND " . $effective . ' > 0.005'; + $conditions = []; $seen = []; foreach ($rules as $label => $condition) { @@ -312,6 +316,11 @@ class Pages extends Controller } } + // 京喜正差额:差额=直营服务费(账单把服务费并入货款(收)再扣回,已按销售费用—平台服务费入账)。 + if (($row->platform ?? '') === '京喜账单' && ($row->match_status ?? '') === '已匹配' && $difference > 0.005) { + return '差额=直营服务费(已计销售费用)'; + } + return null; } diff --git a/config/business_navigation.php b/config/business_navigation.php index 33125b63..2e447c2a 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From 7e1ded5112873800c47193897d25028f6038daf8 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 11:14:07 +0800 Subject: [PATCH 12/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E4=BF=AE=E5=A4=8D=E7=AD=9B=E9=80=89=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=9D=A2=E6=9D=BF=E5=B7=A6=E7=BC=98=E6=BA=A2=E5=87=BA?= =?UTF-8?q?=E8=A2=AB=E8=A3=81=E5=88=87=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-payables/courier-surcharge-settlement.blade.php | 1 + 1 file changed, 1 insertion(+) 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 5fef4a64..fc8c1304 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 @@ -177,6 +177,7 @@ .settlement-page-link:hover { border-color: var(--settlement-accent); background: var(--settlement-soft); } .settlement-page-link.is-disabled { opacity: .4; pointer-events: none; } .settlement-page-link .material-icons-outlined { font-size: 1rem; } + .settlement-filter-picker .finance-table-field-picker__panel { right: auto; left: 0; max-width: min(28rem, calc(100vw - 1rem)); } @media (max-width: 900px) { .settlement-summary { grid-template-columns: 1fr 1fr; } From ac40c6d8a90c954d6bffb0c29eaa25a103790ea8 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 13 Oct 2026 11:31:23 +0800 Subject: [PATCH 13/44] =?UTF-8?q?=E9=95=9C=E5=83=8F=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=EF=BC=9Adewu=5Fsettlement=5Fbill=5Frows=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20(online=5Forder=5Fno,=20bill=5Fitem)=20=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=EF=BC=88=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86=E5=BE=97=E7=89=A9?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E8=81=94=E5=8A=A8=E8=AE=A1=E6=95=B0=2069s=20?= =?UTF-8?q?=E6=85=A2=E6=9F=A5=E8=AF=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...d_online_order_index_to_dewu_bill_rows.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php diff --git a/database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php b/database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php new file mode 100644 index 00000000..95ee114f --- /dev/null +++ b/database/migrations/2026_10_13_120000_add_online_order_index_to_dewu_bill_rows.php @@ -0,0 +1,54 @@ +connection()); + if (! $schema->hasTable('dewu_settlement_bill_rows') || $this->hasIndex()) { + return; + } + + $schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void { + $table->index(['online_order_no', 'bill_item'], self::INDEX); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('dewu_settlement_bill_rows') || ! $this->hasIndex()) { + return; + } + + $schema->table('dewu_settlement_bill_rows', function (Blueprint $table): void { + $table->dropIndex(self::INDEX); + }); + } + + private function hasIndex(): bool + { + return (bool) DB::connection($this->connection())->selectOne( + "SELECT COUNT(*) AS c FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?", + ['dewu_settlement_bill_rows', self::INDEX], + )->c; + } +}; From d47a72c17575940c5cace1980661248a876f1f8d Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 11:32:27 +0800 Subject: [PATCH 14/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E6=96=B0=E5=A2=9E=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E4=B8=8E=E5=A4=84=E7=BD=AE=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E4=B8=A4=E5=88=97=EF=BC=88=E9=A1=B5=E9=9D=A2/=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=A1=A8/=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=EF=BC=89=20(#323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Business/StoreViolationMapper.php | 3 + .../StoreViolationWorkbookImporter.php | 18 +++++- app/Console/Commands/SyncStoreViolations.php | 62 +++++++++++++++++-- app/Http/Controllers/Business/Pages.php | 10 +-- config/store_violations.php | 6 +- ...disposition_to_store_violation_records.php | 59 ++++++++++++++++++ .../financial-risk-control/index.blade.php | 6 +- .../Business/StoreViolationAlertPageTest.php | 59 ++++++++++++++++-- .../Business/StoreViolationMapperTest.php | 27 ++++++++ .../StoreViolationsSyncExtraColumnsTest.php | 33 ++++++++++ 10 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php create mode 100644 tests/Unit/Console/StoreViolationsSyncExtraColumnsTest.php diff --git a/app/Business/StoreViolationMapper.php b/app/Business/StoreViolationMapper.php index bbd41379..8f56da6e 100644 --- a/app/Business/StoreViolationMapper.php +++ b/app/Business/StoreViolationMapper.php @@ -72,6 +72,9 @@ class StoreViolationMapper 'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191), 'violation_type' => $content === '' ? null : mb_substr($content, 0, 500), 'violation_category' => $this->categoryForContent($content), + // 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。 + 'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500), + 'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500), 'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100), 'violation_date' => $this->normalizeDate($row['violation_date'] ?? null), 'status' => $this->normalizeLabel($row['status'] ?? null, 64), diff --git a/app/Business/StoreViolationWorkbookImporter.php b/app/Business/StoreViolationWorkbookImporter.php index 43750e12..ebee242a 100644 --- a/app/Business/StoreViolationWorkbookImporter.php +++ b/app/Business/StoreViolationWorkbookImporter.php @@ -305,13 +305,25 @@ class StoreViolationWorkbookImporter $data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500); } + $reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null; + if ($has('violation_reason')) { + $data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500); + } + + if ($has('disposition')) { + $disposition = trim((string) ($raw['disposition'] ?? '')); + $data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500); + } + if ($has('violation_category')) { + $categorySource = ($type !== null && $type !== '') ? $type : (string) $reason; $category = trim((string) ($raw['violation_category'] ?? '')); $data['violation_category'] = $category === '' - ? $this->mapper->categoryForContent($type) + ? $this->mapper->categoryForContent($categorySource) : mb_substr($category, 0, 64); - } elseif ($has('violation_type')) { - $data['violation_category'] = $this->mapper->categoryForContent($type); + } elseif ($has('violation_type') || $has('violation_reason')) { + $categorySource = ($type !== null && $type !== '') ? $type : (string) $reason; + $data['violation_category'] = $this->mapper->categoryForContent($categorySource); } if ($has('violation_date')) { diff --git a/app/Console/Commands/SyncStoreViolations.php b/app/Console/Commands/SyncStoreViolations.php index f0491065..9dfa48f4 100644 --- a/app/Console/Commands/SyncStoreViolations.php +++ b/app/Console/Commands/SyncStoreViolations.php @@ -68,16 +68,27 @@ class SyncStoreViolations extends Command $skipped = 0; $read = 0; + // 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列, + // 避免每日同步把导入补充的数据清空。 + $extraColumns = $this->availableExtraColumns($pdo); + $updateColumns = $extraColumns === [] + ? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS + : array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns)); + try { while (true) { // FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"), // 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。 - $sql = 'SELECT TOP ' . $batchSize - . ' id, shop_name, violation_no,' + $selectedColumns = ' id, shop_name, violation_no,' . ' CONVERT(varchar(10), violation_date, 23) AS violation_date,' . ' CONVERT(varchar(10), deadline, 23) AS deadline,' . ' violation_content, status, window_name, platform,' - . ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date' + . ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date'; + foreach ($extraColumns as $target => $sourceColumn) { + $selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target; + } + + $sql = 'SELECT TOP ' . $batchSize . $selectedColumns . ' FROM rpt.crawl_shop_violation WHERE id > ?'; $params = [$lastId]; @@ -114,7 +125,7 @@ class SyncStoreViolations extends Command ]; } - $upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS); + $upserted += $repository->upsertRows($payload, $updateColumns); } } catch (Throwable $exception) { report($exception); @@ -137,4 +148,47 @@ class SyncStoreViolations extends Command return self::SUCCESS; } + + /** @return array 统一表字段 => 上游视图列名 */ + private function availableExtraColumns(PDO $pdo): array + { + try { + $statement = $pdo->query( + "SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')" + ); + $names = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (Throwable) { + return []; + } + + return self::pickExtraColumns(array_map('strval', $names)); + } + + /** + * 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。 + * + * @param list $availableColumns + * @return array + */ + public static function pickExtraColumns(array $availableColumns): array + { + $candidates = [ + 'violation_reason' => ['violation_reason', 'reason'], + 'disposition' => ['disposition', 'handling_method', 'disposal'], + ]; + + $available = array_map(static fn (string $name): string => strtolower($name), $availableColumns); + $selected = []; + foreach ($candidates as $target => $names) { + foreach ($names as $name) { + if (in_array($name, $available, true)) { + $selected[$target] = $name; + break; + } + } + } + + return $selected; + } } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index d3a84d7c..3f7e7a9a 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3231,7 +3231,7 @@ class Pages extends Controller $rowNumber++; } - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3257,7 +3257,7 @@ class Pages extends Controller $headers = array_map(static fn (array $column): string => (string) $column['label'], $columns); $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray( - ['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'], + ['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'], null, 'A2', ); @@ -3268,7 +3268,7 @@ class Pages extends Controller 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(24); - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3281,6 +3281,8 @@ class Pages extends Controller ['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'], ['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'], ['违规分类', '选填。留空时按违规类型关键词自动归类。'], + ['违规原因', '选填。平台展示的违规原因/详细说明。'], + ['处置方式', '选填。平台对违规的处置/处理方式。'], ['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'], ['扣分', '选填。数字,例如 2 或 0.5。'], ['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'], @@ -3294,7 +3296,7 @@ class Pages extends Controller ]); $notes->getColumnDimension('A')->setWidth(34); $notes->getColumnDimension('B')->setWidth(72); - $notes->getStyle('B1:B11')->getAlignment()->setWrapText(true); + $notes->getStyle('B1:B13')->getAlignment()->setWrapText(true); (new Xlsx($spreadsheet))->save('php://output'); }, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [ diff --git a/config/store_violations.php b/config/store_violations.php index 1618e164..b4e647e7 100644 --- a/config/store_violations.php +++ b/config/store_violations.php @@ -16,6 +16,8 @@ return [ ['key' => 'shop_name', 'label' => '店铺名'], ['key' => 'violation_type', 'label' => '违规类型'], ['key' => 'violation_category', 'label' => '违规分类'], + ['key' => 'violation_reason', 'label' => '违规原因'], + ['key' => 'disposition', 'label' => '处置方式'], ['key' => 'violation_no', 'label' => '违规单号'], ['key' => 'violation_date', 'label' => '违规日期'], ['key' => 'points_deducted', 'label' => '扣分'], @@ -42,8 +44,10 @@ return [ // 导入表头别名 → 统一列键(不区分大小写、忽略空格) 'header_aliases' => [ 'shop_name' => ['店铺名', '店铺名称', '店铺', '商家名称', '商家', 'shop_name', 'shopname'], - 'violation_type' => ['违规类型', '违规内容', '违规行为', '违规原因', '违规描述', 'violation_type'], + 'violation_type' => ['违规类型', '违规内容', '违规行为', '违规描述', 'violation_type'], 'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'], + 'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'], + 'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'], 'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'], 'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'], 'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'], diff --git a/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php b/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php new file mode 100644 index 00000000..05ed9fc9 --- /dev/null +++ b/database/migrations/2026_10_13_130000_add_reason_and_disposition_to_store_violation_records.php @@ -0,0 +1,59 @@ +connection()); + + if (! $schema->hasTable('store_violation_records')) { + return; + } + + $missing = []; + foreach (['violation_reason', 'disposition'] as $column) { + if (! $schema->hasColumn('store_violation_records', $column)) { + $missing[] = $column; + } + } + + if ($missing === []) { + return; + } + + $schema->table('store_violation_records', function (Blueprint $table) use ($missing): void { + if (in_array('violation_reason', $missing, true)) { + $table->string('violation_reason', 500)->nullable()->after('violation_category'); + } + if (in_array('disposition', $missing, true)) { + $table->string('disposition', 500)->nullable()->after('violation_reason'); + } + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('store_violation_records')) { + return; + } + + foreach (['disposition', 'violation_reason'] as $column) { + if ($schema->hasColumn('store_violation_records', $column)) { + $schema->table('store_violation_records', function (Blueprint $table) use ($column): void { + $table->dropColumn($column); + }); + } + } + } +}; diff --git a/resources/views/business/pages/financial-risk-control/index.blade.php b/resources/views/business/pages/financial-risk-control/index.blade.php index e09776a2..ea7b6dce 100644 --- a/resources/views/business/pages/financial-risk-control/index.blade.php +++ b/resources/views/business/pages/financial-risk-control/index.blade.php @@ -543,6 +543,8 @@
+ + @@ -559,6 +561,8 @@ + + @@ -573,7 +577,7 @@ @empty - + @@ -149,3 +152,31 @@ + + diff --git a/tests/Feature/Business/DingPayableWorkflowPageTest.php b/tests/Feature/Business/DingPayableWorkflowPageTest.php index 87c950d4..4e14b268 100644 --- a/tests/Feature/Business/DingPayableWorkflowPageTest.php +++ b/tests/Feature/Business/DingPayableWorkflowPageTest.php @@ -66,6 +66,9 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('process_instance_id=PROC-TEST-001', false) ->assertSee('file_id=123456', false) ->assertSee('ding-payable-attachment"', false) + ->assertSee('data-ding-payable-row', false) + ->assertSee('is-clickable', false) + ->assertSee('is-active', false) ->assertSee('镜像状态:succeeded'); } From a884da7057100ee2beb055e2749607db9ff6590f Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 14:57:20 +0800 Subject: [PATCH 25/44] =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=90=9C=E7=B4=A2=20+=20=E5=B7=B2=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=A0=87=E7=AD=BE=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1)侧边栏菜单上方新增搜索框:按关键字过滤菜单项(如输「快递应付」直接定位), 无匹配时给出提示,Esc 清空。 2)页面顶部新增「已打开页面」标签栏:点开过的业务页面并排固定在页面上方, 点标签直接切回(回到最近一次带筛选的地址);同一张页面按路径去重; 标签按账号存在浏览器本地(localStorage),没退出登录就一直保留; 支持单个关闭和「清空(保留当前页)」,最多并排 15 个,超出横向滚动。 钉钉单页入口模式下不显示这个标签栏。 --- .../pages/partials/open-page-tabs.blade.php | 177 ++++++++++++++++++ .../views/components/layouts/admin.blade.php | 4 + .../components/layouts/admin/menu.blade.php | 65 +++++++ .../Business/StoreViolationAlertPageTest.php | 1 + tests/Feature/Settings/NavigationTest.php | 17 ++ 5 files changed, 264 insertions(+) create mode 100644 resources/views/business/pages/partials/open-page-tabs.blade.php diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php new file mode 100644 index 00000000..931d2de6 --- /dev/null +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -0,0 +1,177 @@ +{{-- + 已打开页面标签栏:点开过的业务页面固定在顶部并排显示,方便来回切换; + 只要没退出登录就一直保留(按账号存在浏览器本地,不外传)。 +--}} + + + + + diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 1075224e..026eec1b 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -92,6 +92,10 @@ + @unless ($focusEntry) + @include('business.pages.partials.open-page-tabs') + @endunless + diff --git a/resources/views/components/layouts/admin/menu.blade.php b/resources/views/components/layouts/admin/menu.blade.php index 75ff220f..687c82cb 100644 --- a/resources/views/components/layouts/admin/menu.blade.php +++ b/resources/views/components/layouts/admin/menu.blade.php @@ -199,6 +199,22 @@ @endphp +
+ +
+
@foreach ($prototypeGroups as $group)
@@ -226,6 +246,7 @@ @endforeach
@endforeach +
@stack('menu_end') + + diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index 171b7a1a..2b2e3259 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -149,6 +149,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase ])) ->assertOk() ->assertDontSee('id="sidenav-main"', false) + ->assertDontSee('data-open-tabs', false) ->assertSeeText('店铺违规信息预警'); // 同目录下的另一个页面(流程统计)在钉钉里同样是单页模式。 diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 851c5832..f92217f9 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -188,6 +188,23 @@ class NavigationTest extends FeatureTestCase ->assertSee('/workspace/financial-risk-control/store-violation-alert', false); } + public function test_business_pages_render_menu_filter_and_open_page_tabs(): void + { + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ])) + ->assertOk() + // 侧边栏菜单搜索框(方便按名称找页面)。 + ->assertSee('id="prototype-menu-filter"', false) + ->assertSee('prototype-menu-filter', false) + // 已打开页面标签栏(固定在页面上方,点开的页面并排显示)。 + ->assertSee('data-open-tabs', false) + ->assertSee('data-open-tabs-list', false) + ->assertSee('caiwu.open-pages.v1.', false); + } + public function testProcessStatisticsPageIsAvailableUnderRiskControl(): void { $this->loginAs() From 5970572274b0606583a6dc1b45d1768d739911b7 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 13 Oct 2026 15:10:49 +0800 Subject: [PATCH 26/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E6=8A=96=E9=9F=B3=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E4=BB=98=E5=90=AB=E5=B7=B2=E9=80=80=E6=AC=BE=E9=83=A8=E5=88=86?= =?UTF-8?q?=E3=80=8D=E5=88=A4=E5=AE=9A=E6=94=BE=E5=AE=BD=EF=BC=88=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E9=87=91=E9=A2=9D=E2=89=88=E5=B7=AE=E9=A2=9D=20?= =?UTF-8?q?=E6=88=96=20=E2=89=88=E5=AE=9E=E4=BB=98=E2=88=92=E6=94=B6?= =?UTF-8?q?=E5=85=A5=20=E5=91=BD=E4=B8=AD=E5=8D=B3=E5=8F=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 17 ++++++++++++++--- config/business_navigation.php | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 84ba0401..35773ff1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -235,14 +235,19 @@ class Pages extends Controller $rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005"; $rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005"; - // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上(≈差额)才标注。 + // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上 + // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)才标注。 $refundEvidence = self::receiptRefundEvidenceSql(); $differenceAbs = 'ABS(' . $effective . ')'; $refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END'; + $refundGapHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; $rules['订单实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' . $refundHit($refundEvidence['refund_sum']) . ' OR ' . $refundHit($refundEvidence['refund_max']) . ' OR ' . $refundHit($refundEvidence['cancelled_sum']) + . ' OR ' . $refundGapHit($refundEvidence['refund_sum']) + . ' OR ' . $refundGapHit($refundEvidence['refund_max']) + . ' OR ' . $refundGapHit($refundEvidence['cancelled_sum']) . ') ELSE 0 END = 1'; // 京喜正差额:差额=直营服务费(京喜账单把直营服务费并入「货款(收)」再以费用行扣回, @@ -308,11 +313,17 @@ class Pages extends Controller return $label . '未减优惠和冲减'; } - // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上(≈差额)→「订单实付含已退款部分」。 + // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上 + // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)→「订单实付含已退款部分」。 if (($row->platform ?? '') === '抖音' && $difference < 0) { + $refundGap = (float) ($row->paid_amount ?? 0) - $income; $refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null]; foreach ($refunds as $refund) { - if ($refund !== null && (float) $refund > 0.005 && abs((float) $refund - abs($difference)) < 0.02) { + if ($refund === null || (float) $refund <= 0.005) { + continue; + } + $refundAmount = (float) $refund; + if (abs($refundAmount - abs($difference)) < 0.02 || abs($refundAmount - $refundGap) < 0.02) { return '订单实付含已退款部分'; } } diff --git a/config/business_navigation.php b/config/business_navigation.php index 2e447c2a..36faeca2 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额或≈实付−收入对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From c5c71dab748b7241af3a990ea1bc6ecccf631a84 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 15:13:12 +0800 Subject: [PATCH 27/44] =?UTF-8?q?=E5=B7=B2=E6=89=93=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=A0=87=E7=AD=BE=E6=A0=8F=EF=BC=9A=E5=8F=AA=E6=94=B6?= =?UTF-8?q?=E5=BD=95=E4=BB=8E=E7=B3=BB=E7=BB=9F=E9=87=8C=E7=82=B9=E5=BC=80?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前只要在浏览器里加载过业务页面就会固定上去,包括浏览器恢复会话、另外标签页 静默加载的页面(用户没点过也会出现)。改成只在导航来自同源页面(即从系统内点击 进入)时才新增标签;已经在标签里的页面仍会更新标题和最近一次地址。 --- .../pages/partials/open-page-tabs.blade.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php index 931d2de6..7c43a7fc 100644 --- a/resources/views/business/pages/partials/open-page-tabs.blade.php +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -101,6 +101,15 @@ const currentPath = window.location.pathname; const isBusinessPage = /^\/\d+\/workspace\//.test(window.location.pathname); const pageTitle = () => (document.title || '').split(' - ')[0].trim() || window.location.pathname; + // 只有"从系统里点进去"的页面才自动固定:浏览器恢复会话、直接输入地址、 + // 从其它网站点进来时没有同源来源,不会被记进标签栏。 + const openedFromApp = (() => { + try { + return !!document.referrer && new URL(document.referrer).origin === window.location.origin; + } catch (_) { + return false; + } + })(); let items = read(); if (isBusinessPage) { @@ -109,11 +118,12 @@ if (existing) { existing.title = pageTitle(); existing.url = currentUrl; - } else { + save(items); + } else if (openedFromApp) { items.push({ path: currentPath, url: currentUrl, title: pageTitle() }); + items = items.slice(-maxTabs); + save(items); } - items = items.slice(-maxTabs); - save(items); } if (!items.length) { From add787edf5d753b8109bd37d49fe4cb8926cc08e Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 15:18:37 +0800 Subject: [PATCH 28/44] =?UTF-8?q?=E5=B7=B2=E6=89=93=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=A0=87=E7=AD=BE=E6=A0=8F=E4=B8=8A=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=A0=87=E9=A2=98=E8=A1=8C=E4=B8=8A=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按使用习惯把标签栏放在内容区最上面,页面标题/操作那一行在它下面。 --- resources/views/components/layouts/admin.blade.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 026eec1b..4b385da7 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -40,6 +40,11 @@
+ {{-- 已打开页面标签栏放在页面标题那一行上面(更靠近顶部,方便切换)。 --}} + @unless ($focusEntry) + @include('business.pages.partials.open-page-tabs') + @endunless + {!! ! empty($title->attributes->has('title')) ? $title->attributes->get('title') : $title !!} @@ -92,10 +97,6 @@ - @unless ($focusEntry) - @include('business.pages.partials.open-page-tabs') - @endunless - From a8a0c6d4c94f770a243925ed9b400372f767647f Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 15:24:26 +0800 Subject: [PATCH 29/44] =?UTF-8?q?=E5=B7=B2=E6=89=93=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=A0=87=E7=AD=BE=E6=A0=8F=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E4=BC=9A=E8=AF=9D=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前存在 localStorage:跨浏览器重启也不会清,前几天点过的标签刷新后又出现 (用户反馈"没点过/刷新后其他标签又弹出来")。改成 sessionStorage:同一次浏览器 会话内刷新、来回切换都保留,关掉浏览器或另开窗口重新进来就是干净的;同时清掉 旧版本写在 localStorage 里的记录。 --- .../pages/partials/open-page-tabs.blade.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php index 7c43a7fc..f4946521 100644 --- a/resources/views/business/pages/partials/open-page-tabs.blade.php +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -80,12 +80,21 @@ return; } const list = root.querySelector('[data-open-tabs-list]'); - const storageKey = 'caiwu.open-pages.v1.' + (root.dataset.userId || 'anon'); + const userId = root.dataset.userId || 'anon'; + const storageKey = 'caiwu.open-pages.v1.' + userId; + // 只在"本次浏览器会话"里保留:同一个浏览器窗口内刷新/来回切换都在, + // 关掉浏览器或换一个窗口重新进来就是干净的,不会再把前几天的旧标签带出来。 + const storage = window.sessionStorage; const maxTabs = 15; + // 清理早期版本写在 localStorage 里的记录(那份会跨浏览器一直保留)。 + try { + window.localStorage.removeItem(storageKey); + } catch (_) { /* 隐私模式下忽略 */ } + const read = () => { try { - const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]'); + const parsed = JSON.parse(storage.getItem(storageKey) || '[]'); return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.url === 'string' && typeof item.path === 'string') : []; } catch (_) { return []; @@ -93,7 +102,7 @@ }; const save = (items) => { try { - window.localStorage.setItem(storageKey, JSON.stringify(items)); + storage.setItem(storageKey, JSON.stringify(items)); } catch (_) { /* 隐私模式下忽略 */ } }; From 1c0054839665182dddc11453decbea93f77cef7d Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 15:26:40 +0800 Subject: [PATCH 30/44] =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=A0=8F=EF=BC=9A?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=90=8E=E8=87=B3=E5=B0=91=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=A1=B5=EF=BC=8C=E9=81=BF=E5=85=8D=E7=A9=BA?= =?UTF-8?q?=E7=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/business/pages/partials/open-page-tabs.blade.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php index f4946521..f6c65f41 100644 --- a/resources/views/business/pages/partials/open-page-tabs.blade.php +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -128,7 +128,9 @@ existing.title = pageTitle(); existing.url = currentUrl; save(items); - } else if (openedFromApp) { + } else if (openedFromApp || items.length === 0) { + // 从系统内点进来的页面,或者当前会话还没有任何标签(例如刷新后), + // 把当前页作为第一个标签,避免标签栏空着。 items.push({ path: currentPath, url: currentUrl, title: pageTitle() }); items = items.slice(-maxTabs); save(items); From 45a28aa76c2bd395cdc28b583d068a5e2d53f247 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 15:51:04 +0800 Subject: [PATCH 31/44] =?UTF-8?q?=E5=BF=AB=E9=80=92=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=EF=BC=9A=E5=B7=A5=E4=BD=9C=E5=8C=BA=20iframe=20=E5=86=85?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E5=A4=8D=E6=98=BE=E7=A4=BA=E5=B7=B2?= =?UTF-8?q?=E6=89=93=E5=BC=80=E9=A1=B5=E9=9D=A2=E6=A0=87=E7=AD=BE=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 已打开页面标签栏只在最外层窗口渲染,iframe 子页面不再出现同一条标签栏, 避免点标签时在 iframe 内部再套一层快递报价工作区(页面上出现两份同样的页签+标签行) - 快递报价工作区注入的样式同时隐藏 .open-tabs,兜住浏览器缓存的旧子页面 --- .../pages/courier-payables/final-workspace.blade.php | 3 ++- .../views/business/pages/partials/open-page-tabs.blade.php | 5 +++++ tests/Feature/Settings/NavigationTest.php | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/resources/views/business/pages/courier-payables/final-workspace.blade.php b/resources/views/business/pages/courier-payables/final-workspace.blade.php index f9e9a551..27334879 100644 --- a/resources/views/business/pages/courier-payables/final-workspace.blade.php +++ b/resources/views/business/pages/courier-payables/final-workspace.blade.php @@ -83,7 +83,8 @@ } // Preserve each page's own scripts, forms and routes in its document. const style = doc.createElement('style'); - style.textContent = '.js-menu,body>.container,#header [data-page-title-first]{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}'; + // 子页面在 iframe 里不再显示外层那一整排菜单和标签栏,只保留页面本身。 + style.textContent = '.js-menu,body>.container,#header [data-page-title-first],.open-tabs{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}'; doc.head.appendChild(style); frame.style.visibility = 'visible'; delete frame.dataset.failed; diff --git a/resources/views/business/pages/partials/open-page-tabs.blade.php b/resources/views/business/pages/partials/open-page-tabs.blade.php index f6c65f41..291b94f5 100644 --- a/resources/views/business/pages/partials/open-page-tabs.blade.php +++ b/resources/views/business/pages/partials/open-page-tabs.blade.php @@ -79,6 +79,11 @@ if (!root) { return; } + // 目录里有用 iframe 打开子页面的页面(例如快递报价)。iframe 内不再显示标签栏: + // 它既和外层那条重复,点标签也只会在 iframe 内部再套一层同样的页面。 + if (window.self !== window.top) { + return; + } const list = root.querySelector('[data-open-tabs-list]'); const userId = root.dataset.userId || 'anon'; const storageKey = 'caiwu.open-pages.v1.' + userId; diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index f92217f9..84cf5d6b 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -202,7 +202,9 @@ class NavigationTest extends FeatureTestCase // 已打开页面标签栏(固定在页面上方,点开的页面并排显示)。 ->assertSee('data-open-tabs', false) ->assertSee('data-open-tabs-list', false) - ->assertSee('caiwu.open-pages.v1.', false); + ->assertSee('caiwu.open-pages.v1.', false) + // 目录 iframe 里的子页面不再重复渲染这条标签栏。 + ->assertSee('window.self !== window.top', false); } public function testProcessStatisticsPageIsAvailableUnderRiskControl(): void From 2be4d4700ee5cd08e4c56413b7cf336de82dab56 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 13 Oct 2026 16:08:38 +0800 Subject: [PATCH 32/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E6=8A=96=E9=9F=B3=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E4=BB=98=E5=90=AB=E5=B7=B2=E9=80=80=E6=AC=BE=E9=83=A8=E5=88=86?= =?UTF-8?q?=E3=80=8D=E8=A1=A5=E5=85=85=E5=8F=A3=E5=BE=84=EF=BC=88=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=90=88=E8=AE=A1=EF=BC=8B=E8=B4=A6=E5=8D=95=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=87=80=E9=A2=9D=E2=89=88=E5=AE=9E=E4=BB=98=E2=88=92?= =?UTF-8?q?=E6=94=B6=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 9 +++++++++ config/business_navigation.php | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 35773ff1..5a445a7f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -237,10 +237,12 @@ class Pages extends Controller // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上 // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)才标注。 + // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 $refundEvidence = self::receiptRefundEvidenceSql(); $differenceAbs = 'ABS(' . $effective . ')'; $refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END'; $refundGapHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; + $refundGapNetHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS((' . $amount . ' + COALESCE(refund_amount, 0)) - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; $rules['订单实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' . $refundHit($refundEvidence['refund_sum']) . ' OR ' . $refundHit($refundEvidence['refund_max']) @@ -248,6 +250,7 @@ class Pages extends Controller . ' OR ' . $refundGapHit($refundEvidence['refund_sum']) . ' OR ' . $refundGapHit($refundEvidence['refund_max']) . ' OR ' . $refundGapHit($refundEvidence['cancelled_sum']) + . ' OR ' . $refundGapNetHit($refundEvidence['refund_sum']) . ') ELSE 0 END = 1'; // 京喜正差额:差额=直营服务费(京喜账单把直营服务费并入「货款(收)」再以费用行扣回, @@ -315,6 +318,7 @@ class Pages extends Controller // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上 // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)→「订单实付含已退款部分」。 + // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 if (($row->platform ?? '') === '抖音' && $difference < 0) { $refundGap = (float) ($row->paid_amount ?? 0) - $income; $refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null]; @@ -327,6 +331,11 @@ class Pages extends Controller return '订单实付含已退款部分'; } } + $refundSum = $row->order_refund_sum ?? null; + $refundNet = (float) ($row->refund_amount ?? 0); + if ($refundSum !== null && (float) $refundSum > 0.005 && abs((float) $refundSum + $refundNet - $refundGap) < 0.02) { + return '订单实付含已退款部分'; + } } // 京喜正差额:差额=直营服务费(账单把服务费并入货款(收)再扣回,已按销售费用—平台服务费入账)。 diff --git a/config/business_navigation.php b/config/business_navigation.php index 36faeca2..b405a889 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额或≈实付−收入对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From ae54706ceb9799f8969f9ca60ee366a14fd2f293 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 13 Oct 2026 16:37:45 +0800 Subject: [PATCH 33/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E6=8A=96=E9=9F=B3=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E4=BB=98=E5=90=AB=E5=B7=B2=E9=80=80=E6=AC=BE=E9=83=A8=E5=88=86?= =?UTF-8?q?=E3=80=8D=E5=86=8D=E8=A1=A5=E5=85=85=E5=8F=A3=E5=BE=84=EF=BC=88?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E5=90=88=E8=AE=A1=E2=89=88=E5=AE=9E=E4=BB=98?= =?UTF-8?q?=20=E6=95=B4=E5=8D=95=E5=85=A8=E9=80=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 8 +++++++- config/business_navigation.php | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 5a445a7f..bff79fe9 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -238,11 +238,13 @@ class Pages extends Controller // 抖音「订单实付含已退款部分」:负差额能在聚水潭退款单/取消子单金额里对上 // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)才标注。 // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 + // 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。 $refundEvidence = self::receiptRefundEvidenceSql(); $differenceAbs = 'ABS(' . $effective . ')'; $refundHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - ' . $differenceAbs . ') < 0.02 ELSE 0 END'; $refundGapHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; $refundGapNetHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS((' . $amount . ' + COALESCE(refund_amount, 0)) - (COALESCE(paid_amount, 0) - COALESCE(income_amount, 0))) < 0.02 ELSE 0 END'; + $refundFullHit = static fn (string $amount): string => 'CASE WHEN ' . $amount . ' > 0.005 THEN ABS(' . $amount . ' - COALESCE(paid_amount, 0)) < 0.02 ELSE 0 END'; $rules['订单实付含已退款部分'] = 'CASE WHEN (' . $refundEvidence['guard'] . ') THEN (' . $refundHit($refundEvidence['refund_sum']) . ' OR ' . $refundHit($refundEvidence['refund_max']) @@ -251,6 +253,7 @@ class Pages extends Controller . ' OR ' . $refundGapHit($refundEvidence['refund_max']) . ' OR ' . $refundGapHit($refundEvidence['cancelled_sum']) . ' OR ' . $refundGapNetHit($refundEvidence['refund_sum']) + . ' OR ' . $refundFullHit($refundEvidence['refund_sum']) . ') ELSE 0 END = 1'; // 京喜正差额:差额=直营服务费(京喜账单把直营服务费并入「货款(收)」再以费用行扣回, @@ -319,6 +322,7 @@ class Pages extends Controller // 抖音负差额:实付多出来的部分能在聚水潭退款单/取消子单金额里对上 // (≈差额,或放宽口径 ≈实付−收入=差额+优惠+冲减)→「订单实付含已退款部分」。 // 结算后退款会叠在退款合计上(账单里已单独记「退款」):退款合计+账单退款净额≈实付−收入 同样命中。 + // 整单全退(退款合计≈实付)也视为命中:全退后收入只剩后续零星收款(如用户打款)。 if (($row->platform ?? '') === '抖音' && $difference < 0) { $refundGap = (float) ($row->paid_amount ?? 0) - $income; $refunds = [$row->order_refund_sum ?? null, $row->order_refund_max ?? null, $row->order_cancelled_sum ?? null]; @@ -333,7 +337,9 @@ class Pages extends Controller } $refundSum = $row->order_refund_sum ?? null; $refundNet = (float) ($row->refund_amount ?? 0); - if ($refundSum !== null && (float) $refundSum > 0.005 && abs((float) $refundSum + $refundNet - $refundGap) < 0.02) { + $refundFull = (float) ($row->paid_amount ?? 0); + if ($refundSum !== null && (float) $refundSum > 0.005 + && (abs((float) $refundSum + $refundNet - $refundGap) < 0.02 || abs((float) $refundSum - $refundFull) < 0.02)) { return '订单实付含已退款部分'; } } diff --git a/config/business_navigation.php b/config/business_navigation.php index b405a889..dbaebc99 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From 7258d09238e05e18e4ba7d511dbade2b1846e351 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 16:41:45 +0800 Subject: [PATCH 34/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E5=88=97=E9=A1=BA=E5=BA=8F=E7=BB=9F=E4=B8=80=E3=80=8C?= =?UTF-8?q?=E5=B7=AE=E9=A2=9D=E2=86=92=E5=B7=AE=E9=A2=9D=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E2=86=92=E5=8C=B9=E9=85=8D=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E2=86=92=E6=9C=AA=E5=8C=B9=E9=85=8D=E5=8E=9F=E5=9B=A0=E3=80=8D?= =?UTF-8?q?=EF=BC=9B=E5=B7=AE=E9=A2=9D=E7=AD=9B=E9=80=89=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=9A=E9=80=89=E7=BB=84=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E8=AE=A1=E6=95=B0/=E5=AF=BC=E5=87=BA/?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/business_navigation.php | 4 +-- .../pages/partials/directory-table.blade.php | 25 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/config/business_navigation.php b/config/business_navigation.php index dbaebc99..3bf05e0e 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、差额=直营服务费(已计销售费用)(仅京喜正差额);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -821,8 +821,8 @@ $navigation = [ ['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'], ['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'], ['name' => 'difference', 'label' => '差额', 'kind' => 'number'], - ['name' => 'match_status', 'label' => '匹配状态'], ['name' => 'remark', 'label' => '差额异常备注'], + ['name' => 'match_status', 'label' => '匹配状态'], ['name' => 'unmatched_reason', 'label' => '未匹配原因'], ], ], diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index b5d0bbc1..6892ef3e 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1645,9 +1645,9 @@ 线上单号: -
- 匹配状态: +
+ 差额:
+
+ 匹配状态: + + + + + +
未匹配原因: @@ -1689,6 +1700,7 @@ if (!api) { return; } var widgets = []; [ + ['rdDifferenceState', @json(['有差额', '无差额']), '打字找差额(可多选)'], ['rdPlatform', @json(array_values($receiptStats['platform_options'] ?? [])), '打字找平台(可多选)'], ['rdStore', @json(array_values($receiptStats['store_options'] ?? [])), '打字找店铺(可多选)'], ['rdStatus', @json(array_values($receiptStats['status_options'] ?? [])), '打字找匹配状态(可多选)'], @@ -1716,6 +1728,8 @@ }); var month = form.querySelector('[name="month"]'); if (month && month.value) { params.set('month', month.value); } + var differenceState = form.querySelector('[name="difference_state"]'); + if (differenceState && differenceState.value) { params.set('difference_state', differenceState.value); } var orderNo = form.querySelector('[name="online_order_no"]'); if (orderNo && orderNo.value.trim()) { params.set('online_order_no', orderNo.value.trim()); } @@ -1753,6 +1767,8 @@ widget.root.addEventListener('click', schedule); widget.root.addEventListener('change', schedule); }); + var differenceSelect = form.querySelector('[name="difference_state"]'); + if (differenceSelect) { differenceSelect.addEventListener('change', schedule); } schedule(); })(); @@ -2590,6 +2606,9 @@ // 多选筛选提交的是 key[],重置时一并清掉。 url.searchParams.delete(`${key}[]`); }); + // 「差额」筛选(不在 fields 列表里,提交为 difference_state[])一并清掉。 + url.searchParams.delete('difference_state'); + url.searchParams.delete('difference_state[]'); url.searchParams.delete('page'); window.location.assign(url); }); From 5ab7786a904f3e637cea0241435a217f6c23d269 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 16:41:47 +0800 Subject: [PATCH 35/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E4=BA=AC=E5=96=9C=E6=96=B0=E5=A2=9E=E3=80=8C=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BB=98=E5=8F=96=E4=BE=9B=E8=B4=A7=E4=BB=B7?= =?UTF-8?q?=E3=80=8D=E5=A4=87=E6=B3=A8=EF=BC=88=E5=B7=AE=E9=A2=9D=3D?= =?UTF-8?q?=E7=9B=B4=E8=90=A5=E6=9C=8D=E5=8A=A1=E8=B4=B9=E8=A7=84=E6=A8=A1?= =?UTF-8?q?=E6=88=96=E5=87=80=E9=A2=9D=EF=BC=89=EF=BC=9B=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=BB=B6=E8=87=B3=2010=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=EF=BC=9B=E8=AE=A1=E6=95=B0=E4=B8=8E=E5=90=88=E8=AE=A1=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=80=E6=AC=A1=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 京喜工厂店/直营为供货模式:聚水潭订单实付=供货价(净收),账单按「应收−直营服务费=供货价」结算,正差额即直营服务费(含部分退款后的净额场景);逐单核对后标注,对不上不标。得物/京东补充索引已就绪。 --- app/Http/Controllers/Business/Pages.php | 147 ++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index bff79fe9..6d156c12 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -206,6 +206,31 @@ class Pages extends Controller ]; } + /** + * 京喜「订单实付取供货价」证据 SQL(仅京喜正差额行计算,CASE 守卫保证其它行不触发子查询)。 + * + * 京喜工厂店/直营为供货模式:账单按「应收 − 直营服务费 = 供货价(净打款)」结算, + * 聚水潭「订单实付」记的是供货价(净收);差额 = 该单直营服务费规模(收/支较大一边)。 + * + * @return array{guard:string, supply_fee:string} + */ + private static function receiptJingxiSupplyEvidenceSql(): array + { + $guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005"; + + return [ + 'guard' => $guard, + 'supply_fee' => "(SELECT GREATEST(COALESCE(SUM(b.income), 0), COALESCE(SUM(b.expense), 0)) + FROM jd_daily_bill_rows b + WHERE b.order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '直营服务费')", + 'supply_net' => "(SELECT ABS(COALESCE(SUM(b.income), 0) - COALESCE(SUM(b.expense), 0)) + FROM jd_daily_bill_rows b + WHERE b.order_no = store_receipt_order_match_rows.online_order_no + AND b.bill_item = '直营服务费')", + ]; + } + /** * 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。 * 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0; @@ -231,6 +256,13 @@ class Pages extends Controller $rules['订单实付未减联合营销费'] = 'CASE WHEN (' . $dewuEvidence['guard'] . ' AND (' . $dewuEvidence['marketing_expense'] . ') > 0.005' . ' AND ABS(' . $dewuNeat . ' - ROUND(' . $dewuNeat . ', 0)) < 0.005) THEN 1 ELSE 0 END = 1'; + + // 京喜「订单实付取供货价」:正差额 = 该单直营服务费规模(供货模式的净额差)才标注。 + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); + $rules['订单实付取供货价'] = 'CASE WHEN (' . $jingxiEvidence['guard'] + . ' AND (' . $jingxiEvidence['supply_fee'] . ') > 0.005' + . ' AND (ABS(' . $effective . ' - (' . $jingxiEvidence['supply_fee'] . ')) < 0.005' + . ' OR ABS(' . $effective . ' - (' . $jingxiEvidence['supply_net'] . ')) < 0.005)) THEN 1 ELSE 0 END = 1'; $rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005"; $rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005"; $rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005"; @@ -309,6 +341,15 @@ class Pages extends Controller } } } + // 京喜正差额:差额与直营服务费规模吻合(订单实付取的是供货价/净收)→「订单实付取供货价」。 + if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) { + $supplyFee = (float) ($row->order_jingxi_supply_fee ?? 0); + $supplyNet = (float) ($row->order_jingxi_supply_net ?? 0); + if ($supplyFee > 0.005 + && (abs($difference - $supplyFee) < 0.005 || abs($difference - $supplyNet) < 0.005)) { + return '订单实付取供货价'; + } + } if (abs($product - ($income + $discount)) < 0.005) { return $label . '未减冲减'; } @@ -892,10 +933,11 @@ class Pages extends Controller $storeFilter = $this->receiptOrderMatchFilterValues(request('store_name')); [$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark')); $unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason')); + $differenceState = $this->receiptOrderMatchFilterValues(request('difference_state')); $orderNo = trim((string) request('online_order_no', '')); // 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static - $applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter): void { + $applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void { if ($platformFilter !== []) { $query->whereIn('platform', $platformFilter); } @@ -915,6 +957,9 @@ class Pages extends Controller if ($unmatchedReasonFilter !== []) { $this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter); } + if ($differenceState !== []) { + $this->applyReceiptDifferenceStateFilter($query, $differenceState); + } }; // 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。 @@ -922,13 +967,11 @@ class Pages extends Controller ->table('store_receipt_order_match_rows') ->where('report_month', $month); - $countQuery = $base(); - $applyFilters($countQuery); - $total = (int) $countQuery->count(); - + // 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。 $aggQuery = $base(); $applyFilters($aggQuery); $agg = $aggQuery->selectRaw(" + COUNT(*) as total, SUM(payment_count) as payments, SUM(income_amount) as income_total, SUM(discount_amount) as discount_total, @@ -943,6 +986,7 @@ class Pages extends Controller SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched, SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched ")->first(); + $total = (int) ($agg->total ?? 0); $stats = [ 'month' => $month, @@ -1005,12 +1049,15 @@ class Pages extends Controller $applyFilters($itemsQuery); $refundEvidence = self::receiptRefundEvidenceSql(); $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); $items = $itemsQuery ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net') ->orderBy('occurred_date') ->orderBy('store_name') ->orderBy('online_order_no') @@ -1084,6 +1131,71 @@ class Pages extends Controller $query->where('match_status', '未匹配')->whereIn('remark', $labels); } + /** + * 「差额(有/无)」筛选:按页面差额口径判断(未匹配行按订单实付=0 计入差额)。 + * 多选内部是「或」:全选或全不选都等于不过滤。 + * + * @param \Illuminate\Database\Query\Builder $query + * @param string|list $state + */ + private function applyReceiptDifferenceStateFilter($query, $state): void + { + $states = is_array($state) ? $state : [$state]; + $has = in_array('有差额', $states, true); + $none = in_array('无差额', $states, true); + if ($has === $none) { + return; + } + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; + $query->whereRaw($has ? 'ABS(' . $effective . ') > 0.005' : 'ABS(' . $effective . ') <= 0.005'); + } + + /** + * 「差额(有/无)」联动计数:应用除差额以外的筛选条件后, + * 分别统计有差额 / 无差额的行数(行数为 0 的取值不返回,前端不展示)。 + * + * @param array $filters + * @return array{values:list,counts:array} + */ + private function receiptDifferenceStateFacet(string $month, array $filters, string $orderNo): array + { + $query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('store_receipt_order_match_rows') + ->where('report_month', $month); + + foreach ($filters as $key => $values) { + if ($key === 'difference_state' || $values === [] || $values === '') { + continue; + } + if ($key === 'remark') { + $this->applyReceiptRemarkFilter($query, (array) $values); + continue; + } + if ($key === 'unmatched_reason') { + $this->applyReceiptUnmatchedReasonFilter($query, (array) $values); + continue; + } + $query->whereIn($key, $values); + } + if ($orderNo !== '') { + $query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%'); + } + + $effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))'; + $row = $query->selectRaw('SUM(CASE WHEN ABS(' . $effective . ') > 0.005 THEN 1 ELSE 0 END) as has_diff, ' + . 'SUM(CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 1 ELSE 0 END) as none_diff')->first(); + + $counts = []; + if ((int) ($row->has_diff ?? 0) > 0) { + $counts['有差额'] = (int) $row->has_diff; + } + if ((int) ($row->none_diff ?? 0) > 0) { + $counts['无差额'] = (int) $row->none_diff; + } + + return ['values' => ['有差额', '无差额'], 'counts' => $counts]; + } + /** * 业绩明细的多选筛选值:多选控件提交 name[](数组),同时兼容历史单值链接。 * @@ -1171,13 +1283,15 @@ class Pages extends Controller 'match_status' => $statusValues, 'remark' => $remarkValues, 'unmatched_reason' => $this->receiptOrderMatchFilterValues(request('unmatched_reason')), + 'difference_state' => $this->receiptOrderMatchFilterValues(request('difference_state')), ]; $orderNo = trim((string) request('online_order_no', '')); // 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存 $cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo])); - return response()->json(cache()->remember($cacheKey, now()->addSeconds(60), function () use ($month, $filters, $orderNo) { + // 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。 + return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) { return [ 'month' => substr($month, 0, 7), 'facets' => [ @@ -1186,6 +1300,7 @@ class Pages extends Controller 'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS), 'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())), 'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()), + 'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo), ], ]; })); @@ -1205,7 +1320,7 @@ class Pages extends Controller ->where('report_month', $month); foreach ($filters as $key => $values) { - if ($key === $column || $values === []) { + if ($key === $column || $values === [] || $values === '') { continue; } // 差额异常备注按公式条件判定 @@ -1218,6 +1333,11 @@ class Pages extends Controller $this->applyReceiptUnmatchedReasonFilter($query, (array) $values); continue; } + // 差额(有/无):按页面差额口径过滤 + if ($key === 'difference_state') { + $this->applyReceiptDifferenceStateFilter($query, (array) $values); + continue; + } $query->whereIn($key, $values); } if ($orderNo !== '') { @@ -1381,16 +1501,27 @@ class Pages extends Controller if ($remarkValues !== []) { $this->applyReceiptRemarkFilter($query, $remarkValues); } + $unmatchedReasonValues = $this->receiptOrderMatchFilterValues($request->query('unmatched_reason')); + if ($unmatchedReasonValues !== []) { + $this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonValues); + } + $differenceState = $this->receiptOrderMatchFilterValues($request->query('difference_state')); + if ($differenceState !== []) { + $this->applyReceiptDifferenceStateFilter($query, $differenceState); + } // 「订单实付含已退款部分」「订单实付未减联合营销费」判定所需证据列(与页面同一口径;仅对应平台负差额行会真正计算子查询)。 $refundEvidence = self::receiptRefundEvidenceSql(); $dewuEvidence = self::receiptDewuMarketingEvidenceSql(); + $jingxiEvidence = self::receiptJingxiSupplyEvidenceSql(); $query ->selectRaw('*') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_sum'] . ' END AS order_refund_sum') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['refund_max'] . ' END AS order_refund_max') ->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum') - ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense'); + ->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee') + ->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net'); $selected = array_values(array_intersect( array_filter(explode(',', (string) $request->query('columns', ''))), From d8d5196628a25f8a31f3f102aae70d01cfd4560d Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 16:41:48 +0800 Subject: [PATCH 36/44] =?UTF-8?q?chore=EF=BC=9A.gitignore/.dockerignore=20?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E6=9C=AC=E5=9C=B0=E6=BC=94=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 +++ .gitignore | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.dockerignore b/.dockerignore index 776b9a12..b0370ad0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,3 +29,6 @@ docs scripts npm-debug.log yarn-error.log +public/net-layer-demo.html +public/perf-diff-plan.html +public/plan-a-changes-202608.csv diff --git a/.gitignore b/.gitignore index 77f148a7..35ebdef7 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ modules/* .workbuddy/ runtime/.env /.private/kaifa-progress-sync/ +public/net-layer-demo.html +public/perf-diff-plan.html +public/plan-a-changes-202608.csv From 10d3067aeeffcc820f3dcfec5af6bc97078bd85d Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 16:44:17 +0800 Subject: [PATCH 37/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E4=BA=AC=E5=96=9C=E5=A4=87=E6=B3=A8=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E8=AE=A2=E5=8D=95=E5=AE=9E=E4=BB=98=E5=8F=96?= =?UTF-8?q?=E4=BE=9B=E8=B4=A7=E4=BB=B7=E3=80=8D=EF=BC=88=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E7=9A=84=E5=AE=BD=E5=88=A4=E5=AE=9A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E8=B4=9F=E8=B4=A3=E4=BA=BA=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同事此前以「差额=直营服务费(已计销售费用)」宽判定(京喜正差额即标,约 2% 与本单服务费对不上);经负责人确认,统一为「订单实付取供货价」:逐单核对差额=直营服务费规模或净额(部分退款场景),对不上不标。 --- app/Http/Controllers/Business/Pages.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 6d156c12..0d10616a 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -288,10 +288,6 @@ class Pages extends Controller . ' OR ' . $refundFullHit($refundEvidence['refund_sum']) . ') ELSE 0 END = 1'; - // 京喜正差额:差额=直营服务费(京喜账单把直营服务费并入「货款(收)」再以费用行扣回, - // 该服务费已在科目映射表按「销售费用—平台服务费」入账);正差额行逐单 97.8% 与直营服务费精确相等。 - $rules['差额=直营服务费(已计销售费用)'] = "match_status = '已匹配' AND platform = '京喜账单' AND " . $effective . ' > 0.005'; - $conditions = []; $seen = []; foreach ($rules as $label => $condition) { @@ -385,11 +381,6 @@ class Pages extends Controller } } - // 京喜正差额:差额=直营服务费(账单把服务费并入货款(收)再扣回,已按销售费用—平台服务费入账)。 - if (($row->platform ?? '') === '京喜账单' && ($row->match_status ?? '') === '已匹配' && $difference > 0.005) { - return '差额=直营服务费(已计销售费用)'; - } - return null; } From 8a67c7d7689ca080a2cc8f38eeb1b70f6a83903a Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 17:46:18 +0800 Subject: [PATCH 38/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A1688=E9=87=87=E9=94=80=E4=B8=93=E7=94=A8=E6=94=B6?= =?UTF-8?q?=E6=AC=BE=E5=8D=95=E7=8B=AC=E6=88=90=E6=A1=A3=EF=BC=88=E6=9C=AA?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=8E=9F=E5=9B=A0=E6=96=B0=E5=A2=9E=E4=B8=A4?= =?UTF-8?q?=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步逻辑:「支付宝-1688采销专用」账户的未匹配行写「账单无单号(采销收款)」「1688采销收款(非销售单)」,不再混进「订单表无此单」 - 页面筛选:未匹配原因下拉补上两档 - 字段结构说明同步更新 --- .../Commands/SyncStoreReceiptOrderMatches.php | 41 ++++++++++++++++++- app/Http/Controllers/Business/Pages.php | 10 ++++- config/business_navigation.php | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 004f775f..e5164a41 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -162,6 +162,17 @@ class SyncStoreReceiptOrderMatches extends Command /** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */ private const REMARK_PROMO_ONLY = '无收款账单'; + /** + * 1688 采销专用账户(按账单店铺名识别): + * 这个账户收的是采销/供货结算款,不是店铺销售单收款,订单侧本来就没有对应的销售单, + * 所以未匹配原因单独成档,避免和真实丢单混在「订单表无此单」里。 + */ + private const CAIXIAO_STORE_KEYWORD = '采销专用'; + + private const REMARK_EMPTY_NO_CAIXIAO = '账单无单号(采销收款)'; + + private const REMARK_NO_ORDER_CAIXIAO = '1688采销收款(非销售单)'; + public function handle(): int { // 全量重建时账单与订单侧映射都在内存里:拼多多 / 淘系支付宝这类大平台需要 1G 以上, @@ -691,6 +702,8 @@ class SyncStoreReceiptOrderMatches extends Command $status = $basis === 'product' ? $productStatus : $paidStatus; $remark = $basis === 'product' ? $productRemark : $paidRemark; + // 1688 采销专用账户的未匹配原因单独成档(采销收款不是店铺销售单)。 + $remark = self::caixiaoRemark((string) $group['store_name'], $remark); $basisAmount = $basis === 'product' ? $productAmount : $paidAmount; if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') { @@ -1081,7 +1094,9 @@ class SyncStoreReceiptOrderMatches extends Command 'paid_amount' => null, 'difference' => null, 'match_status' => self::STATUS_UNMATCHED, - 'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY, + 'remark' => $core === '' + ? self::caixiaoRemark((string) $group['store_name'], self::REMARK_EMPTY_NO) + : self::REMARK_PROMO_ONLY, 'imported_at' => $now, ]; } @@ -1197,6 +1212,30 @@ class SyncStoreReceiptOrderMatches extends Command return strtoupper($value); } + /** + * 是否 1688 采销专用账户(按账单店铺名判断)。 + */ + private static function isCaixiaoStore(string $storeName): bool + { + return str_contains(trim($storeName), self::CAIXIAO_STORE_KEYWORD); + } + + /** + * 1688 采销专用账户的未匹配原因单独成档;其余店铺的标注原样返回。 + */ + private static function caixiaoRemark(string $storeName, string $remark): string + { + if (! self::isCaixiaoStore($storeName)) { + return $remark; + } + + return match ($remark) { + self::REMARK_EMPTY_NO => self::REMARK_EMPTY_NO_CAIXIAO, + self::REMARK_NO_ORDER => self::REMARK_NO_ORDER_CAIXIAO, + default => $remark, + }; + } + /** * 账单行上的业务备注:科目映射表的「业务备注关键词」用它匹配。 * diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0d10616a..8d3a7592 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -137,7 +137,15 @@ class Pages extends Controller { $basis = self::differenceBasis(); - return ['账单无单号', '单号格式异常', '订单表无此单', $basis['no_amount_remark'], '无收款账单']; + return [ + '账单无单号', + '账单无单号(采销收款)', + '单号格式异常', + '订单表无此单', + '1688采销收款(非销售单)', + $basis['no_amount_remark'], + '无收款账单', + ]; } /** diff --git a/config/business_navigation.php b/config/business_navigation.php index 3bf05e0e..c1bdf4d0 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -806,7 +806,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「未匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)、订单实付含已退款部分(仅抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注);都不满足则留空。「未匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], From 530c9858ec7b4a85a449a06178a1c21ad34823d3 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 13 Oct 2026 17:57:28 +0800 Subject: [PATCH 39/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=9A=E6=9C=88=E4=BB=BD=E9=80=89=E5=AE=8C=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=EF=BC=88=E6=9C=88=E4=BB=BD=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E8=A1=A5=E5=80=BC=E5=8F=98=E6=9B=B4=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=20+=20=E7=AD=9B=E9=80=89=E6=A0=8F=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/partials/directory-table.blade.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 6892ef3e..baf337c8 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1769,6 +1769,20 @@ }); var differenceSelect = form.querySelector('[name="difference_state"]'); if (differenceSelect) { differenceSelect.addEventListener('change', schedule); } + + // 月份选完即查询:月份是自定义选择器(选完只改隐藏值),这里接上自动提交筛选表单, + // 省掉「选完月份再点查询」这一步;清空 / 本月按钮同属值变更,一并自动查询。 + var monthInput = form.querySelector('[data-inventory-month-value]'); + if (monthInput) { + monthInput.addEventListener('change', function () { + window.clearTimeout(timer); + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.submit(); + } + }); + } schedule(); })(); @@ -2497,6 +2511,8 @@ if (date) viewYear = date.year; render(); close(); + // 通知使用方「月份已选好」:筛选栏可以据此自动查询,不用再点一次「查询」。 + valueInput.dispatchEvent(new Event('change', { bubbles: true })); }; label.textContent = formatLabel(valueInput.value); From ea80148dfc11ef55d8eea0eda728e4bce417de45 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 19:54:01 +0800 Subject: [PATCH 40/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E7=BA=A7=E5=88=AB/=E8=B4=A3?= =?UTF-8?q?=E4=BB=BB=E4=BA=BA/=E5=A4=84=E7=90=86=E6=88=AA=E6=AD=A2/?= =?UTF-8?q?=E8=B6=85=E6=97=B6/=E6=8E=A8=E9=80=81=E7=8A=B6=E6=80=81=20+=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E7=8A=B6=E6=80=81=E5=9B=9B=E6=A1=A3=20+=20?= =?UTF-8?q?=E7=BE=A4=E6=8E=A8=E9=80=81=E6=8E=A5=E5=8F=A3=20+=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=BB=9F=E8=AE=A1=E9=A1=B5=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoreViolationAlertCalculator.php | 273 ++++++++++ app/Business/StoreViolationNotifier.php | 237 +++++++++ .../StoreViolationWorkbookImporter.php | 28 ++ .../Commands/PushStoreViolationAlerts.php | 41 ++ .../Commands/PushStoreViolationDigest.php | 38 ++ .../Commands/RecalcStoreViolationAlerts.php | 73 +++ app/Console/Commands/SyncStoreViolations.php | 20 +- app/Console/Kernel.php | 11 + app/Http/Controllers/Business/Pages.php | 204 +++++++- app/Utilities/SidebarPermissions.php | 19 +- config/business_navigation.php | 1 + config/store_violations.php | 44 +- ...flow_fields_to_store_violation_records.php | 104 ++++ ...100_create_store_violation_rules_table.php | 49 ++ ...create_store_violation_push_logs_table.php | 48 ++ deploy/activate-incremental-release.sh | 3 + deploy/activate-release.sh | 3 + .../StoreViolationPushRepository.php | 128 +++++ .../StoreViolationRecordRepository.php | 140 +++++- .../StoreViolationRuleRepository.php | 58 +++ .../financial-risk-control/index.blade.php | 177 ++++++- .../preview-statistics.blade.php | 470 ++++++++++++++++++ routes/admin.php | 2 + .../Business/StoreViolationAlertPageTest.php | 353 ++++++++++++- tests/Feature/Settings/NavigationTest.php | 1 + .../StoreViolationAlertCalculatorTest.php | 82 +++ .../Deployment/BusinessMirrorScheduleTest.php | 6 + 27 files changed, 2574 insertions(+), 39 deletions(-) create mode 100644 app/Business/StoreViolationAlertCalculator.php create mode 100644 app/Business/StoreViolationNotifier.php create mode 100644 app/Console/Commands/PushStoreViolationAlerts.php create mode 100644 app/Console/Commands/PushStoreViolationDigest.php create mode 100644 app/Console/Commands/RecalcStoreViolationAlerts.php create mode 100644 database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php create mode 100644 database/migrations/2026_10_13_200100_create_store_violation_rules_table.php create mode 100644 database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php create mode 100644 modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php create mode 100644 modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php create mode 100644 resources/views/business/pages/financial-risk-control/preview-statistics.blade.php create mode 100644 tests/Unit/Business/StoreViolationAlertCalculatorTest.php diff --git a/app/Business/StoreViolationAlertCalculator.php b/app/Business/StoreViolationAlertCalculator.php new file mode 100644 index 00000000..f875be7e --- /dev/null +++ b/app/Business/StoreViolationAlertCalculator.php @@ -0,0 +1,273 @@ +>|null */ + private ?array $rules = null; + + /** @var array|null */ + private ?array $ownerMap = null; + + /** @var array */ + private array $ownerFallback = []; + + public function __construct( + private readonly StoreViolationRuleRepository $ruleRepository, + private readonly StoreAccountMirrorRepository $storeAccounts, + ) { + } + + public function flush(): void + { + $this->rules = null; + $this->ownerMap = null; + $this->ownerFallback = []; + } + + /** @return array 预警相关字段(用于同步/导入写入) */ + public function calculate( + string $platform, + ?string $shopName, + ?string $violationType, + ?string $violationDate, + ?string $appealDeadline, + ): array { + $rule = self::matchRule($this->rules(), $platform, $violationType); + $needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true); + $level = null; + if ($needsAlert) { + $level = trim((string) ($rule['alert_level'] ?? '')); + if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) { + $level = 'P2'; + } + } + + $hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null + ? (int) $rule['handling_hours'] + : null; + + return [ + 'needs_alert' => $needsAlert, + 'alert_level' => $level, + 'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null, + 'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null, + 'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null, + 'handling_hours' => $needsAlert ? $hours : null, + 'owner_name' => $this->ownerFor($shopName), + 'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level), + ]; + } + + /** @return list> */ + private function rules(): array + { + if ($this->rules === null) { + try { + $this->rules = $this->ruleRepository->activeRules(); + } catch (Throwable) { + $this->rules = []; + } + } + + return $this->rules; + } + + /** + * 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。 + */ + public static function cleanViolationType(?string $text): string + { + $text = trim((string) $text); + if ($text === '') { + return ''; + } + + $previous = null; + while ($previous !== $text) { + $previous = $text; + $text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text; + } + + return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text; + } + + private static function loose(string $text): string + { + return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text; + } + + /** + * 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。 + * + * @param list> $rules 已按优先级排序 + * @return array|null + */ + public static function matchRule(array $rules, string $platform, ?string $violationType): ?array + { + $record = self::cleanViolationType($violationType); + if ($record === '') { + return null; + } + + $looseRecord = self::loose($record); + + foreach ($rules as $rule) { + if (trim((string) ($rule['platform'] ?? '')) !== $platform) { + continue; + } + + $ruleType = self::cleanViolationType($rule['violation_type'] ?? null); + if ($ruleType !== '') { + if ($record === $ruleType || str_starts_with($record, $ruleType)) { + return $rule; + } + foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) { + $part = trim((string) $part); + if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) { + return $rule; + } + } + $looseRule = self::loose($ruleType); + if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) { + return $rule; + } + } + + foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) { + $keyword = self::loose(self::cleanViolationType($keyword)); + if ($keyword !== '' && str_contains($looseRecord, $keyword)) { + return $rule; + } + } + } + + return null; + } + + /** + * 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。 + */ + public static function deadlineFor( + ?string $violationDate, + ?string $appealDeadline, + ?int $handlingHours, + ?string $alertLevel, + ): ?string { + $appeal = trim((string) $appealDeadline); + if ($appeal !== '') { + try { + return Carbon::parse($appeal)->endOfDay()->toDateTimeString(); + } catch (Throwable) { + // 解析失败时退回按级别计算 + } + } + + $date = trim((string) $violationDate); + if ($date === '') { + return null; + } + + try { + $base = Carbon::parse($date)->startOfDay(); + } catch (Throwable) { + return null; + } + + if ($handlingHours !== null && $handlingHours > 0) { + return $base->addHours($handlingHours)->toDateTimeString(); + } + + $level = trim((string) $alertLevel); + if ($level === '') { + return null; + } + + $days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0); + + return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null; + } + + /** 是否超时:处理状态不在终态且当前时间已过处理截止。 */ + public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool + { + $status = trim((string) $handlingStatus); + if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) { + return false; + } + + $deadline = trim((string) $handlingDeadline); + if ($deadline === '') { + return false; + } + + try { + return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline)); + } catch (Throwable) { + return false; + } + } + + private function ownerFor(?string $shopName): ?string + { + $name = trim((string) $shopName); + if ($name === '') { + return null; + } + + try { + if ($this->ownerMap === null) { + $this->ownerMap = $this->storeAccounts->ownershipByStoreName(); + } + $owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? '')); + if ($owner !== '') { + return mb_substr($owner, 0, 64); + } + } catch (Throwable) { + $this->ownerMap ??= []; + } + + // 兜底:店铺档案(经营分析副本库)的店铺运营负责人。 + if (array_key_exists($name, $this->ownerFallback)) { + return $this->ownerFallback[$name]; + } + + $head = null; + try { + $row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('ops_fys_shop_info') + ->where('shopName', $name) + ->orderByDesc('id') + ->first(['shopHead']); + $head = self::nullableText($row->shopHead ?? null, 64); + } catch (Throwable) { + $head = null; + } + + return $this->ownerFallback[$name] = $head; + } + + private static function nullableText(mixed $value, int $limit): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : mb_substr($text, 0, $limit); + } +} diff --git a/app/Business/StoreViolationNotifier.php b/app/Business/StoreViolationNotifier.php new file mode 100644 index 00000000..add81683 --- /dev/null +++ b/app/Business/StoreViolationNotifier.php @@ -0,0 +1,237 @@ +pushes->instantCandidates($levels, $retryFailed, $limit); + $summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0]; + $ownerContacts = (array) config('store_violations.push.owner_contacts', []); + + foreach ($candidates as $record) { + $owner = trim((string) ($record->owner_name ?? '')); + $atMobiles = []; + if ($owner !== '' && ! empty($ownerContacts[$owner])) { + $atMobiles[] = (string) $ownerContacts[$owner]; + } + + [$title, $text] = $this->instantMessage($record); + $pushNo = $this->pushNo(); + $now = Carbon::now(); + + try { + $this->send($title, $text, $atMobiles); + $this->pushes->markPushed([(int) $record->id], $now); + $summary['succeeded']++; + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'success', + 'error' => null, + ]); + } catch (Throwable $exception) { + $summary['failed']++; + $this->pushes->markPushFailed((int) $record->id, $now); + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + } + } + + return $summary; + } + + /** + * @return array{pending:array,overdue:int,p2:int,sent:bool} + */ + public function pushDigest(): array + { + $data = $this->pushes->digestData(); + $pending = $data['pending']; + $overdue = $data['overdue']; + $p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + + $lines = [ + '### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')', + '- 待处理:P0 ' . (int) ($pending['P0'] ?? 0) + . ' 条 / P1 ' . (int) ($pending['P1'] ?? 0) + . ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条', + '- 已超时未认领:' . $overdue->count() . ' 条', + ]; + foreach ($overdue as $index => $record) { + $lines[] = sprintf( + ' %d. [%s] %s(责任人:%s) · 截止 %s', + $index + 1, + (string) ($record->alert_level ?? ''), + (string) ($record->shop_name ?? ''), + trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配', + (string) ($record->handling_deadline ?? ''), + ); + } + + $now = Carbon::now(); + try { + $this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []); + } catch (Throwable $exception) { + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false]; + } + + // 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。 + $this->pushes->markPushed($p2Ids, $now); + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'success', + 'error' => null, + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true]; + } + + /** @return array{0:string,1:string} */ + private function instantMessage(object $record): array + { + $level = (string) ($record->alert_level ?? ''); + $levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level); + $platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? '')); + $title = '店铺违规预警(' . $levelLabel . ')'; + $lines = [ + '### 【' . $levelLabel . '】店铺违规预警', + '- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''), + '- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200), + '- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'), + '- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'), + '- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'), + '- 处理截止:' . (string) ($record->handling_deadline ?? '—'), + ]; + + $claimUrl = $this->claimUrl((int) $record->id); + if ($claimUrl !== null) { + $lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)'; + } + + return [$title, implode("\n", $lines)]; + } + + private function claimUrl(int $recordId): ?string + { + try { + $companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1); + + return URL::signedRoute('business.store-violation-alert.claim', [ + 'record' => $recordId, + 'company_id' => $companyId, + ]); + } catch (Throwable) { + return null; + } + } + + /** @param list $atMobiles */ + private function send(string $title, string $text, array $atMobiles): void + { + $config = (array) config('store_violations.push', []); + $webhook = trim((string) ($config['webhook'] ?? '')); + if ($webhook === '') { + throw new RuntimeException('推送 Webhook 未配置。'); + } + + $url = $webhook; + $secret = trim((string) ($config['secret'] ?? '')); + if ($secret !== '') { + $timestamp = (string) (int) (microtime(true) * 1000); + $sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true))); + $url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign; + } + + $response = Http::timeout(10)->post($url, [ + 'msgtype' => 'markdown', + 'markdown' => ['title' => $title, 'text' => $text], + 'at' => [ + 'atMobiles' => array_values($atMobiles), + 'isAtAll' => (bool) ($config['at_all'] ?? false), + ], + ]); + + if (! $response->successful()) { + throw new RuntimeException('推送接口返回 HTTP ' . $response->status()); + } + + $body = (array) $response->json(); + if ((int) ($body['errcode'] ?? 0) !== 0) { + throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? '')); + } + } + + private function pushNo(): string + { + return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6)); + } +} diff --git a/app/Business/StoreViolationWorkbookImporter.php b/app/Business/StoreViolationWorkbookImporter.php index ebee242a..00391749 100644 --- a/app/Business/StoreViolationWorkbookImporter.php +++ b/app/Business/StoreViolationWorkbookImporter.php @@ -19,6 +19,7 @@ class StoreViolationWorkbookImporter public function __construct( private readonly StoreViolationMapper $mapper, private readonly StoreViolationRecordRepository $repository, + private readonly StoreViolationAlertCalculator $alerts, ) { } @@ -139,6 +140,11 @@ class StoreViolationWorkbookImporter } $updateColumns[] = 'source'; $updateColumns[] = 'updated_at'; + // 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); $payload = []; $created = 0; @@ -155,6 +161,17 @@ class StoreViolationWorkbookImporter continue; } + $matchType = trim((string) ($data['violation_type'] ?? '')) !== '' + ? (string) $data['violation_type'] + : ($data['violation_reason'] ?? null); + $data = array_merge($data, $this->alerts->calculate( + $platform, + (string) ($data['shop_name'] ?? ''), + $matchType, + $data['violation_date'] ?? null, + $data['appeal_deadline'] ?? null, + )); + foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) { if (! array_key_exists($column, $data)) { $data[$column] = null; @@ -369,6 +386,8 @@ class StoreViolationWorkbookImporter if ($has('handling_status')) { $handling = trim((string) ($raw['handling_status'] ?? '')); + $legacyMap = (array) config('store_violations.handling_status_legacy', []); + $handling = (string) ($legacyMap[$handling] ?? $handling); $options = (array) config('store_violations.handling_statuses', []); if ($handling === '') { $data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); @@ -380,6 +399,15 @@ class StoreViolationWorkbookImporter } } + if ($has('ignore_reason')) { + $ignoreReason = trim((string) ($raw['ignore_reason'] ?? '')); + $data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191); + } + + if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') { + $errors[] = '处理状态为“已忽略”时必须填写忽略原因'; + } + return [ 'row_number' => $rowNumber, 'status' => $errors === [] ? 'valid' : 'invalid', diff --git a/app/Console/Commands/PushStoreViolationAlerts.php b/app/Console/Commands/PushStoreViolationAlerts.php new file mode 100644 index 00000000..b0036b9c --- /dev/null +++ b/app/Console/Commands/PushStoreViolationAlerts.php @@ -0,0 +1,41 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushInstant( + max((int) $this->option('limit'), 1), + (bool) $this->option('retry-failed'), + ); + + $this->info(sprintf( + '即时推送:待推 %d 条,成功 %d 条,失败 %d 条。', + $summary['attempted'], + $summary['succeeded'], + $summary['failed'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PushStoreViolationDigest.php b/app/Console/Commands/PushStoreViolationDigest.php new file mode 100644 index 00000000..aee87d08 --- /dev/null +++ b/app/Console/Commands/PushStoreViolationDigest.php @@ -0,0 +1,38 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushDigest(); + + $this->info(sprintf( + '日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。', + $summary['sent'] ? '已发送' : '发送失败', + (int) ($summary['pending']['P0'] ?? 0), + (int) ($summary['pending']['P1'] ?? 0), + (int) ($summary['pending']['P2'] ?? 0), + (int) $summary['overdue'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalcStoreViolationAlerts.php b/app/Console/Commands/RecalcStoreViolationAlerts.php new file mode 100644 index 00000000..1698581e --- /dev/null +++ b/app/Console/Commands/RecalcStoreViolationAlerts.php @@ -0,0 +1,73 @@ +available()) { + $this->error('统一表 store_violation_records 尚未建立。'); + + return self::FAILURE; + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $chunk = max(min((int) $this->option('chunk'), 2000), 100); + $platform = trim((string) $this->option('platform')); + $total = 0; + + $query = $connection->table(StoreViolationRecordRepository::TABLE) + ->select(['id', 'platform', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline']) + ->orderBy('id'); + if ($platform !== '') { + $query->where('platform', $platform); + } + + $query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void { + $updates = []; + foreach ($rows as $row) { + $matchType = trim((string) ($row->violation_type ?? '')) !== '' + ? (string) $row->violation_type + : ($row->violation_reason ?? null); + + $updates[] = ['id' => $row->id] + $calculator->calculate( + (string) $row->platform, + $row->shop_name, + $matchType, + $row->violation_date ? (string) $row->violation_date : null, + $row->appeal_deadline ? (string) $row->appeal_deadline : null, + ); + } + + if ($updates !== []) { + $connection->table(StoreViolationRecordRepository::TABLE)->upsert( + $updates, + ['id'], + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ); + $total += count($updates); + } + }); + + $this->info(sprintf('预警重算完成:%d 行。', $total)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncStoreViolations.php b/app/Console/Commands/SyncStoreViolations.php index 9dfa48f4..a05743ac 100644 --- a/app/Console/Commands/SyncStoreViolations.php +++ b/app/Console/Commands/SyncStoreViolations.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Business\StoreViolationAlertCalculator; use App\Business\StoreViolationMapper; use Illuminate\Console\Command; use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository; @@ -23,7 +24,11 @@ class SyncStoreViolations extends Command protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)'; - public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int + public function handle( + StoreViolationMapper $mapper, + StoreViolationRecordRepository $repository, + StoreViolationAlertCalculator $calculator, + ): int { if (! $repository->available()) { $this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。'); @@ -74,6 +79,11 @@ class SyncStoreViolations extends Command $updateColumns = $extraColumns === [] ? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS : array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns)); + // 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); try { while (true) { @@ -118,6 +128,14 @@ class SyncStoreViolations extends Command continue; } + $mapped = array_merge($mapped, $calculator->calculate( + (string) $mapped['platform'], + $mapped['shop_name'], + $mapped['violation_type'], + $mapped['violation_date'], + $mapped['appeal_deadline'], + )); + $payload[] = $mapped + [ 'synced_at' => $now, 'created_at' => $now, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 43feda7a..64d8d09b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -65,6 +65,17 @@ class Kernel extends ConsoleKernel ->withoutOverlapping(60) ->runInBackground(); + // 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。 + $schedule->command('store-violations:push-instant') + ->cron('*/5 9-21 * * *') + ->withoutOverlapping(5) + ->runInBackground(); + + $schedule->command('store-violations:push-digest') + ->dailyAt('10:00') + ->withoutOverlapping(30) + ->runInBackground(); + $schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0') ->dailyAt('00:00') ->withoutOverlapping(180) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 8d3a7592..b33bbf21 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -13,6 +13,7 @@ use App\Business\UpstreamBusinessInformation; use App\Business\StoreReceivingAccountTemplate; use App\Business\StoreReceivingAccountWorkbookImporter; use App\Business\StoreViolationWorkbookImporter; +use App\Business\StoreViolationAlertCalculator; use App\Business\WithdrawalManagementReport; use App\Jobs\Common\InventoryReportExport; use App\Models\Banking\MarketingAccountBill; @@ -3008,6 +3009,7 @@ class Pages extends Controller 'account-mapping' => $this->contentForAccountMapping(), 'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(), 'store-violation-alert' => $this->contentForStoreViolationAlerts(), + 'preview-statistics' => $this->contentForPreviewStatistics(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), 'store-report-data-logic' => $this->prepareStoreReportLogic(), @@ -3323,7 +3325,7 @@ class Pages extends Controller 'storeViolationFilters' => $filters, 'storeViolationFilterOptions' => $error === null ? $repository->filterOptions($activePlatform) - : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []], + : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []], 'storeViolationPlatformCounts' => $error === null ? $repository->platformCounts() : [], 'storeViolationSync' => $repository->syncStatus(), 'storeViolationError' => $error, @@ -3334,14 +3336,185 @@ class Pages extends Controller /** @return array */ private function storeViolationFilters(Request $request): array { - return [ + $owner = mb_substr(trim((string) $request->query('owner')), 0, 64); + $ownerIsMe = in_array(mb_strtolower($owner), ['me', '我的'], true); + if ($ownerIsMe) { + $owner = mb_substr(trim((string) (auth()->user()->name ?? '')), 0, 64); + } + + $handlingStatus = mb_substr(trim((string) $request->query('handling_status')), 0, 32); + $view = trim((string) $request->query('view')) === 'all' ? 'all' : 'default'; + $explicit = trim((string) $request->query('keyword')) !== '' + || trim((string) $request->query('status')) !== '' + || $handlingStatus !== '' + || trim((string) $request->query('category')) !== '' + || $this->dateFilter($request->query('date_from')) !== '' + || $this->dateFilter($request->query('date_to')) !== '' + || trim((string) $request->query('level')) !== '' + || $owner !== '' + || trim((string) $request->query('overdue')) === '1'; + + $filters = [ 'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100), 'status' => mb_substr(trim((string) $request->query('status')), 0, 64), - 'handling_status' => mb_substr(trim((string) $request->query('handling_status')), 0, 32), + 'handling_status' => $handlingStatus, 'category' => mb_substr(trim((string) $request->query('category')), 0, 64), 'date_from' => $this->dateFilter($request->query('date_from')), 'date_to' => $this->dateFilter($request->query('date_to')), + 'level' => mb_substr(trim((string) $request->query('level')), 0, 8), + 'owner' => $owner, + 'owner_is_me' => $ownerIsMe ? '1' : '', + 'overdue' => trim((string) $request->query('overdue')) === '1' ? '1' : '', + 'view' => $view, + 'has_explicit' => $explicit ? '1' : '', ]; + + // 默认视图:只展示「需提醒 + 未处理」,避免历史数据淹没预警; + // 其余数据仍可通过「全部数据」查看。 + if ($view === 'default') { + $filters['needs_alert'] = '1'; + if ($filters['handling_status'] === '') { + $filters['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); + } + } + + return $filters; + } + + /** 财务经营风险管控 › 预览统计:顶部 4 张 KPI 卡 + 需提醒且未处理的明细预览。 */ + protected function contentForPreviewStatistics(): array + { + $request = request(); + $platforms = (array) config('store_violations.platforms', []); + $requested = (string) $request->query('platform', (string) array_key_first($platforms)); + $activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms); + + $repository = app(StoreViolationRecordRepository::class); + $limit = min(max((int) setting('default.list_limit', 25), 10), 100); + $error = null; + $stats = []; + $counts = []; + $panels = []; + + if (! $repository->available()) { + $error = '店铺违规统一表尚未建立,暂时无法读取数据。'; + } else { + try { + $stats = $repository->statsByPlatform(); + $counts = $repository->platformCounts(); + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + foreach (array_keys($platforms) as $slug) { + $page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1; + $records = Collection::make(); + $total = 0; + + if ($error === null) { + try { + $result = $repository->paginate((string) $slug, [ + 'needs_alert' => true, + 'handling_status' => (string) config('store_violations.default_handling_status', '未处理'), + ], $page, $limit); + $records = Collection::make($result['data']); + $total = (int) $result['total']; + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + $panels[$slug] = [ + 'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [ + 'path' => $request->url(), + 'query' => array_merge($request->query(), ['platform' => (string) $slug]), + ]), + 'total' => $total, + 'stats' => $stats[$slug] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0], + ]; + } + + return [ + 'view' => 'business.pages.financial-risk-control.preview-statistics', + 'storeViolationPlatforms' => $platforms, + 'storeViolationActivePlatform' => $activePlatform, + 'storeViolationPlatformCounts' => $counts, + 'storeViolationPanels' => $panels, + 'storeViolationError' => $error, + ]; + } + + /** 店铺违规信息预警:行内更新处理状态(已忽略必填原因)。 */ + public function updateStoreViolation(Request $request) + { + $data = $request->validate([ + 'id' => ['required', 'integer'], + 'handling_status' => ['required', 'string', \Illuminate\Validation\Rule::in((array) config('store_violations.handling_statuses', []))], + 'ignore_reason' => ['nullable', 'string', 'max:191'], + 'points_deducted' => ['nullable', 'numeric', 'min:0', 'max:9999'], + 'penalty_date' => ['nullable', 'date_format:Y-m-d'], + ]); + + $ignoreReason = trim((string) ($data['ignore_reason'] ?? '')); + if ($data['handling_status'] === '已忽略' && $ignoreReason === '') { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'ignore_reason' => '已忽略必须填写忽略原因。', + ]); + } + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + abort_if($repository->findById((int) $data['id']) === null, 404); + + $updates = [ + 'handling_status' => $data['handling_status'], + 'ignore_reason' => $data['handling_status'] === '已忽略' ? $ignoreReason : null, + 'updated_at' => now(), + ]; + if ($request->has('points_deducted')) { + $updates['points_deducted'] = $data['points_deducted'] ?? null; + } + if ($request->has('penalty_date')) { + $updates['penalty_date'] = $data['penalty_date'] ?? null; + } + + $repository->updateFields((int) $data['id'], $updates); + + return back(302, [], route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ]))->with('success', '处理状态已更新。'); + } + + /** 店铺违规信息预警:群消息「去认领」签名链接,幂等置为处理中。 */ + public function claimStoreViolation(Request $request, int $record) + { + abort_unless($request->hasValidSignature(), 403, '链接已过期,请从最新的提醒消息进入。'); + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + $row = $repository->findById($record); + abort_if($row === null, 404); + + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + if (trim((string) ($row->handling_status ?? '')) === $defaultStatus) { + $repository->updateFields($record, [ + 'handling_status' => '处理中', + 'updated_at' => now(), + ]); + } + + return redirect() + ->route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => (string) ($row->platform ?? ''), + 'keyword' => (string) ($row->violation_no ?? ''), + ]) + ->with('success', '已认领该违规记录,处理状态已更新为“处理中”。'); } /** 店铺违规信息预警:按当前筛选导出统一表头(Excel)。 */ @@ -3383,13 +3556,22 @@ class Pages extends Controller if ($key === 'points_deducted' && $value !== null) { $value = (float) $value; } + if ($key === 'alert_level' && $value !== null) { + $value = (string) (config('store_violations.alert_levels.' . $value . '.label') ?? $value); + } + if ($key === 'is_overdue') { + $value = StoreViolationAlertCalculator::isOverdue( + $row->handling_status ?? null, + $row->handling_deadline ?? null, + ) ? '已超时' : '未超时'; + } $values[] = $value === null ? '' : $value; } $sheet->fromArray($values, null, 'A' . $rowNumber); $rowNumber++; } - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3415,7 +3597,7 @@ class Pages extends Controller $headers = array_map(static fn (array $column): string => (string) $column['label'], $columns); $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray( - ['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'], + ['示例·某某旗舰店', 'P1-较重', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '示例·张三', '待处理', '', '2026-10-11', '2026-10-13 23:59:59', '未超时', '未推送', '未处理', ''], null, 'A2', ); @@ -3426,7 +3608,7 @@ class Pages extends Controller 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(24); - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3439,12 +3621,18 @@ class Pages extends Controller ['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'], ['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'], ['违规分类', '选填。留空时按违规类型关键词自动归类。'], + ['预警级别', '自动计算。按「平台+违规类型」匹配违规字典;可留空,导入时自动生成。'], + ['责任人', '自动计算。按店铺名称关联店铺运营负责人;可留空,导入时自动生成。'], ['违规原因', '选填。平台展示的违规原因/详细说明。'], ['处置方式', '选填。平台对违规的处置/处理方式。'], ['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'], ['扣分', '选填。数字,例如 2 或 0.5。'], ['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'], - ['处理状态', '选填。可选值:' . $handling . ';留空按“未处理”写入。'], + ['处理截止', '自动计算。申诉截止日期非空时取其当天 23:59:59;否则按级别(P0+1 / P1+2 / P2+3 天)计算。'], + ['是否超时', '自动计算。处理状态非“处理完成/已忽略”且已过处理截止时显示“已超时”。'], + ['推送状态', '系统回写。导入时忽略该列。'], + ['处理状态', '选填。可选值:' . $handling . ';选“已忽略”时必须填写忽略原因;留空按“未处理”写入。'], + ['忽略原因', '选填。处理状态为“已忽略”时必填。'], ['平台', '无需填写。导入时按当前页签决定平台。'], ['提示', '请保留第一行表头;示例行可直接替换为实际数据。'], ], null, 'A1'); @@ -3454,7 +3642,7 @@ class Pages extends Controller ]); $notes->getColumnDimension('A')->setWidth(34); $notes->getColumnDimension('B')->setWidth(72); - $notes->getStyle('B1:B13')->getAlignment()->setWrapText(true); + $notes->getStyle('B1:B19')->getAlignment()->setWrapText(true); (new Xlsx($spreadsheet))->save('php://output'); }, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [ diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index e5f02725..5edcaa50 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -149,25 +149,30 @@ class SidebarPermissions } unset($section); - // 「财务经营风险管控」新增「流程统计」子页面:按页面键保存过精确权限的账号只会 - // 有 store-violation-alert,会看不到新页面;这里按「能看店铺违规预警 就能看流程统计」 - // 兼容旧配置(两页同属财务经营风险管控权限)。 + // 「财务经营风险管控」新增「预览统计 / 流程统计」子页面:按页面键保存过精确权限的 + // 账号只会有一条或两条,会看不到新页面;这里按「能看店铺违规预警 就能看新增页」 + // 兼容旧配置(同属财务经营风险管控权限)。 foreach ($navigation as &$riskSection) { if (($riskSection['slug'] ?? null) !== 'financial-risk-control') { continue; } $pageSlugs = array_column($riskSection['pages'] ?? [], 'slug'); - if (! in_array('store-violation-alert', $pageSlugs, true) || in_array('process-statistics', $pageSlugs, true)) { + if (! in_array('store-violation-alert', $pageSlugs, true)) { continue; } $configuredPages = collect(BusinessNavigation::all()) ->firstWhere('slug', 'financial-risk-control')['pages'] ?? []; - $statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics'); - if ($statisticsPage) { - $riskSection['pages'][] = $statisticsPage; + foreach ($configuredPages as $configuredPage) { + $configuredSlug = (string) ($configuredPage['slug'] ?? ''); + if (! in_array($configuredSlug, ['preview-statistics', 'process-statistics'], true)) { + continue; + } + if (! in_array($configuredSlug, $pageSlugs, true)) { + $riskSection['pages'][] = $configuredPage; + } } } unset($riskSection); diff --git a/config/business_navigation.php b/config/business_navigation.php index c1bdf4d0..8dfb0429 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -871,6 +871,7 @@ $navigation = [ 'icon' => 'security', 'pages' => [ ['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'], + ['slug' => 'preview-statistics', 'title' => '预览统计'], [ 'slug' => 'process-statistics', 'title' => '流程统计', diff --git a/config/store_violations.php b/config/store_violations.php index b4e647e7..b15153a6 100644 --- a/config/store_violations.php +++ b/config/store_violations.php @@ -12,8 +12,10 @@ return [ // 统一表头(顺序即页面、导出、导入模板的列顺序) + // auto=自动计算列(导入忽略、导出可见);virtual=查询时计算;system=系统回写(导入忽略)。 'columns' => [ ['key' => 'shop_name', 'label' => '店铺名'], + ['key' => 'alert_level', 'label' => '预警级别', 'auto' => true], ['key' => 'violation_type', 'label' => '违规类型'], ['key' => 'violation_category', 'label' => '违规分类'], ['key' => 'violation_reason', 'label' => '违规原因'], @@ -21,10 +23,15 @@ return [ ['key' => 'violation_no', 'label' => '违规单号'], ['key' => 'violation_date', 'label' => '违规日期'], ['key' => 'points_deducted', 'label' => '扣分'], + ['key' => 'owner_name', 'label' => '责任人', 'auto' => true], ['key' => 'status', 'label' => '状态'], ['key' => 'penalty_date', 'label' => '处罚日期'], ['key' => 'appeal_deadline', 'label' => '申诉截止日期'], + ['key' => 'handling_deadline', 'label' => '处理截止', 'auto' => true], + ['key' => 'is_overdue', 'label' => '是否超时', 'virtual' => true], + ['key' => 'push_status', 'label' => '推送状态', 'system' => true], ['key' => 'handling_status', 'label' => '处理状态'], + ['key' => 'ignore_reason', 'label' => '忽略原因'], ], // 页面平台页签;source_values 为上游/导入文件中平台字段的取值 @@ -38,8 +45,37 @@ return [ ], // 处理状态(本地维护) - 'handling_statuses' => ['未处理', '处理中', '已处理', '无需处理'], + 'handling_statuses' => ['未处理', '处理中', '处理完成', '已忽略'], 'default_handling_status' => '未处理', + // 历史取值兼容:导入时归一化到新四档 + 'handling_status_legacy' => ['已处理' => '处理完成', '无需处理' => '已忽略'], + + // 预警级别(页面标签颜色)与处理时效默认(违规日期起算的天数,当天 23:59:59 到期) + 'alert_levels' => [ + 'P0' => ['label' => 'P0-严重', 'tone' => 'is-danger'], + 'P1' => ['label' => 'P1-较重', 'tone' => 'is-warning'], + 'P2' => ['label' => 'P2-提醒', 'tone' => 'is-success'], + ], + 'alert_default_days' => ['P0' => 1, 'P1' => 2, 'P2' => 3], + + // 推送状态 + 'push_statuses' => ['未推送', '已推送', '推送失败'], + + // 默认视图:只展示需提醒且未处理的记录,其余数据只入库不打扰 + 'default_view' => ['needs_alert' => true, 'handling_status' => '未处理'], + + // 群机器人推送(先留接口,配置后启用;窗口从每天 09:00 开始) + 'push' => [ + 'enabled' => false, + 'channel' => 'dingtalk_robot', + 'webhook' => '', + 'secret' => '', + 'window_start' => '09:00', + 'window_end' => '21:00', + 'instant_levels' => ['P0', 'P1'], + 'digest_time' => '10:00', + 'at_all' => false, + ], // 导入表头别名 → 统一列键(不区分大小写、忽略空格) 'header_aliases' => [ @@ -48,6 +84,12 @@ return [ 'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'], 'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'], 'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'], + 'alert_level' => ['预警级别', '预警等级', 'alert_level'], + 'owner_name' => ['责任人', '店铺负责人', '运营负责人', 'owner_name'], + 'handling_deadline' => ['处理截止', '处理截止时间', '处理时限', 'handling_deadline'], + 'is_overdue' => ['是否超时', '是否逾期', 'is_overdue'], + 'push_status' => ['推送状态', 'push_status'], + 'ignore_reason' => ['忽略原因', '忽略理由', 'ignore_reason'], 'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'], 'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'], 'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'], diff --git a/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php new file mode 100644 index 00000000..d6125d21 --- /dev/null +++ b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php @@ -0,0 +1,104 @@ +connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + $columns = [ + 'owner_name' => fn (Blueprint $t) => $t->string('owner_name', 64)->nullable()->after('shop_name'), + 'alert_level' => fn (Blueprint $t) => $t->string('alert_level', 8)->nullable()->after('violation_category'), + 'needs_alert' => fn (Blueprint $t) => $t->boolean('needs_alert')->default(false)->after('alert_level'), + 'alert_rule_id' => fn (Blueprint $t) => $t->unsignedBigInteger('alert_rule_id')->nullable()->after('needs_alert'), + 'alert_action' => fn (Blueprint $t) => $t->string('alert_action', 500)->nullable(), + 'alert_channel' => fn (Blueprint $t) => $t->string('alert_channel', 64)->nullable(), + 'handling_hours' => fn (Blueprint $t) => $t->unsignedInteger('handling_hours')->nullable(), + 'handling_deadline' => fn (Blueprint $t) => $t->dateTime('handling_deadline')->nullable()->after('appeal_deadline'), + 'ignore_reason' => fn (Blueprint $t) => $t->string('ignore_reason', 191)->nullable()->after('handling_status'), + 'push_status' => fn (Blueprint $t) => $t->string('push_status', 16)->default('未推送'), + 'last_pushed_at' => fn (Blueprint $t) => $t->dateTime('last_pushed_at')->nullable(), + ]; + + $missing = []; + foreach ($columns as $column => $definition) { + if (! $schema->hasColumn($table, $column)) { + $missing[$column] = $definition; + } + } + + if ($missing !== []) { + $schema->table($table, function (Blueprint $blueprint) use ($missing): void { + foreach ($missing as $definition) { + $definition($blueprint); + } + }); + } + + $indexes = [ + 'store_violations_platform_alert_status_index' => ['platform', 'needs_alert', 'handling_status'], + 'store_violations_alert_deadline_index' => ['needs_alert', 'handling_status', 'handling_deadline'], + 'store_violations_platform_owner_index' => ['platform', 'owner_name'], + 'store_violations_push_level_index' => ['push_status', 'alert_level'], + ]; + + foreach ($indexes as $name => $indexColumns) { + if (! $schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($indexColumns, $name): void { + $blueprint->index($indexColumns, $name); + }); + } + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + foreach ([ + 'store_violations_platform_alert_status_index', + 'store_violations_alert_deadline_index', + 'store_violations_platform_owner_index', + 'store_violations_push_level_index', + ] as $name) { + if ($schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($name): void { + $blueprint->dropIndex($name); + }); + } + } + + foreach ([ + 'owner_name', 'alert_level', 'needs_alert', 'alert_rule_id', 'alert_action', 'alert_channel', + 'handling_hours', 'handling_deadline', 'ignore_reason', 'push_status', 'last_pushed_at', + ] as $column) { + if ($schema->hasColumn($table, $column)) { + $schema->table($table, function (Blueprint $blueprint) use ($column): void { + $blueprint->dropColumn($column); + }); + } + } + } +}; diff --git a/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php new file mode 100644 index 00000000..ab5b7fde --- /dev/null +++ b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php @@ -0,0 +1,49 @@ +connection()); + + if ($schema->hasTable('store_violation_rules')) { + return; + } + + $schema->create('store_violation_rules', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('platform', 32)->comment('平台 slug:taobao/alibaba/jd/jingxi/pinduoduo/douyin'); + $table->string('violation_category', 64)->nullable()->comment('字典分类,命中后可用于校准记录分类'); + $table->string('violation_type', 500)->nullable()->comment('匹配用类型:清洗后相等或前缀匹配'); + $table->string('match_keywords', 500)->nullable()->comment('补充关键词,|、/、顿号分隔,包含任一即命中'); + $table->string('alert_level', 8)->nullable()->comment('P0/P1/P2;为空时命中后按 P2 处理'); + $table->boolean('needs_alert')->default(true)->comment('是否需要提醒;否=只入库不推送'); + $table->string('suggested_action', 500)->nullable()->comment('建议处理动作'); + $table->unsignedInteger('handling_hours')->nullable()->comment('处理时效(小时);为空时按级别默认'); + $table->string('remind_channel', 191)->nullable()->comment('提醒方式'); + $table->unsignedInteger('sort')->default(0)->comment('匹配顺序,越小越先匹配'); + $table->boolean('enabled')->default(true); + $table->string('remark', 191)->nullable(); + $table->timestamps(); + + $table->index(['platform', 'enabled', 'sort'], 'store_violation_rules_match_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_rules'); + } +}; diff --git a/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php new file mode 100644 index 00000000..4f1f5b16 --- /dev/null +++ b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php @@ -0,0 +1,48 @@ +connection()); + + if ($schema->hasTable('store_violation_push_logs')) { + return; + } + + $schema->create('store_violation_push_logs', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('push_no', 32)->unique('store_violation_push_logs_no_unique'); + $table->dateTime('pushed_at')->nullable(); + $table->string('channel', 32)->default('dingtalk_robot'); + $table->string('message_type', 16)->default('instant')->comment('instant/daily'); + $table->string('platform', 32)->nullable(); + $table->string('violation_no', 100)->nullable(); + $table->string('message_digest', 500)->nullable(); + $table->string('mentioned', 191)->nullable(); + $table->string('result', 16)->default('success')->comment('success/failed'); + $table->string('error', 500)->nullable(); + $table->timestamps(); + + $table->index(['violation_no', 'message_type'], 'store_violation_push_logs_no_type_index'); + $table->index('pushed_at', 'store_violation_push_logs_pushed_at_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_push_logs'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 3ad13a1f..abe0128c 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -199,6 +199,9 @@ CRON_TZ=Asia/Shanghai 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 110b1445..fc040f90 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -203,6 +203,9 @@ CRON_TZ=Asia/Shanghai 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php new file mode 100644 index 00000000..d6f4b6b8 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php @@ -0,0 +1,128 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + /** + * 即时推送候选:需提醒 + 未处理 + 未推送 + 指定级别。 + * + * @param list $levels + * @return Collection + */ + public function instantCandidates(array $levels, bool $retryFailed, int $limit): Collection + { + return $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->whereIn('alert_level', $levels) + ->where(function ($query) use ($retryFailed): void { + $query->where('push_status', '未推送'); + if ($retryFailed) { + $query->orWhere('push_status', '推送失败'); + } + }) + ->orderByRaw("CASE alert_level WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 ELSE 2 END") + ->orderBy('id') + ->limit(max($limit, 1)) + ->get(); + } + + /** + * 每日汇总取数:各级别待处理数量与超时未认领记录。 + * + * @return array{pending:array,overdue:Collection,p2_pending:Collection} + */ + public function digestData(int $overdueLimit = 20): array + { + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + + $pending = []; + foreach ($this->connection->table(StoreViolationRecordRepository::TABLE) + ->selectRaw('alert_level, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', $defaultStatus) + ->groupBy('alert_level') + ->get() as $row) { + $pending[(string) $row->alert_level] = (int) $row->aggregate; + } + + $overdue = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('alert_level', array_keys((array) config('store_violations.alert_levels', []))) + ->whereNotIn('handling_status', StoreViolationRecordRepository::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', Carbon::now()) + ->where('needs_alert', true) + ->orderBy('handling_deadline') + ->limit(max($overdueLimit, 1)) + ->get(['id', 'platform', 'shop_name', 'owner_name', 'alert_level', 'violation_type', 'handling_deadline']); + + $p2 = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('alert_level', 'P2') + ->where('handling_status', $defaultStatus) + ->where('push_status', '未推送') + ->orderBy('id') + ->get(['id']); + + return ['pending' => $pending, 'overdue' => $overdue, 'p2_pending' => $p2]; + } + + /** @param list $ids */ + public function markPushed(array $ids, Carbon $at): void + { + if ($ids === []) { + return; + } + + foreach (array_chunk($ids, 500) as $chunk) { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('id', $chunk) + ->update(['push_status' => '已推送', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + } + + public function markPushFailed(int $id, Carbon $at): void + { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('id', $id) + ->update(['push_status' => '推送失败', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + + /** @param array $entry */ + public function log(array $entry): void + { + if (! $this->available()) { + return; + } + + $now = now(); + $this->connection->table(self::LOG_TABLE)->insert($entry + [ + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function available(): bool + { + return \Illuminate\Support\Facades\Schema::connection($this->connection->getName()) + ->hasTable(self::LOG_TABLE); + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php index 987285ba..458d4f5e 100644 --- a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php @@ -36,6 +36,21 @@ class StoreViolationRecordRepository 'updated_at', ]; + /** 预警派生字段(同步/导入/重算时自动计算;本地工作流字段不受影响) */ + public const ALERT_DERIVED_COLUMNS = [ + 'needs_alert', + 'alert_level', + 'alert_rule_id', + 'alert_action', + 'alert_channel', + 'handling_hours', + 'owner_name', + 'handling_deadline', + ]; + + /** 视为已终结的处理状态(不再计入超时) */ + public const FINAL_HANDLING_STATUSES = ['处理完成', '已忽略', '已处理', '无需处理']; + private Connection $connection; public function __construct() @@ -68,6 +83,25 @@ class StoreViolationRecordRepository return $this->available() ? (int) $this->connection->table(self::TABLE)->count() : 0; } + public function findById(int $id): ?object + { + if (! $this->available() || $id <= 0) { + return null; + } + + return $this->connection->table(self::TABLE)->where('id', $id)->first(); + } + + /** @param array $fields */ + public function updateFields(int $id, array $fields): void + { + if (! $this->available() || $fields === []) { + return; + } + + $this->connection->table(self::TABLE)->where('id', $id)->update($fields); + } + /** @return array{code:string,last_succeeded_at:?string,row_count:int,last_error:?string} */ public function syncStatus(): array { @@ -152,14 +186,41 @@ class StoreViolationRecordRepository $query->whereDate('violation_date', '<=', $filters['date_to']); } + if (($filters['needs_alert'] ?? null) !== null && $filters['needs_alert'] !== '') { + $query->where('needs_alert', (bool) $filters['needs_alert']); + } + + $level = trim((string) ($filters['level'] ?? '')); + if ($level !== '') { + $query->where('alert_level', $level); + } + + $owner = trim((string) ($filters['owner'] ?? '')); + if ($owner !== '') { + $query->where('owner_name', $owner); + } + + if (! empty($filters['overdue'])) { + $query->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()); + } + return $query; } - /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list} */ + /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list,owners:list,levels:list} */ public function filterOptions(string $platform): array { if (! $this->available()) { - return ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + return [ + 'statuses' => [], + 'handling_statuses' => [], + 'categories' => [], + 'shops' => [], + 'owners' => [], + 'levels' => [], + ]; } $distinct = fn (string $column): array => $this->connection->table(self::TABLE) @@ -176,6 +237,17 @@ class StoreViolationRecordRepository 'statuses' => $distinct('status'), 'handling_statuses' => $distinct('handling_status'), 'categories' => $distinct('violation_category'), + 'owners' => $this->connection->table(self::TABLE) + ->where('platform', $platform) + ->whereNotNull('owner_name') + ->where('owner_name', '<>', '') + ->orderBy('owner_name') + ->distinct() + ->limit(500) + ->pluck('owner_name') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + 'levels' => array_keys((array) config('store_violations.alert_levels', [])), 'shops' => $this->connection->table(self::TABLE) ->where('platform', $platform) ->where('shop_name', '<>', '') @@ -188,6 +260,70 @@ class StoreViolationRecordRepository ]; } + /** + * 预览统计:各平台 4 个指标(今日新增 / 待处理 / P0未处理 / 已超时)。 + * + * @return array + */ + public function statsByPlatform(): array + { + $result = []; + if (! $this->available()) { + return $result; + } + + $ensure = static function (array &$result, string $platform): void { + $result[$platform] ??= ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + }; + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('created_at', '>=', now()->startOfDay()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['new_today'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('alert_level', 'P0') + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['p0_pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['overdue'] = (int) $row->aggregate; + } + + return $result; + } + /** * 统一表头导出用的行数据(流式读取,避免一次性载入)。 * diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php new file mode 100644 index 00000000..c80d952f --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php @@ -0,0 +1,58 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } + + /** @return list> 启用中的规则,按 sort、id 升序 */ + public function activeRules(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->where('enabled', true) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } + + /** @return list> 全部规则(后台维护用),按 sort、id 升序 */ + public function all(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } +} diff --git a/resources/views/business/pages/financial-risk-control/index.blade.php b/resources/views/business/pages/financial-risk-control/index.blade.php index ea7b6dce..ca3c1156 100644 --- a/resources/views/business/pages/financial-risk-control/index.blade.php +++ b/resources/views/business/pages/financial-risk-control/index.blade.php @@ -3,20 +3,29 @@ $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); $panels = $storeViolationPanels ?? []; $filters = $storeViolationFilters ?? []; - $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []]; $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); $sync = $storeViolationSync ?? ['code' => 'pending', 'last_succeeded_at' => null, 'row_count' => 0, 'last_error' => null]; $error = $storeViolationError ?? null; $preview = $storeViolationPreview ?? null; $columns = config('store_violations.columns', []); $handlingStatuses = config('store_violations.handling_statuses', []); - $hasFilters = collect($filters)->contains(static fn ($value): bool => is_string($value) && $value !== ''); + $alertLevels = config('store_violations.alert_levels', []); + $defaultView = ($filters['view'] ?? 'default') !== 'all'; + $hasFilters = ($filters['has_explicit'] ?? '') === '1'; $handlingTone = static fn (string $status): string => match ($status) { - '已处理' => 'is-success', + '处理完成', '已处理' => 'is-success', '处理中' => 'is-info', - '无需处理' => 'is-muted', + '已忽略', '无需处理' => 'is-muted', default => 'is-warning', }; + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; $previewTone = static fn (string $status): string => match ($status) { 'new' => 'is-success', 'update' => 'is-info', @@ -334,7 +343,84 @@ } .financial-risk-control__table { - min-width: 78rem; + min-width: 108rem; + } + + .financial-risk-control__viewbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; + font-size: 0.75rem; + color: var(--vip-content-muted, #595959); + } + + .financial-risk-control__viewbar-label { + font-weight: 600; + } + + .financial-risk-control__viewbar .upstream-data-button.is-active { + border-color: var(--vip-content-accent, #B9954F); + background: var(--vip-content-accent-soft, #F7F0DE); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + } + + .financial-risk-control__row-actions button { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + background: #FFFFFF; + color: var(--vip-content-ink, #171717); + cursor: pointer; + font-size: 0.75rem; + white-space: nowrap; + } + + .financial-risk-control__row-actions button:hover { + border-color: var(--vip-content-accent, #B9954F); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__ignore summary { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.75rem; + list-style: none; + white-space: nowrap; + } + + .financial-risk-control__ignore summary::-webkit-details-marker { + display: none; + } + + .financial-risk-control__ignore-body { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; + } + + .financial-risk-control__ignore-body input { + width: 8rem; + padding: 0.125rem 0.375rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + font-size: 0.75rem; + } + + .financial-risk-control__muted-note { + margin-top: 0.125rem; + color: var(--vip-content-muted, #595959); + font-size: 0.6875rem; } .financial-risk-control__table td.is-number { @@ -444,8 +530,15 @@ 当前筛选 {{ number_format($total) }} 条{{ $hasFilters ? '(已启用筛选条件)' : '' }}。

+ +
+
@@ -473,6 +566,32 @@ @endforeach
+
+ + +
+
+ + +
+
+ + +
+ @@ -548,17 +668,32 @@ + + + + + @forelse (($rows ?? collect()) as $index => $record) + @php + $overdue = \App\Business\StoreViolationAlertCalculator::isOverdue($record->handling_status ?? null, $record->handling_deadline ?? null); + @endphp + @@ -566,18 +701,48 @@ + + + + + @empty -
{{ $displayValue(data_get($row, $column['key']), $column['key']) }}
店铺名 违规类型 违规分类违规原因处置方式 违规单号 违规日期 扣分{{ $record->shop_name ?: '-' }} {{ \Illuminate\Support\Str::limit((string) $record->violation_type, 40, '…') ?: '-' }} {{ $record->violation_category ?: '-' }}{{ \Illuminate\Support\Str::limit((string) $record->violation_reason, 40, '…') ?: '-' }}{{ \Illuminate\Support\Str::limit((string) $record->disposition, 30, '…') ?: '-' }} {{ $record->violation_no ?: '-' }} {{ $record->violation_date ?: '-' }} {{ $record->points_deducted === null ? '-' : rtrim(rtrim(number_format((float) $record->points_deducted, 2, '.', ''), '0'), '.') }}
+ @if ($error) {{ $error }} @elseif ($platformTotal === 0) diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index 18fac931..27e9a39e 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -37,6 +37,8 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->string('shop_name', 191)->default(''); $table->string('violation_type', 500)->nullable(); $table->string('violation_category', 64)->nullable(); + $table->string('violation_reason', 500)->nullable(); + $table->string('disposition', 500)->nullable(); $table->string('violation_no', 100); $table->date('violation_date')->nullable(); $table->decimal('points_deducted', 10, 2)->nullable(); @@ -69,21 +71,24 @@ class StoreViolationAlertPageTest extends FeatureTestCase DB::connection('business_mirror')->table('store_violation_records')->insert([ [ 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'violation_type' => '延迟发货/缺货无货', - 'violation_category' => '发货履约', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04', + 'violation_category' => '发货履约', 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', + 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => 2, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => '2026-10-11', 'handling_status' => '未处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'violation_type' => '订单存在揽收超时行为', - 'violation_category' => '发货履约', 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03', + 'violation_category' => '发货履约', 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, + 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03', 'points_deducted' => null, 'status' => '超期未申诉', 'penalty_date' => '2026-10-05', 'appeal_deadline' => '2026-10-08', 'handling_status' => '处理中', 'source' => 'crawl', 'crawl_date' => '2026-10-04', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'violation_type' => '发票违规', - 'violation_category' => '发票与考核', 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04', + 'violation_category' => '发票与考核', 'violation_reason' => null, 'disposition' => '维持原判', + 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => null, 'status' => '已处置', 'penalty_date' => null, 'appeal_deadline' => null, 'handling_status' => '已处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', @@ -117,11 +122,14 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertSee('店铺名') ->assertSee('违规类型') ->assertSee('违规分类') + ->assertSee('违规原因') + ->assertSee('处置方式') ->assertSee('违规单号') ->assertSee('违规日期') ->assertSee('扣分') ->assertSee('申诉截止日期') ->assertSee('处理状态') + ->assertSee('订单存在延迟发货、缺货无货') ->assertSee('JD202610040001') ->assertSee('京东-亿家达全屋旗舰店') ->assertSee('data-platform-panel="jingxi"', false); @@ -166,13 +174,15 @@ class StoreViolationAlertPageTest extends FeatureTestCase file_put_contents($temp, $binary); $sheet = IOFactory::load($temp)->getActiveSheet(); - $headers = $sheet->rangeToArray('A1:J1')[0]; + $headers = $sheet->rangeToArray('A1:L1')[0]; $this->assertSame( - ['店铺名', '违规类型', '违规分类', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'], + ['店铺名', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'], $headers, ); - $this->assertSame('JD202610040001', $sheet->getCell('D2')->getValue()); + $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('D2')->getValue()); + $this->assertSame('警告', $sheet->getCell('E2')->getValue()); + $this->assertSame('JD202610040001', $sheet->getCell('F2')->getValue()); unlink($temp); } @@ -237,6 +247,43 @@ class StoreViolationAlertPageTest extends FeatureTestCase $response->assertSessionHasErrors('import'); } + public function test_import_maps_reason_and_disposition_columns(): void + { + $this->allowPage(); + + $file = $this->makeWorkbook([ + ['店铺名', '违规单号', '违规原因', '处置方式'], + ['京东-纪辰家具旗舰店', 'JD202610040088', '订单存在揽收超时行为', '警告;整改'], + ]); + + $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ + 'platform' => 'jd', + 'import' => $file, + ]); + + $preview->assertRedirect(); + $payload = $preview->getSession()->get('store_violation_preview'); + $this->assertIsArray($payload); + $this->assertSame(1, $payload['summary']['new']); + + $commit = $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ + 'preview_token' => $payload['token'], + ]); + $commit->assertRedirect(); + + $record = DB::connection('business_mirror') + ->table('store_violation_records') + ->where('platform', 'jd') + ->where('violation_no', 'JD202610040088') + ->first(); + + $this->assertNotNull($record); + $this->assertSame('订单存在揽收超时行为', $record->violation_reason); + $this->assertSame('警告;整改', $record->disposition); + // 未提供违规类型时,违规分类按违规原因关键词自动归类。 + $this->assertSame('发货履约', $record->violation_category); + } + /** @param list> $rows */ private function makeWorkbook(array $rows): UploadedFile { diff --git a/tests/Unit/Business/StoreViolationMapperTest.php b/tests/Unit/Business/StoreViolationMapperTest.php index e654ef30..e165fd45 100644 --- a/tests/Unit/Business/StoreViolationMapperTest.php +++ b/tests/Unit/Business/StoreViolationMapperTest.php @@ -71,6 +71,33 @@ class StoreViolationMapperTest extends TestCase $this->assertNull($this->mapper->normalizePoints('不扣分')); } + public function test_map_crawl_row_includes_reason_and_disposition_when_upstream_provides_them(): void + { + $base = [ + 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', + 'violation_no' => '86522327', + 'violation_date' => '2026-09-30', + 'deadline' => '2026-10-07', + 'violation_content' => '订单存在揽收超时行为', + 'status' => '待处理', + 'window_name' => '京喜-久伴吾家具京喜自营工厂店_商家后台', + 'platform' => '京喜', + 'crawl_date' => '2026-10-04', + ]; + + $mapped = $this->mapper->mapCrawlRow($base + [ + 'violation_reason' => '订单存在揽收超时行为', + 'disposition' => '警告;扣2分', + ]); + $this->assertSame('订单存在揽收超时行为', $mapped['violation_reason']); + $this->assertSame('警告;扣2分', $mapped['disposition']); + + // 上游尚未提供两列时保持 null,避免同步误覆盖导入补充的数据。 + $legacy = $this->mapper->mapCrawlRow($base); + $this->assertNull($legacy['violation_reason']); + $this->assertNull($legacy['disposition']); + } + public function test_unrecognized_points_throw(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Console/StoreViolationsSyncExtraColumnsTest.php b/tests/Unit/Console/StoreViolationsSyncExtraColumnsTest.php new file mode 100644 index 00000000..3f301800 --- /dev/null +++ b/tests/Unit/Console/StoreViolationsSyncExtraColumnsTest.php @@ -0,0 +1,33 @@ +assertSame([], SyncStoreViolations::pickExtraColumns([ + 'id', 'shop_name', 'violation_no', 'violation_date', 'deadline', + 'violation_content', 'status', 'window_name', 'platform', 'crawl_date', + ])); + } + + public function test_picks_exact_and_alternative_column_names_case_insensitively(): void + { + $this->assertSame( + ['violation_reason' => 'violation_reason', 'disposition' => 'disposition'], + SyncStoreViolations::pickExtraColumns(['id', 'violation_reason', 'disposition']), + ); + + $this->assertSame( + ['violation_reason' => 'reason', 'disposition' => 'handling_method'], + SyncStoreViolations::pickExtraColumns(['Reason', 'HANDLING_METHOD']), + ); + } +} From 6b6b97a375724aaeda2e6c9dbd0f20727b5737e4 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 11:32:28 +0800 Subject: [PATCH 15/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E7=AD=9B=E9=80=89=E5=8C=BA=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=E3=80=81=E8=A1=A8=E6=A0=BC=E5=8C=BA=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...payable-cloud-warehouse-vehicle-fee.blade.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index eded0d9e..0312fcd3 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -19,19 +19,21 @@
From 7223a2eb2ea3d1cf9ce92f6b318a4880d14e35a9 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 11:53:43 +0800 Subject: [PATCH 16/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E8=A1=A8=E6=A0=BC=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8C=BA=E5=A1=AB=E6=BB=A1=E5=88=B0=E5=BA=95=E9=83=A8=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payable-cloud-warehouse-vehicle-fee.blade.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index 0312fcd3..3144eccc 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -33,7 +33,11 @@ .ding-payable-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; } .ding-payable-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); } .ding-payable-meta { display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-bottom:.5rem; color:var(--vip-content-muted,#595959); font-size:.75rem; } - .ding-payable-page .ding-payable-table-wrap { max-height:min(calc(100vh - 16.5rem), 46rem); } + /* 表格区填满整块区域:提高高度上限,并回收本页外层容器的底部留白。 */ + .my-6:has(.ding-payable-page) { margin-bottom: .4rem; } + .business-content-section:has(.ding-payable-page) { padding-bottom: .4rem; } + .container:has(.ding-payable-page) { margin-bottom: 0; padding-bottom: 0; } + .ding-payable-page .ding-payable-table-wrap { max-height:min(calc(100vh - 12rem), 46rem); } .ding-payable-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; } .ding-payable-table { width:100%; min-width:125rem; border-collapse:separate; border-spacing:0; font-size:.75rem; } .ding-payable-table th,.ding-payable-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; } From 0fa52090f78e9059e9584e105aeddb6a2bb5894d Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 12:10:58 +0800 Subject: [PATCH 17/44] =?UTF-8?q?=E9=80=80=E8=B4=A7=E5=8F=96=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=9A=E5=90=8C=E6=AD=A5=E6=9D=A1=E4=B8=8E?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=8C=BA=E6=94=B6=E7=B4=A7=E3=80=81=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=8C=BA=E6=94=BE=E5=A4=A7=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../return-pickup-management.blade.php | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/resources/views/business/pages/courier-payables/return-pickup-management.blade.php b/resources/views/business/pages/courier-payables/return-pickup-management.blade.php index d23c299c..9ffdcfbc 100644 --- a/resources/views/business/pages/courier-payables/return-pickup-management.blade.php +++ b/resources/views/business/pages/courier-payables/return-pickup-management.blade.php @@ -66,7 +66,7 @@ color: var(--vip-content-ink, #171717); font-family: JetBrains Mono, Microsoft YaHei, PingFang SC, monospace; font-size: 0.8125rem; - margin: 1.5rem 0; + margin: .5rem 0 .4rem; } .return-pickup-status { @@ -74,7 +74,7 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 14px; + padding: 6px 12px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; @@ -101,17 +101,17 @@ .return-pickup-status__meta { display: flex; flex-wrap: wrap; - gap: 4px 14px; + gap: 3px 12px; color: var(--vip-content-muted, #595959); - font-size: 0.75rem; + font-size: 0.7rem; } .return-pickup-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px 14px; - margin-top: 14px; - padding: 14px; + gap: 6px 10px; + margin-top: 8px; + padding: 8px 10px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; @@ -121,21 +121,21 @@ display: flex; min-width: 0; flex-direction: column; - gap: 4px; + gap: 3px; } .return-pickup-field label { color: var(--vip-content-muted, #595959); - font-size: 0.75rem; + font-size: 0.7rem; } .return-pickup-field input, .return-pickup-field select { - min-height: 34px; + min-height: 28px; border: 1px solid var(--vip-border, #d8d2c7); - border-radius: 8px; + border-radius: 6px; background: #fff; - padding: 0 10px; + padding: 0 8px; color: var(--vip-content-ink, #171717); font: inherit; outline: none; @@ -144,18 +144,18 @@ .return-pickup-actions { display: flex; align-items: flex-end; - gap: 8px; + gap: 6px; } .return-pickup-button { display: inline-flex; - min-height: 34px; + min-height: 28px; align-items: center; - gap: 6px; + gap: 5px; border: 1px solid var(--vip-border, #d8d2c7); - border-radius: 8px; + border-radius: 6px; background: #fff; - padding: 0 12px; + padding: 0 9px; color: var(--vip-content-ink, #171717); font: inherit; cursor: pointer; @@ -210,11 +210,16 @@ } .return-pickup-table-wrap--scroll { - max-height: max(16rem, min(56vh, 40rem)); + max-height: max(14rem, min(calc(100vh - 19.5rem), 44rem)); overflow-y: auto; scrollbar-gutter: stable; } + /* 列表区填满底部:回收本页外层容器留白(仅本页生效)。 */ + .my-6:has(.return-pickup-page) { margin-bottom: .4rem; } + .business-content-section:has(.return-pickup-page) { padding-bottom: .4rem; } + .container:has(.return-pickup-page) { margin-bottom: 0; padding-bottom: 0; } + .return-pickup-table-wrap--scroll thead th { position: sticky; top: 0; @@ -296,7 +301,7 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px; + padding: 5px 12px; border-top: 1px solid var(--vip-border, #d8d2c7); color: var(--vip-content-muted, #595959); font-size: 0.75rem; From b962c23e92996413c241ee89aaf5c1ed7f8d7b9d Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 13:13:14 +0800 Subject: [PATCH 18/44] =?UTF-8?q?=E9=99=84=E5=8A=A0=E8=B4=B9=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=9A=E5=AF=BC=E5=85=A5/=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=A4=B4=E6=94=B6=E7=B4=A7=E3=80=81=E7=BB=93=E6=9E=9C=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=B9=B6=E5=85=A5=E5=AF=BC=E5=85=A5=E8=A1=8C=E5=8F=B3?= =?UTF-8?q?=E4=BE=A7=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../courier-surcharge-settlement.blade.php | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 fc8c1304..1a55c0f9 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 @@ -141,18 +141,20 @@ .settlement-button--primary { border-color: var(--settlement-accent); background: var(--settlement-accent); color: #fff; } .settlement-button .material-icons-outlined { font-size: .95rem; } .settlement-import-bar { display: flex; flex-wrap: wrap; align-items: end; gap: .45rem .75rem; margin-top: .45rem; padding: .5rem .9rem; border: 1px solid var(--settlement-border); border-radius: .75rem; background: #fff; } - .settlement-import-bar .settlement-field { flex: 0 1 22rem; } + .settlement-import-bar .settlement-field { flex: 0 1 18rem; } .settlement-import-bar input[type="file"] { width: 100%; height: 1.9rem; padding: .25rem .5rem; border: 1px dashed var(--settlement-border); border-radius: .4rem; background: #fffdf8; color: var(--settlement-ink); font: inherit; } .settlement-import-bar .settlement-table-hint { align-self: center; flex-basis: 100%; } + .settlement-import-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .15rem .9rem; margin-left: auto; align-self: center; color: var(--settlement-muted); font-size: .7rem; } + .settlement-import-meta strong { color: var(--settlement-ink); } .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); } + .settlement-table-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .4rem .9rem; padding: .5rem .9rem; border-bottom: 1px solid var(--settlement-border); } + .settlement-table-head .finance-table-controls { margin: 0; padding: 0; border: 0; background: transparent; } .settlement-table-title { display: flex; align-items: center; gap: .45rem; font-size: .9rem; font-weight: 700; } .settlement-table-title .material-icons-outlined { color: var(--settlement-accent); font-size: 1.1rem; } - .settlement-table-hint { color: var(--settlement-muted); font-size: .72rem; } - .settlement-table-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem 1rem; padding: .65rem 1.15rem; border-bottom: 1px solid var(--settlement-border); background: #fffdf8; color: var(--settlement-muted); font-size: .72rem; } - .settlement-table-meta strong { color: var(--settlement-ink); } - .settlement-table-scroll { max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; } + .settlement-table-hint { color: var(--settlement-muted); font-size: .68rem; } + .settlement-table-scroll { overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable both-edges; } + .surcharge-settlement-page .settlement-table-scroll { max-height: max(14rem, min(calc(100vh - 12rem), 46rem)); } .settlement-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .75rem; } .settlement-table th, .settlement-table td { min-width: 7.5rem; padding: .62rem .7rem; border-right: 1px solid var(--settlement-border); border-bottom: 1px solid var(--settlement-border); text-align: left; vertical-align: top; white-space: nowrap; } @@ -246,12 +248,12 @@ 下载导入模板
- 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回导入金额与差异金额(系统金额 − 导入金额)。未生成结算行的运单用导入核验获取字段明细。 +
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
+ 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。
-
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整;点击行可高亮标记。
列设置按当前用户和本页面保存
-
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
+
附加费结算明细
快递名称和附加费类型固定显示,其余字段可在“列设置”中调整;点击行可高亮标记。
From 57f2dccfacaceb992b12c21178ee8707c7d01af5 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 13:26:59 +0800 Subject: [PATCH 19/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E4=BF=AE=E5=A4=8D=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=88=97=E4=B8=BA=E7=A9=BA=20(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payable-cloud-warehouse-vehicle-fee.blade.php | 2 +- .../Business/DingPayableWorkflowPageTest.php | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index 3144eccc..893ade32 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -124,7 +124,7 @@ - + @empty diff --git a/tests/Feature/Business/DingPayableWorkflowPageTest.php b/tests/Feature/Business/DingPayableWorkflowPageTest.php index a04fa8e0..29a3e174 100644 --- a/tests/Feature/Business/DingPayableWorkflowPageTest.php +++ b/tests/Feature/Business/DingPayableWorkflowPageTest.php @@ -28,7 +28,17 @@ class DingPayableWorkflowPageTest extends FeatureTestCase 'finishTime' => '2026-08-28 11:00:00', ], 'detail' => ['类型' => '快递运费', '用途' => '拉到江西仓的车费', '付款金额' => '1200'], - 'fields' => ['应付明细' => [], '付款明细' => [['付款金额' => '1200']], '附件' => []], + 'fields' => [ + '应付明细' => [], + '付款明细' => [['付款金额' => '1200']], + '附件' => '', + '未签字版合同附件或其他明细附件' => [[ + 'spaceId' => '369334649', + 'fileName' => '测试对账附件.xlsx', + 'fileId' => '123456', + 'fileType' => 'xlsx', + ]], + ], 'tasks' => [['status' => 'COMPLETED']], ]], 'total' => 1, @@ -50,6 +60,7 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('用途同时包含“仓”“车费”') ->assertSee('全部表单字段') ->assertSee('审批任务') + ->assertSee('测试对账附件.xlsx') ->assertSee('镜像状态:succeeded'); } From 7a5f04287c3ecada45600f61ff14f9acc21a503d Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 13:28:49 +0800 Subject: [PATCH 20/44] =?UTF-8?q?=E9=92=89=E9=92=89=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E5=85=A5=E5=8F=A3=EF=BC=9A=E5=BA=97=E9=93=BA=E8=BF=9D?= =?UTF-8?q?=E8=A7=84=E4=BF=A1=E6=81=AF=E9=A2=84=E8=AD=A6=E6=95=B4=E9=A1=B5?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=EF=BC=88=E9=9A=90=E8=97=8F=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 钉钉工作台里点进来的这个入口,现在只显示这一张页面:不再渲染全局侧边栏, 内容区占满宽度,其余页面和网页端登录完全不受影响。 判断依据:当前页面是 financial-risk-control/store-violation-alert,且请求来自 钉钉容器(UA 含 DingTalk)或显式带 ?focus=1;路由参数取不到时按路径段兜底。 --- .../views/components/layouts/admin.blade.php | 18 +++++++++++-- .../Business/StoreViolationAlertPageTest.php | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index d3c4f797..485cffb4 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -1,5 +1,17 @@ + @php + // 钉钉工作台「财务系统」入口:只在店铺违规信息预警这一页隐藏全局菜单, + // 让这个入口点进去就只是一张页面;网页端登录(非钉钉容器)不受影响。 + // 也支持显式 ?focus=1,便于以后给别的入口做同样的单页模式。 + // 路由参数在部分上下文取不到时退回按路径段判断(与目录表同一套兼容写法)。 + $focusSection = (string) (request()->route('section') ?: request()->segment(3)); + $focusPage = (string) (request()->route('page') ?: request()->segment(4)); + $focusEntry = $focusSection === 'financial-risk-control' + && $focusPage === 'store-violation-alert' + && (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk')); + @endphp + @if (! empty($metaTitle)) @@ -20,11 +32,13 @@ @stack('body_start') - + @unless ($focusEntry) + + @endunless -
+
diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index 27e9a39e..a6e2bbf7 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -135,6 +135,31 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertSee('data-platform-panel="jingxi"', false); } + public function test_dingtalk_entry_shows_only_this_page_without_the_global_menu(): void + { + $this->allowPage(); + + // 钉钉工作台入口:隐藏全局菜单,只留这一页。 + $this->loginAs() + ->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC']) + ->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ])) + ->assertOk() + ->assertDontSee('id="sidenav-main"', false) + ->assertSeeText('店铺违规信息预警'); + + // 网页端(非钉钉)照旧显示完整菜单。 + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) Safari/605.1.15']) + ->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ])) + ->assertOk() + ->assertSee('id="sidenav-main"', false); + } + public function test_page_filters_by_keyword(): void { $this->allowPage(); From 26b83bd8e793397ae9c0ddc4f5fe8db8b1da32eb Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 13:53:49 +0800 Subject: [PATCH 21/44] =?UTF-8?q?=E9=92=89=E9=92=89=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=E6=A8=A1=E5=BC=8F=E6=89=A9=E5=88=B0=E6=95=B4?= =?UTF-8?q?=E4=B8=AA=E3=80=8C=E8=B4=A2=E5=8A=A1=E7=BB=8F=E8=90=A5=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E7=AE=A1=E6=8E=A7=E3=80=8D=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版只对店铺违规信息预警单页;在钉钉里点该目录的另一个页签(流程统计)又会 冒出整排菜单。改成:只要属于 financial-risk-control 目录、且来自钉钉容器 (或显式 ?focus=1),就隐藏全局菜单,只保留该目录自己的页签。 --- resources/views/components/layouts/admin.blade.php | 9 ++++----- .../Business/StoreViolationAlertPageTest.php | 13 ++++++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 485cffb4..1075224e 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -1,14 +1,13 @@ @php - // 钉钉工作台「财务系统」入口:只在店铺违规信息预警这一页隐藏全局菜单, - // 让这个入口点进去就只是一张页面;网页端登录(非钉钉容器)不受影响。 + // 钉钉工作台「财务系统」入口:在「财务经营风险管控」目录下隐藏全局菜单, + // 只保留该目录自己的页签(店铺违规信息预警 / 流程统计…), + // 让钉钉里点进来就只是这一块内容;网页端登录(非钉钉容器)不受影响。 // 也支持显式 ?focus=1,便于以后给别的入口做同样的单页模式。 // 路由参数在部分上下文取不到时退回按路径段判断(与目录表同一套兼容写法)。 $focusSection = (string) (request()->route('section') ?: request()->segment(3)); - $focusPage = (string) (request()->route('page') ?: request()->segment(4)); - $focusEntry = $focusSection === 'financial-risk-control' - && $focusPage === 'store-violation-alert' + $focusEntry = in_array($focusSection, ['financial-risk-control'], true) && (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk')); @endphp diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index a6e2bbf7..171b7a1a 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -139,7 +139,8 @@ class StoreViolationAlertPageTest extends FeatureTestCase { $this->allowPage(); - // 钉钉工作台入口:隐藏全局菜单,只留这一页。 + // 钉钉工作台入口:整个「财务经营风险管控」目录都隐藏全局菜单, + // 只保留目录自己的页签(店铺违规信息预警 / 流程统计)。 $this->loginAs() ->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC']) ->get(route('business.pages.show', [ @@ -150,6 +151,16 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertDontSee('id="sidenav-main"', false) ->assertSeeText('店铺违规信息预警'); + // 同目录下的另一个页面(流程统计)在钉钉里同样是单页模式。 + $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC']) + ->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'process-statistics', + ])) + ->assertOk() + ->assertDontSee('id="sidenav-main"', false) + ->assertSeeText('流程统计'); + // 网页端(非钉钉)照旧显示完整菜单。 $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) Safari/605.1.15']) ->get(route('business.pages.show', [ From 8905192b78497f7bd3b675a754cf0c4be441ae79 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 13:58:31 +0800 Subject: [PATCH 22/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E9=99=84=E4=BB=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=B8=8B=E8=BD=BD=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 33 +++++++++ .../DingPayableWorkflowMirrorRepository.php | 51 +++++++++++++ .../DingTalkApprovalAttachmentDownloader.php | 71 +++++++++++++++++++ ...able-cloud-warehouse-vehicle-fee.blade.php | 16 ++++- routes/admin.php | 4 ++ .../Business/DingPayableWorkflowPageTest.php | 66 +++++++++++++++++ 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 3f7e7a9a..84ba0401 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -31,6 +31,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Illuminate\Support\Carbon; @@ -69,6 +70,7 @@ use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\AccountMappingValidator; +use Modules\EcommerceFinance\Services\DingTalkApprovalAttachmentDownloader; use Modules\EcommerceFinance\Services\StoreReportInitialPeriods; use Modules\EcommerceFinance\Services\TaxForecastLogic; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; @@ -4688,6 +4690,37 @@ class Pages extends Controller } } + /** + * 下载「应付云仓车费」某条审批实例的钉钉附件: + * 先校验附件确实属于该实例(镜像表单数据),再换取钉盘临时地址并透传下载。 + */ + public function downloadDingPayableAttachment(Request $request): StreamedResponse + { + $processInstanceId = mb_substr(trim((string) $request->query('process_instance_id', '')), 0, 100); + $fileId = mb_substr(trim((string) $request->query('file_id', '')), 0, 64); + abort_if($processInstanceId === '' || $fileId === '', 404); + + $attachment = $this->dingPayableWorkflows->attachment($processInstanceId, $fileId); + abort_if($attachment === null, 404, '附件不存在或不属于该审批实例。'); + + $uri = app(DingTalkApprovalAttachmentDownloader::class)->downloadUri($processInstanceId, $fileId); + abort_if($uri === null || $uri === '', 502, '暂时无法获取钉钉附件下载地址,请稍后重试。'); + + // 钉盘地址是带签名的 OSS 链接:请求时不要附带额外请求头,避免破坏签名。 + $upstream = Http::withOptions(['stream' => true])->timeout(60)->get($uri); + abort_if(! $upstream->successful(), 502, '附件下载失败,请稍后重试。'); + + $fileName = $attachment['fileName'] !== '' ? $attachment['fileName'] : $fileId; + $contentType = (string) ($upstream->header('Content-Type') ?: 'application/octet-stream'); + + return response()->streamDownload(function () use ($upstream): void { + $body = $upstream->toPsrResponse()->getBody(); + while (! $body->eof()) { + echo $body->read(8192); + } + }, $fileName, ['Content-Type' => $contentType]); + } + protected function contentForOnlineDaifaPrice(): array { $filters = $this->onlineDaifaPriceFilters(request()); diff --git a/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php index 3d0bbb17..0617e04f 100644 --- a/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php @@ -98,6 +98,57 @@ class DingPayableWorkflowMirrorRepository ]; } + /** + * 校验某个审批实例里是否存在该附件,并返回附件元数据(下载接口使用, + * 防止拿任意 fileId 越权取钉盘文件)。 + * + * @return array{fileName:string,fileType:string,fileSize:int}|null + */ + public function attachment(string $processInstanceId, string $fileId): ?array + { + if ($processInstanceId === '' || $fileId === '') { + return null; + } + + $record = $this->connection->table('ding_payable_workflow_mirror') + ->where('process_instance_id', $processInstanceId) + ->first(); + + if ($record === null) { + return null; + } + + $scheme = $this->decode((string) $record->scheme_payload, []); + $forms = is_array($scheme) ? $this->formFields($scheme) : []; + $files = []; + + foreach (['附件', '未签字版合同附件或其他明细附件'] as $key) { + $value = $forms[$key] ?? null; + if (! is_array($value)) { + continue; + } + foreach ($value as $file) { + if (is_array($file)) { + $files[] = $file; + } + } + } + + foreach ($files as $file) { + if ((string) ($file['fileId'] ?? '') !== $fileId) { + continue; + } + + return [ + 'fileName' => trim((string) ($file['fileName'] ?? '')) ?: $fileId, + 'fileType' => trim((string) ($file['fileType'] ?? '')), + 'fileSize' => (int) ($file['fileSize'] ?? 0), + ]; + } + + return null; + } + /** @param array $workflow @param array $filters */ private function matchesWorkflowFilters(array $workflow, array $filters): bool { diff --git a/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php new file mode 100644 index 00000000..073e64cc --- /dev/null +++ b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php @@ -0,0 +1,71 @@ +accessToken(); + + if ($token === null) { + return null; + } + + $response = Http::withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->timeout(20) + ->post(self::GRANT_DOWNLOAD_URL, [ + 'processInstanceId' => $processInstanceId, + 'fileId' => $fileId, + ]); + + if (! $response->successful()) { + report(new \RuntimeException( + 'dingtalk approval attachment grant failed: HTTP ' . $response->status() + )); + + return null; + } + + $uri = (string) $response->json('result.downloadUri'); + + return $uri !== '' ? $uri : null; + } + + private function accessToken(): ?string + { + $clientId = (string) config('dingtalk.client_id'); + $secret = (string) config('dingtalk.client_secret'); + + if ($clientId === '' || $secret === '') { + return null; + } + + $token = Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(100), function () use ($clientId, $secret): ?string { + $response = Http::timeout(15)->get(self::GET_TOKEN_URL, [ + 'appkey' => $clientId, + 'appsecret' => $secret, + ]); + + $token = (string) $response->json('access_token'); + + return $token !== '' ? $token : null; + }); + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index 893ade32..7903fc5b 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -45,6 +45,8 @@ .ding-payable-table tr:hover td { background:#fcfaf4; } .ding-payable-table .num { text-align:right; font-variant-numeric:tabular-nums; } .ding-payable-detail { max-width:26rem; white-space:normal; } + .ding-payable-attachment { color:var(--vip-content-accent-dark,#856632); } + .ding-payable-attachment-size { margin-left:.35rem; color:var(--vip-content-muted,#595959); font-size:.68rem; } .ding-payable-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); } .ding-payable-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; } .ding-payable-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); } @@ -124,7 +126,19 @@
- + @php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : [])) + @empty diff --git a/routes/admin.php b/routes/admin.php index 094bdb16..3c47b50d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -356,6 +356,10 @@ Route::get('workspace/courier-payables/return-pickup-management/detail', 'Busine ->middleware('business.page:courier-payables,return-pickup-management') ->name('business.courier-payables.return-pickup-management.detail'); +Route::get('workspace/courier-payables/payable-cloud-warehouse-vehicle-fee/attachment', 'Business\Pages@downloadDingPayableAttachment') + ->middleware('business.page:courier-payables,payable-cloud-warehouse-vehicle-fee') + ->name('business.courier-payables.ding-payable-attachment.download'); + // 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。 Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement') ->middleware('business.page:courier-payables,courier-surcharge') diff --git a/tests/Feature/Business/DingPayableWorkflowPageTest.php b/tests/Feature/Business/DingPayableWorkflowPageTest.php index 29a3e174..355afdd6 100644 --- a/tests/Feature/Business/DingPayableWorkflowPageTest.php +++ b/tests/Feature/Business/DingPayableWorkflowPageTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use Illuminate\Support\Facades\Http; use Mockery; use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository; use Tests\Feature\FeatureTestCase; @@ -61,6 +62,9 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('全部表单字段') ->assertSee('审批任务') ->assertSee('测试对账附件.xlsx') + ->assertSee('process_instance_id=PROC-TEST-001', false) + ->assertSee('file_id=123456', false) + ->assertSee('ding-payable-attachment"', false) ->assertSee('镜像状态:succeeded'); } @@ -147,4 +151,66 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('状态') ->assertSee('全部状态'); } + + public function test_attachment_download_streams_dingtalk_file(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + config([ + 'dingtalk.client_id' => 'test-client-id', + 'dingtalk.client_secret' => 'test-client-secret', + ]); + + $repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class); + $repository->shouldReceive('attachment') + ->once() + ->with('PROC-TEST-001', '123456') + ->andReturn(['fileName' => '测试对账附件.xlsx', 'fileType' => 'xlsx', 'fileSize' => 9]); + $this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository); + + Http::fake([ + 'oapi.dingtalk.com/*' => Http::response(['access_token' => 'test-token']), + 'api.dingtalk.com/*' => Http::response([ + 'success' => true, + 'result' => ['downloadUri' => 'https://fake-oss.example.com/file'], + ]), + 'fake-oss.example.com/*' => Http::response('file-bytes', 200, ['Content-Type' => 'application/octet-stream']), + ]); + + $response = $this->loginAs()->get(route('business.courier-payables.ding-payable-attachment.download', [ + 'process_instance_id' => 'PROC-TEST-001', + 'file_id' => '123456', + ])); + + $response->assertOk() + ->assertHeader('content-type', 'application/octet-stream'); + + $this->assertSame('file-bytes', $response->streamedContent()); + $this->assertStringContainsString( + rawurlencode('测试对账附件.xlsx'), + (string) $response->headers->get('content-disposition'), + ); + } + + public function test_attachment_download_rejects_unknown_file(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class); + $repository->shouldReceive('attachment')->once()->andReturn(null); + $this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository); + + Http::fake(); + + $this->loginAs() + ->withExceptionHandling() + ->withHeaders(['X-Requested-With' => 'XMLHttpRequest']) + ->get(route('business.courier-payables.ding-payable-attachment.download', [ + 'process_instance_id' => 'PROC-UNKNOWN', + 'file_id' => '999', + ])) + ->assertNotFound(); + + Http::assertNothingSent(); + } } From d3049686b4c65caa5a1125e4975b4f036e36dd52 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 14:12:47 +0800 Subject: [PATCH 23/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E7=A7=BB=E9=99=A4=E3=80=8C=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E8=A1=A8=E5=8D=95=E5=AD=97=E6=AE=B5/=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E6=98=8E=E7=BB=86/=E5=AE=A1=E6=89=B9=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E3=80=8D=E4=B8=89=E5=88=97=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payable-cloud-warehouse-vehicle-fee.blade.php | 10 ++-------- tests/Feature/Business/DingPayableWorkflowPageTest.php | 5 +++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index 7903fc5b..c4ecf1f3 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -4,9 +4,6 @@ $status = (string) ($dingPayableWorkflowStatus ?? 'unknown'); $syncedAt = $dingPayableWorkflowSyncedAt ?? null; $error = $dingPayableWorkflowError ?? null; - $json = static fn (mixed $value): string => is_array($value) - ? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-') - : (string) ($value ?? '-'); $statusLabel = static fn (array $workflow): string => \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::label($workflow); $statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions(); @@ -98,7 +95,7 @@ - + @forelse ($rows ?? [] as $row) @@ -124,8 +121,6 @@ - - @php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : [])) - @empty - + @endforelse
{{ $value($detail, '银行账号') }}
展开字段({{ count($fields) }} 项)
{{ $json($fields) }}
展开付款明细
{{ $json($fields['付款明细'] ?? []) }}
展开附件
{{ $json($fields['附件'] ?? ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}
展开附件
{{ $json(($fields['附件'] ?? '') ?: ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}
展开审批任务
{{ $json($row['tasks'] ?? []) }}
{{ $value($detail, '银行账号') }}
展开字段({{ count($fields) }} 项)
{{ $json($fields) }}
展开付款明细
{{ $json($fields['付款明细'] ?? []) }}
展开附件
{{ $json(($fields['附件'] ?? '') ?: ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}
+ @forelse ($attachmentFiles as $attachmentFile) + @if (is_array($attachmentFile) && ! empty($attachmentFile['fileId'])) +
+ {{ $attachmentFile['fileName'] ?? ('附件 ' . $attachmentFile['fileId']) }} + @if (! empty($attachmentFile['fileSize'])){{ number_format(((int) $attachmentFile['fileSize']) / 1048576, 2) }} MB@endif +
+ @endif + @empty + - + @endforelse +
展开审批任务
{{ $json($row['tasks'] ?? []) }}
钉钉审批编号标题状态申请人申请部门创建时间完成时间 类型用途付款金额货款月份快递支付方式收款人全称开户银行银行账号全部表单字段付款明细附件审批任务附件
{{ $value($detail, '收款人全称') }} {{ $value($detail, '开户银行') }} {{ $value($detail, '银行账号') }}
展开字段({{ count($fields) }} 项)
{{ $json($fields) }}
展开付款明细
{{ $json($fields['付款明细'] ?? []) }}
@forelse ($attachmentFiles as $attachmentFile) @@ -139,10 +134,9 @@ - @endforelse
展开审批任务
{{ $json($row['tasks'] ?? []) }}
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的应付云仓车费流程;同步完成后会自动显示。' }}
diff --git a/tests/Feature/Business/DingPayableWorkflowPageTest.php b/tests/Feature/Business/DingPayableWorkflowPageTest.php index 355afdd6..87c950d4 100644 --- a/tests/Feature/Business/DingPayableWorkflowPageTest.php +++ b/tests/Feature/Business/DingPayableWorkflowPageTest.php @@ -59,8 +59,9 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('已完成') ->assertSee('拉到江西仓的车费') ->assertSee('用途同时包含“仓”“车费”') - ->assertSee('全部表单字段') - ->assertSee('审批任务') + ->assertDontSeeText('全部表单字段') + ->assertDontSeeText('付款明细') + ->assertDontSeeText('审批任务') ->assertSee('测试对账附件.xlsx') ->assertSee('process_instance_id=PROC-TEST-001', false) ->assertSee('file_id=123456', false) From cdcb1c66629833303d29a096d1a9bcbb6f0ea9a8 Mon Sep 17 00:00:00 2001 From: zhoushengchen <3057083327@qq.com> Date: Tue, 13 Oct 2026 14:29:57 +0800 Subject: [PATCH 24/44] =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E8=BD=A6=E8=B4=B9=EF=BC=9A=E6=98=8E=E7=BB=86=E8=A1=8C=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E9=AB=98=E4=BA=AE=20(#331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...able-cloud-warehouse-vehicle-fee.blade.php | 33 ++++++++++++++++++- .../Business/DingPayableWorkflowPageTest.php | 3 ++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index c4ecf1f3..400a3699 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -40,6 +40,9 @@ .ding-payable-table th,.ding-payable-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; } .ding-payable-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; } .ding-payable-table tr:hover td { background:#fcfaf4; } + .ding-payable-table tbody tr.is-clickable { cursor:pointer; } + .ding-payable-table tbody tr.is-active td { background:var(--vip-content-accent-soft,#F7F0DE); border-bottom-color:#e4d7ba; } + .ding-payable-table tbody tr.is-active td:first-child { box-shadow:inset 3px 0 0 var(--vip-content-accent-dark,#856632); } .ding-payable-table .num { text-align:right; font-variant-numeric:tabular-nums; } .ding-payable-detail { max-width:26rem; white-space:normal; } .ding-payable-attachment { color:var(--vip-content-accent-dark,#856632); } @@ -104,7 +107,7 @@ @php($fields = $row['fields'] ?? []) @php($approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '-')) @php($originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '-'))) -
{{ $approvalNumber }} {{ $workflow['title'] ?? '-' }} {{ $statusLabel($workflow) }}
序号 店铺名预警级别 违规类型 违规分类 违规原因违规单号 违规日期 扣分责任人 状态 处罚日期 申诉截止日期处理截止是否超时推送状态 处理状态操作
{{ ($rows->firstItem() ?? 1) + $index }} {{ $record->shop_name ?: '-' }} + @if (! empty($record->alert_level) && ($record->needs_alert ?? false)) + {{ $levelLabel($record->alert_level) }} + @else + 不提醒 + @endif + {{ \Illuminate\Support\Str::limit((string) $record->violation_type, 40, '…') ?: '-' }} {{ $record->violation_category ?: '-' }} {{ \Illuminate\Support\Str::limit((string) $record->violation_reason, 40, '…') ?: '-' }}{{ $record->violation_no ?: '-' }} {{ $record->violation_date ?: '-' }} {{ $record->points_deducted === null ? '-' : rtrim(rtrim(number_format((float) $record->points_deducted, 2, '.', ''), '0'), '.') }}{{ $record->owner_name ?: '-' }} {{ $record->status ?: '-' }} {{ $record->penalty_date ?: '-' }} {{ $record->appeal_deadline ?: '-' }}{{ $record->handling_deadline ? \Illuminate\Support\Carbon::parse($record->handling_deadline)->format('Y-m-d H:i') : '-' }} + @if ($overdue) + 已超时 + @else + 未超时 + @endif + + {{ $record->push_status ?: '未推送' }} + {{ $record->handling_status ?: '未处理' }} + @if (($record->handling_status ?? '') === '已忽略' && ! empty($record->ignore_reason)) +
{{ \Illuminate\Support\Str::limit((string) $record->ignore_reason, 14, '…') }}
+ @endif +
+ + @csrf + + + +
+ 忽略 +
+ + +
+
+
+ @if ($error) {{ $error }} @elseif ($platformTotal === 0) diff --git a/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php new file mode 100644 index 00000000..10b1261e --- /dev/null +++ b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php @@ -0,0 +1,470 @@ +@php + $platforms = $storeViolationPlatforms ?? []; + $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); + $panels = $storeViolationPanels ?? []; + $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); + $error = $storeViolationError ?? null; + $alertLevels = config('store_violations.alert_levels', []); + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; +@endphp + + + +
+ @include('business.pages.partials.upstream-data-styles') + + + + @foreach ($platforms as $slug => $platform) + @php + $panel = $panels[$slug] ?? null; + $rows = $panel['rows'] ?? null; + $total = (int) ($panel['total'] ?? 0); + $stats = $panel['stats'] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + $label = $platform['label'] ?? $slug; + @endphp + + + @endforeach +
+ + diff --git a/routes/admin.php b/routes/admin.php index 3c47b50d..1df5eb1e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -232,6 +232,8 @@ Route::prefix('workspace/financial-risk-control/store-violation-alert') Route::get('template', 'Business\Pages@storeViolationTemplate')->name('template'); Route::post('import/preview', 'Business\Pages@previewStoreViolationImport')->middleware('import')->name('import.preview'); Route::post('import/commit', 'Business\Pages@commitStoreViolationImport')->middleware('import')->name('import.commit'); + Route::post('update', 'Business\Pages@updateStoreViolation')->name('update'); + Route::get('claim/{record}', 'Business\Pages@claimStoreViolation')->name('claim'); }); Route::prefix('workspace/supplier-payables/product-prices') diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index 2b2e3259..e90f1abb 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -5,7 +5,9 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\URL; use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -35,6 +37,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->bigIncrements('id'); $table->string('platform', 32); $table->string('shop_name', 191)->default(''); + $table->string('owner_name', 64)->nullable(); $table->string('violation_type', 500)->nullable(); $table->string('violation_category', 64)->nullable(); $table->string('violation_reason', 500)->nullable(); @@ -45,15 +48,75 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->string('status', 64)->nullable(); $table->date('penalty_date')->nullable(); $table->date('appeal_deadline')->nullable(); + $table->dateTime('handling_deadline')->nullable(); $table->string('handling_status', 32)->default('未处理'); + $table->string('ignore_reason', 191)->nullable(); $table->string('source', 16)->default('crawl'); $table->string('window_name', 191)->nullable(); $table->date('crawl_date')->nullable(); $table->dateTime('synced_at')->nullable(); + $table->boolean('needs_alert')->default(false); + $table->string('alert_level', 8)->nullable(); + $table->unsignedBigInteger('alert_rule_id')->nullable(); + $table->string('alert_action', 500)->nullable(); + $table->string('alert_channel', 64)->nullable(); + $table->unsignedInteger('handling_hours')->nullable(); + $table->string('push_status', 16)->default('未推送'); + $table->dateTime('last_pushed_at')->nullable(); $table->timestamps(); $table->unique(['platform', 'violation_no']); }); + Schema::connection('business_mirror')->create('store_violation_rules', function ($table): void { + $table->bigIncrements('id'); + $table->string('platform', 32); + $table->string('violation_category', 64)->nullable(); + $table->string('violation_type', 500)->nullable(); + $table->string('match_keywords', 500)->nullable(); + $table->string('alert_level', 8)->nullable(); + $table->boolean('needs_alert')->default(true); + $table->string('suggested_action', 500)->nullable(); + $table->unsignedInteger('handling_hours')->nullable(); + $table->string('remind_channel', 191)->nullable(); + $table->unsignedInteger('sort')->default(0); + $table->boolean('enabled')->default(true); + $table->string('remark', 191)->nullable(); + $table->timestamps(); + }); + + Schema::connection('business_mirror')->create('ops_fys_shop_info', function ($table): void { + $table->bigIncrements('id'); + $table->string('shopName', 191)->nullable(); + $table->string('shopHead', 64)->nullable(); + }); + + Schema::connection('business_mirror')->create('store_violation_push_logs', function ($table): void { + $table->bigIncrements('id'); + $table->string('push_no', 32)->unique(); + $table->dateTime('pushed_at')->nullable(); + $table->string('channel', 32)->default('dingtalk_robot'); + $table->string('message_type', 16)->default('instant'); + $table->string('platform', 32)->nullable(); + $table->string('violation_no', 100)->nullable(); + $table->string('message_digest', 500)->nullable(); + $table->string('mentioned', 191)->nullable(); + $table->string('result', 16)->default('success'); + $table->string('error', 500)->nullable(); + $table->timestamps(); + }); + + config(['store_violations.push' => [ + 'enabled' => true, + 'channel' => 'dingtalk_robot', + 'webhook' => 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + 'secret' => '', + 'window_start' => '09:00', + 'window_end' => '21:00', + 'instant_levels' => ['P0', 'P1'], + 'digest_time' => '10:00', + 'at_all' => false, + ]]); + Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void { $table->string('dataset_key', 191)->primary(); $table->string('source_key', 64)->default(''); @@ -68,29 +131,79 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->timestamps(); }); + DB::connection('business_mirror')->table('store_violation_rules')->insert([ + ['id' => 1, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '缺货/无货', + 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '联系客户补发', + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 2, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单延迟发货', + 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '致电客户协商', + 'handling_hours' => 24, 'remind_channel' => null, 'sort' => 10, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 3, 'platform' => 'jingxi', 'violation_category' => '发票与考核', 'violation_type' => '发票违规', + 'match_keywords' => null, 'alert_level' => 'P0', 'needs_alert' => true, 'suggested_action' => '立即补开发票', + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 4, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单存在揽收超时行为', + 'match_keywords' => null, 'alert_level' => null, 'needs_alert' => true, 'suggested_action' => null, + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 20, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ]); + + DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([ + ['shopName' => '京东-亿家达全屋旗舰店', 'shopHead' => '张三'], + ['shopName' => '京东-卓禾卧室家具旗舰店', 'shopHead' => '李四'], + ['shopName' => '京东-纪辰家具旗舰店', 'shopHead' => '王五'], + ['shopName' => '京喜-久伴吾家具京喜自营工厂店', 'shopHead' => '幽竹'], + ]); + DB::connection('business_mirror')->table('store_violation_records')->insert([ [ - 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'violation_type' => '延迟发货/缺货无货', - 'violation_category' => '发货履约', 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', + 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'owner_name' => '张三', + 'violation_type' => '延迟发货/缺货无货', 'violation_category' => '发货履约', + 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => 2, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => '2026-10-11', - 'handling_status' => '未处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-12 23:59:59', 'handling_status' => '未处理', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P1', 'alert_rule_id' => 1, 'alert_action' => '联系客户补发', + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ - 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'violation_type' => '订单存在揽收超时行为', - 'violation_category' => '发货履约', 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, + 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'owner_name' => '李四', + 'violation_type' => '订单存在揽收超时行为', 'violation_category' => '发货履约', + 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03', 'points_deducted' => null, 'status' => '超期未申诉', 'penalty_date' => '2026-10-05', 'appeal_deadline' => '2026-10-08', - 'handling_status' => '处理中', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-14 23:59:59', 'handling_status' => '处理中', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P2', 'alert_rule_id' => 4, 'alert_action' => null, + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ - 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'violation_type' => '发票违规', - 'violation_category' => '发票与考核', 'violation_reason' => null, 'disposition' => '维持原判', + 'platform' => 'jd', 'shop_name' => '京东-无提醒示例店', 'owner_name' => null, + 'violation_type' => '普通通知事项', 'violation_category' => '其他', + 'violation_reason' => null, 'disposition' => null, + 'violation_no' => 'JD202610040003', 'violation_date' => '2026-10-05', + 'points_deducted' => null, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => null, + 'handling_deadline' => null, 'handling_status' => '未处理', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-05', 'synced_at' => null, + 'needs_alert' => false, 'alert_level' => null, 'alert_rule_id' => null, 'alert_action' => null, + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, + 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', + ], + [ + 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'owner_name' => '幽竹', + 'violation_type' => '发票违规', 'violation_category' => '发票与考核', + 'violation_reason' => null, 'disposition' => '维持原判', 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => null, 'status' => '已处置', 'penalty_date' => null, 'appeal_deadline' => null, - 'handling_status' => '已处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-13 23:59:59', 'handling_status' => '处理完成', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P0', 'alert_rule_id' => 3, 'alert_action' => '立即补开发票', + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], ]); @@ -120,6 +233,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase $page->assertOk() ->assertSee('店铺名') + ->assertSee('预警级别') ->assertSee('违规类型') ->assertSee('违规分类') ->assertSee('违规原因') @@ -127,8 +241,18 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertSee('违规单号') ->assertSee('违规日期') ->assertSee('扣分') + ->assertSee('责任人') ->assertSee('申诉截止日期') + ->assertSee('处理截止') + ->assertSee('是否超时') + ->assertSee('推送状态') ->assertSee('处理状态') + ->assertSee('操作') + ->assertSee('P1-较重') + ->assertSee('张三') + ->assertSee('已超时') + ->assertSee('未推送') + ->assertSee('默认视图') ->assertSee('订单存在延迟发货、缺货无货') ->assertSee('JD202610040001') ->assertSee('京东-亿家达全屋旗舰店') @@ -181,6 +305,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase 'page' => 'store-violation-alert', 'platform' => 'jd', 'keyword' => '卓禾', + 'view' => 'all', ])); $page->assertOk() @@ -189,6 +314,33 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertDontSee('JX202610040001'); } + public function test_default_view_only_shows_alert_and_pending_records(): void + { + $this->allowPage(); + + $default = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => 'jd', + ])); + $default->assertOk() + ->assertSee('JD202610040001') + ->assertDontSee('JD202610040002') + ->assertDontSee('JD202610040003'); + + $all = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => 'jd', + 'view' => 'all', + ])); + $all->assertOk() + ->assertSee('JD202610040001') + ->assertSee('JD202610040002') + ->assertSee('JD202610040003') + ->assertSee('不提醒'); + } + public function test_export_streams_unified_headers(): void { $this->allowPage(); @@ -211,15 +363,21 @@ class StoreViolationAlertPageTest extends FeatureTestCase file_put_contents($temp, $binary); $sheet = IOFactory::load($temp)->getActiveSheet(); - $headers = $sheet->rangeToArray('A1:L1')[0]; + $headers = $sheet->rangeToArray('A1:R1')[0]; $this->assertSame( - ['店铺名', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'], + [ + '店铺名', '预警级别', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', + '责任人', '状态', '处罚日期', '申诉截止日期', '处理截止', '是否超时', '推送状态', '处理状态', '忽略原因', + ], $headers, ); - $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('D2')->getValue()); - $this->assertSame('警告', $sheet->getCell('E2')->getValue()); - $this->assertSame('JD202610040001', $sheet->getCell('F2')->getValue()); + $this->assertSame('P1-较重', $sheet->getCell('B2')->getValue()); + $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('E2')->getValue()); + $this->assertSame('警告', $sheet->getCell('F2')->getValue()); + $this->assertSame('JD202610040001', $sheet->getCell('G2')->getValue()); + $this->assertSame('张三', $sheet->getCell('J2')->getValue()); + $this->assertSame('已超时', $sheet->getCell('O2')->getValue()); unlink($temp); } @@ -319,6 +477,173 @@ class StoreViolationAlertPageTest extends FeatureTestCase $this->assertSame('警告;整改', $record->disposition); // 未提供违规类型时,违规分类按违规原因关键词自动归类。 $this->assertSame('发货履约', $record->violation_category); + // 预警派生字段按字典自动计算(无级别配置的规则默认 P2)。 + $this->assertTrue((bool) $record->needs_alert); + $this->assertSame('P2', $record->alert_level); + } + + public function test_import_commit_calculates_alert_fields_from_dictionary(): void + { + $this->allowPage(); + + $file = $this->makeWorkbook([ + ['店铺名', '违规单号', '违规类型', '违规日期'], + ['京东-纪辰家具旗舰店', 'JD202610040099', '订单延迟发货', '2026-10-02'], + ]); + + $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ + 'platform' => 'jd', + 'import' => $file, + ]); + $preview->assertRedirect(); + $payload = $preview->getSession()->get('store_violation_preview'); + + $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ + 'preview_token' => $payload['token'], + ])->assertRedirect(); + + $record = DB::connection('business_mirror') + ->table('store_violation_records') + ->where('platform', 'jd') + ->where('violation_no', 'JD202610040099') + ->first(); + + $this->assertNotNull($record); + $this->assertTrue((bool) $record->needs_alert); + $this->assertSame('P1', $record->alert_level); + $this->assertSame('王五', $record->owner_name); + // 字典时效 24 小时:违规日期 2026-10-02 起算。 + $this->assertSame('2026-10-03 00:00:00', (string) $record->handling_deadline); + } + + public function test_update_handling_status_requires_reason_when_ignored(): void + { + $this->allowPage(); + $this->withExceptionHandling(); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + + // 已忽略缺原因 → 报错且状态不变 + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '已忽略', + ])->assertSessionHasErrors('ignore_reason'); + $this->assertSame('未处理', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 认领 → 处理中 + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '处理中', + ])->assertRedirect(); + $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 忽略(带原因) + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '已忽略', + 'ignore_reason' => '误报,无需处理', + ])->assertRedirect(); + $updated = DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->first(); + $this->assertSame('已忽略', $updated->handling_status); + $this->assertSame('误报,无需处理', $updated->ignore_reason); + } + + public function test_claim_route_marks_record_processing_via_signed_link(): void + { + $this->allowPage(); + $this->withExceptionHandling(); + $this->loginAs(); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + + $url = URL::signedRoute('business.store-violation-alert.claim', [ + 'record' => $record->id, + 'company_id' => $this->company->id, + ]); + $this->get($url)->assertRedirect(); + $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 幂等:再次访问不报错。 + $this->get($url)->assertRedirect(); + + // 无签名 → 403。 + $this->get(route('business.store-violation-alert.claim', [ + 'record' => $record->id, + 'company_id' => $this->company->id, + ])) + ->assertStatus(403); + } + + public function test_preview_statistics_page_renders_kpi_cards_and_pending_rows(): void + { + $this->allowPage(); + + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'preview-statistics', + 'platform' => 'jd', + ])); + + $response->assertOk() + ->assertSeeText('预览统计') + ->assertSeeText('今日新增') + ->assertSeeText('待处理总数') + ->assertSeeText('P0 未处理') + ->assertSeeText('已超时') + ->assertSeeText('待处理 1 条') + ->assertSee('JD202610040001') + ->assertDontSee('JD202610040002') + ->assertDontSee('JD202610040003') + ->assertSee('data-risk-preview-tabs', false); + } + + public function test_push_instant_sends_p0_p1_and_marks_pushed(): void + { + $this->allowPage(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->artisan('store-violations:push-instant')->assertExitCode(0); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + $this->assertSame('已推送', $record->push_status); + $this->assertNotNull($record->last_pushed_at); + + $log = DB::connection('business_mirror')->table('store_violation_push_logs') + ->where('message_type', 'instant')->first(); + $this->assertNotNull($log); + $this->assertSame('JD202610040001', $log->violation_no); + $this->assertSame('success', $log->result); + $this->assertSame('张三', $log->mentioned); + + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($request->url(), 'oapi.dingtalk.com/robot/send') + && str_contains($text, 'P1-较重') + && str_contains($text, '去认领'); + }); + } + + public function test_push_digest_sends_daily_summary(): void + { + $this->allowPage(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->artisan('store-violations:push-digest')->assertExitCode(0); + + $log = DB::connection('business_mirror')->table('store_violation_push_logs') + ->where('message_type', 'daily')->first(); + $this->assertNotNull($log); + $this->assertSame('success', $log->result); + + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($text, '每日汇总') && str_contains($text, '已超时未认领'); + }); } /** @param list> $rows */ diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 84cf5d6b..525cca47 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -131,6 +131,7 @@ class NavigationTest extends FeatureTestCase $this->assertSame('security', $navigation[$riskControlIndex]['icon']); $this->assertSame([ ['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'], + ['slug' => 'preview-statistics', 'title' => '预览统计'], [ 'slug' => 'process-statistics', 'title' => '流程统计', diff --git a/tests/Unit/Business/StoreViolationAlertCalculatorTest.php b/tests/Unit/Business/StoreViolationAlertCalculatorTest.php new file mode 100644 index 00000000..965b7750 --- /dev/null +++ b/tests/Unit/Business/StoreViolationAlertCalculatorTest.php @@ -0,0 +1,82 @@ +assertSame('虚假交易', StoreViolationAlertCalculator::cleanViolationType('虚假交易(立即删除销量;)')); + $this->assertSame('AE', StoreViolationAlertCalculator::cleanViolationType('A(B(C)D)E(F)')); + $this->assertSame('订单存在揽收超时行为', StoreViolationAlertCalculator::cleanViolationType(' 订单存在揽收超时行为 ')); + $this->assertSame('', StoreViolationAlertCalculator::cleanViolationType(null)); + } + + public function test_match_rule_uses_exact_prefix_parts_and_keywords_in_order(): void + { + $rules = [ + ['id' => 1, 'platform' => 'jd', 'violation_type' => '缺货/无货', 'match_keywords' => null, + 'alert_level' => 'P1', 'needs_alert' => 1, 'suggested_action' => '联系客户补发', 'handling_hours' => null], + ['id' => 2, 'platform' => 'jingxi', 'violation_type' => '订单延迟发货', 'match_keywords' => null, + 'alert_level' => 'P0', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null], + ['id' => 3, 'platform' => 'taobao', 'violation_type' => null, 'match_keywords' => '虚假交易|危及交易安全', + 'alert_level' => 'P2', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null], + ['id' => 4, 'platform' => 'douyin', 'violation_type' => '效果虚假', 'match_keywords' => null, + 'alert_level' => null, 'needs_alert' => 0, 'suggested_action' => null, 'handling_hours' => null], + ]; + + // 分词包含:规则"缺货/无货"命中"延迟发货/缺货无货" + $this->assertSame(1, StoreViolationAlertCalculator::matchRule($rules, 'jd', '延迟发货/缺货无货')['id']); + // 相等与前缀 + $this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货')['id']); + $this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货(立即扣分;)')['id']); + // 关键词匹配(先去括号) + $this->assertSame(3, StoreViolationAlertCalculator::matchRule($rules, 'taobao', '虚假交易(立即删除销量&屏蔽评论内容;)')['id']); + // 命中但规则标记为不需要提醒 + $this->assertSame(4, StoreViolationAlertCalculator::matchRule($rules, 'douyin', '效果虚假')['id']); + // 平台隔离与未命中 + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', '效果虚假')); + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'pinduoduo', '任意类型')); + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', null)); + } + + public function test_deadline_prefers_appeal_date_then_hours_then_level_days(): void + { + // 申诉截止非空 → 当天 23:59:59 + $this->assertSame( + '2026-10-11 23:59:59', + StoreViolationAlertCalculator::deadlineFor('2026-10-04', '2026-10-11', null, 'P1'), + ); + // 字典时效(小时) + $this->assertSame( + '2026-10-04 06:00:00', + StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, 6, 'P0'), + ); + // 级别默认天数:P0+1 / P1+2 / P2+3 天的 23:59:59 + $this->assertSame('2026-10-05 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P0')); + $this->assertSame('2026-10-06 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P1')); + $this->assertSame('2026-10-07 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P2')); + // 无违规日期或无级别 → null + $this->assertNull(StoreViolationAlertCalculator::deadlineFor(null, null, null, 'P0')); + $this->assertNull(StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, null)); + } + + public function test_is_overdue_respects_status_and_deadline(): void + { + $now = Carbon::parse('2026-10-05 10:00:00'); + + $this->assertTrue(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-04 23:59:59', $now)); + $this->assertTrue(StoreViolationAlertCalculator::isOverdue('处理中', '2026-10-05 09:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('处理完成', '2020-01-01 00:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('已忽略', '2020-01-01 00:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', null, $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-06 23:59:59', $now)); + } +} diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 48c8fbaf..aea94808 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -65,6 +65,12 @@ class BusinessMirrorScheduleTest extends TestCase $script, ); $this->assertSame(1, substr_count($contents, 'store-violations:sync --quiet'), $script); + $this->assertStringContainsString('store-violations:push-instant --quiet', $contents, $script); + $this->assertStringContainsString('store-violations:push-digest --quiet', $contents, $script); + $this->assertStringContainsString('*/5 9-21 * * * root', $contents, $script); + $this->assertStringContainsString('0 10 * * * root', $contents, $script); + $this->assertSame(1, substr_count($contents, 'store-violations:push-instant --quiet'), $script); + $this->assertSame(1, substr_count($contents, 'store-violations:push-digest --quiet'), $script); $this->assertStringContainsString( '15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet', $contents, From 0c04860d6922ef6bc2a597d54688de923fa76cf5 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 20:04:02 +0800 Subject: [PATCH 41/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E4=BF=AE=E5=A4=8D=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=B8=A5=E6=A0=BC=E6=A8=A1=E5=BC=8F=E6=8A=A5?= =?UTF-8?q?=E9=94=99=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=BF=9D=E8=A7=84=E5=AD=97?= =?UTF-8?q?=E5=85=B8=E5=AF=BC=E5=85=A5=E5=91=BD=E4=BB=A4=20(#333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportStoreViolationRules.php | 177 ++++++++++++++++++ .../Commands/RecalcStoreViolationAlerts.php | 10 +- 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/ImportStoreViolationRules.php diff --git a/app/Console/Commands/ImportStoreViolationRules.php b/app/Console/Commands/ImportStoreViolationRules.php new file mode 100644 index 00000000..18fb67d2 --- /dev/null +++ b/app/Console/Commands/ImportStoreViolationRules.php @@ -0,0 +1,177 @@ + ['平台', 'platform'], + 'violation_category' => ['违规分类', '分类', 'violation_category'], + 'violation_type' => ['违规类型', '类型', 'violation_type'], + 'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'], + 'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'], + 'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'], + 'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'], + 'handling_hours' => ['处理时效', '时效', 'handling_hours'], + 'remind_channel' => ['提醒方式', 'remind_channel'], + 'sort' => ['排序', 'sort'], + 'enabled' => ['启用', 'enabled'], + 'remark' => ['备注', 'remark'], + ]; + + private const PLATFORM_MAP = [ + '淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao', + '京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin', + '拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba', + ]; + + public function handle(): int + { + $file = (string) $this->argument('file'); + if (! is_file($file)) { + $this->error('文件不存在:' . $file); + + return self::FAILURE; + } + + try { + $sheet = IOFactory::load($file)->getActiveSheet(); + $matrix = $sheet->toArray(null, true, true, false); + } catch (Throwable $exception) { + $this->error('无法读取文件:' . $exception->getMessage()); + + return self::FAILURE; + } + + if ($matrix === []) { + $this->error('文件为空。'); + + return self::FAILURE; + } + + $map = []; + foreach ($matrix[0] as $index => $cell) { + $label = mb_strtolower(trim((string) $cell)); + foreach (self::HEADERS as $key => $aliases) { + foreach ($aliases as $alias) { + if ($label === mb_strtolower($alias)) { + $map[$key] = $index; + break 2; + } + } + } + } + + if (! isset($map['platform'], $map['violation_type'])) { + $this->error('缺少必需表头:平台、违规类型。'); + + return self::FAILURE; + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + if (! app(StoreViolationRuleRepository::class)->available()) { + $this->error('违规字典表尚未建立,请先执行数据库迁移。'); + + return self::FAILURE; + } + + $created = 0; + $updated = 0; + $skipped = 0; + $sort = 0; + + foreach (array_slice($matrix, 1) as $row) { + $value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : ''; + + $platform = $value('platform'); + $platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform); + $type = $value('violation_type'); + if ($platform === '' || $type === '') { + $skipped++; + continue; + } + + $sort += 10; + + $levelRaw = strtoupper($value('alert_level')); + $level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null; + + $needsAlertRaw = mb_strtolower($value('needs_alert')); + $needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true); + + $hoursRaw = $value('handling_hours'); + $hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null; + + $payload = [ + 'platform' => $platform, + 'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null, + 'violation_type' => mb_substr($type, 0, 500), + 'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null, + 'alert_level' => $needsAlert ? $level : null, + 'needs_alert' => $needsAlert ? 1 : 0, + 'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null, + 'handling_hours' => $hours, + 'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null, + 'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort, + 'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0, + 'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null, + 'updated_at' => now(), + ]; + + if ($this->option('dry-run')) { + $skipped += 0; + $updated += 0; + $created += 0; + continue; + } + + $exists = $connection->table(StoreViolationRuleRepository::TABLE) + ->where('platform', $platform) + ->where('violation_type', $payload['violation_type']) + ->exists(); + + if ($exists) { + $connection->table(StoreViolationRuleRepository::TABLE) + ->where('platform', $platform) + ->where('violation_type', $payload['violation_type']) + ->update($payload); + $updated++; + } else { + $connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [ + 'created_at' => now(), + ]); + $created++; + } + } + + $this->info(sprintf( + '字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。', + $this->option('dry-run') ? '(试运行,未写库)' : '完成', + $created, + $updated, + $skipped, + )); + $this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalcStoreViolationAlerts.php b/app/Console/Commands/RecalcStoreViolationAlerts.php index 1698581e..2e381924 100644 --- a/app/Console/Commands/RecalcStoreViolationAlerts.php +++ b/app/Console/Commands/RecalcStoreViolationAlerts.php @@ -34,7 +34,7 @@ class RecalcStoreViolationAlerts extends Command $total = 0; $query = $connection->table(StoreViolationRecordRepository::TABLE) - ->select(['id', 'platform', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline']) + ->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline']) ->orderBy('id'); if ($platform !== '') { $query->where('platform', $platform); @@ -47,7 +47,13 @@ class RecalcStoreViolationAlerts extends Command ? (string) $row->violation_type : ($row->violation_reason ?? null); - $updates[] = ['id' => $row->id] + $calculator->calculate( + // upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式; + // 命中已有记录时只会更新派生字段(update 列表)。 + $updates[] = [ + 'id' => $row->id, + 'platform' => (string) $row->platform, + 'violation_no' => (string) ($row->violation_no ?? ''), + ] + $calculator->calculate( (string) $row->platform, $row->shop_name, $matchType, From b4f84167948df23ccdcc58dd189708499b1bbfdd Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 13 Oct 2026 20:13:53 +0800 Subject: [PATCH 42/44] =?UTF-8?q?=E5=BA=97=E9=93=BA=E8=BF=9D=E8=A7=84?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=EF=BC=9A=E5=B7=B2=E8=B6=85=E6=97=B6=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E9=99=90=E5=AE=9A=E9=9C=80=E6=8F=90=E9=86=92=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=EF=BC=8C=E5=AD=97=E5=85=B8=E5=AF=BC=E5=85=A5=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=85=BC=E5=AE=B9=E5=A4=A7=E7=BB=B4=E5=BA=A6=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportStoreViolationRules.php | 28 +++++++++++++------ .../StoreViolationRecordRepository.php | 2 ++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/ImportStoreViolationRules.php b/app/Console/Commands/ImportStoreViolationRules.php index 18fb67d2..bd5d234d 100644 --- a/app/Console/Commands/ImportStoreViolationRules.php +++ b/app/Console/Commands/ImportStoreViolationRules.php @@ -54,8 +54,20 @@ class ImportStoreViolationRules extends Command } try { - $sheet = IOFactory::load($file)->getActiveSheet(); - $matrix = $sheet->toArray(null, true, true, false); + $reader = IOFactory::createReaderForFile($file); + if (method_exists($reader, 'setReadDataOnly')) { + $reader->setReadDataOnly(true); + } + if (method_exists($reader, 'setReadFilter')) { + // 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。 + $reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter { + public function readCell($columnAddress, $row, $worksheetName = ''): bool + { + return $row <= 5000; + } + }); + } + $matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false); } catch (Throwable $exception) { $this->error('无法读取文件:' . $exception->getMessage()); @@ -137,18 +149,16 @@ class ImportStoreViolationRules extends Command 'updated_at' => now(), ]; - if ($this->option('dry-run')) { - $skipped += 0; - $updated += 0; - $created += 0; - continue; - } - $exists = $connection->table(StoreViolationRuleRepository::TABLE) ->where('platform', $platform) ->where('violation_type', $payload['violation_type']) ->exists(); + if ($this->option('dry-run')) { + $exists ? $updated++ : $created++; + continue; + } + if ($exists) { $connection->table(StoreViolationRuleRepository::TABLE) ->where('platform', $platform) diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php index 458d4f5e..c11c7dd0 100644 --- a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php @@ -202,6 +202,7 @@ class StoreViolationRecordRepository if (! empty($filters['overdue'])) { $query->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->where('needs_alert', true) ->whereNotNull('handling_deadline') ->where('handling_deadline', '<', now()); } @@ -311,6 +312,7 @@ class StoreViolationRecordRepository $grouped = $this->connection->table(self::TABLE) ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) ->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) ->whereNotNull('handling_deadline') ->where('handling_deadline', '<', now()) From f6497f5afc0cc979af67f91bfb1471a0a2ef7c88 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Wed, 14 Oct 2026 08:54:45 +0800 Subject: [PATCH 43/44] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE)?= =?UTF-8?q?:=20=E9=87=8D=E8=B7=91=E6=97=B6=E6=B8=85=E9=99=A4=E8=A2=AB?= =?UTF-8?q?=E5=8F=96=E4=BB=A3=E7=9A=84=E5=85=9C=E5=BA=95=E5=8C=85=E8=A3=B9?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99=E5=8C=85=E8=A3=B9=E4=B9=9F=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=9C=81=E5=B8=82=E5=8C=BA/=E5=BF=AB=E9=80=92/?= =?UTF-8?q?=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 凌晨同步时发货明细常未到齐,包裹先按自动发货仓/出库单兜底入库(无省份、无商品);之后发货明细到齐换成发货明细来源键, 旧兜底包裹因"上游未再出现"被保留,造成重复和空白省份(10 月重跑 2,226 个)。现在:旧包裹的快递单号已由本次其它包裹吐出时 视为被取代并删除;无对应的仍保留待核对。 - 商品金额无法安全匹配(或新商品明细缺失)而保留的旧包裹(10 月 28,201 个),此前整包不更新;现在商品与金额不动, 省市区、快递名称、仓库、店铺、平台、交易单号、付款时间按本次上游刷新。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 55 +++++++++++++++-- .../ShippingReplicaSynchronizerTest.php | 59 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index edcc74ae..8ae617f3 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -43,6 +43,10 @@ class ShippingReplicaSynchronizer 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no', ]; private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone']; + private const REFRESHED_PACKAGE_FIELDS = [ + 'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time', + 'receiver_state', 'receiver_city', 'receiver_district', + ]; private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier']; private const VIEW = 'rpt.Jst_SendData_View_extension'; @@ -68,6 +72,7 @@ class ShippingReplicaSynchronizer private int $packagesRetainedForMissingItems = 0; private int $packagesRetainedForUnmatchedAmount = 0; private int $staleMirrorPackages = 0; + private int $supersededRemoved = 0; /** @var array */ private array $seenSourceIds = []; @@ -174,6 +179,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; + $this->supersededRemoved = 0; $this->seenSourceIds = []; $this->expressOrderIds = []; $this->dwsFailedDays = []; @@ -261,6 +267,7 @@ class ShippingReplicaSynchronizer $result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount; $result['dws_failed_days'] = $this->dwsFailedDays; $result['stale_mirror_packages'] = $this->staleMirrorPackages; + $result['superseded_packages_removed'] = $this->supersededRemoved; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; } if ($skipFreight) { @@ -2177,9 +2184,11 @@ class ShippingReplicaSynchronizer $rows = []; $replaceIds = []; + $refresh = []; foreach ($packages as $id => $packageRows) { if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) { $this->packagesRetainedForMissingItems++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? []; @@ -2216,6 +2225,7 @@ class ShippingReplicaSynchronizer } if ($unsafeAmount) { $this->packagesRetainedForUnmatchedAmount++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $replaceIds[] = $id; @@ -2245,6 +2255,7 @@ class ShippingReplicaSynchronizer } array_push($rows, ...$packageRows); } + $this->refreshRetainedPackages($refresh); if ($replaceIds === []) { return; } @@ -2347,21 +2358,55 @@ class ShippingReplicaSynchronizer return $out; } + /** + * 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录 + * (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白; + * 其余仍保留并计数,需按源键核对。 + */ private function countUnseenMirrorPackages(string $fromS, string $toS): int { $unseen = 0; - $ids = $this->db->table('jst_oms_shipping') + $superseded = []; + $rows = $this->db->table('jst_oms_shipping') ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) - ->distinct()->pluck('source_id'); - foreach ($ids as $id) { - if (! isset($this->seenSourceIds[(int) $id])) { - $unseen++; + ->selectRaw('source_id, MAX(express_no) AS express_no')->groupBy('source_id')->get(); + foreach ($rows as $row) { + $id = (int) $row->source_id; + if (isset($this->seenSourceIds[$id])) { + continue; } + $tk = ltrim(trim((string) ($row->express_no ?? '')), '@'); + if ($tk !== '' && isset($this->emittedTrack[$tk])) { + $superseded[] = $id; + continue; + } + $unseen++; } + foreach (array_chunk($superseded, 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete(); + } + $this->supersededRemoved = count($superseded); return $unseen; } + /** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */ + private function refreshRetainedPackages(array $refresh): void + { + foreach ($refresh as $id => $new) { + $set = []; + foreach (self::REFRESHED_PACKAGE_FIELDS as $field) { + $v = $new[$field] ?? null; + if ($v !== null && trim((string) $v) !== '') { + $set[$field] = $v; + } + } + if ($set !== []) { + $this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set); + } + } + } + private function withPreservedFreight(array $payload, array $previous): array { $payload = $this->withPreservedCharges($payload, $previous); diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 85da9083..3a3182df 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1315,6 +1315,65 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame('15170678', $pending->getValue($sync)[2]); } + public function test_retained_package_refreshes_address_and_superseded_fallback_package_is_removed(): void + { + $db = DB::connection('business_mirror'); + $db->table('jst_oms_shipping')->insert([ + ['source_id' => 5000, 'sku_source_id' => 500000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'YT5', + 'barcode' => null, 'product_name' => '旧商品甲', 'amount' => 10.50, 'receiver_state' => null], + // 凌晨先按自动发货仓兜底入库的旧包裹,之后发货明细到齐由 6000 吐出同一单号 + ['source_id' => 1000000777, 'sku_source_id' => 1000000777, 'ship_time' => '2026-09-02 13:00:00', 'express_no' => 'YT6', + 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null], + ['source_id' => 1000000888, 'sku_source_id' => 1000000888, 'ship_time' => '2026-09-02 14:00:00', 'express_no' => 'ZZ9', + 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null], + ]); + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturnCallback(function (string $sql): array { + if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { + return [ + ['id' => 5000, 'inner_order_id' => 'order-1', 'logistic_id' => 'YT5', 'send_time' => '2026-09-02 12:00:00', + 'receiver_address' => '河南省-郑州市-金水区'], + ['id' => 6000, 'inner_order_id' => 'order-2', 'logistic_id' => 'YT6', 'send_time' => '2026-09-02 13:00:00', + 'receiver_address' => '江苏省-常州市-武进区'], + ]; + } + if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) { + return [ + ['o_id' => 'order-1', 'io_id' => 'outbound-1', 'l_id' => 'YT5'], + ['o_id' => 'order-2', 'io_id' => 'outbound-2', 'l_id' => 'YT6'], + ]; + } + if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) { + return [ + ['io_id' => 'outbound-1', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品甲', 'volume' => null], + ['io_id' => 'outbound-2', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品乙', 'volume' => null], + ]; + } + + return []; + }); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->with('operations_replica')->willReturn($upstream); + + $result = (new ShippingReplicaSynchronizer($factory))->sync( + Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), + ); + + $this->assertSame(1, $result['packages_retained_for_unmatched_amount']); + $this->assertSame(1, $result['superseded_packages_removed']); + $this->assertSame(1, $result['stale_mirror_packages']); + // 保留的旧包裹:商品金额不动,省市区按上游刷新 + $kept = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole(); + $this->assertSame('旧商品甲', $kept->product_name); + $this->assertEquals(10.50, $kept->amount); + $this->assertSame('河南省', $kept->receiver_state); + $this->assertSame('郑州市', $kept->receiver_city); + // 被发货明细取代的兜底包裹删除;无对应的仍保留待核对 + $this->assertSame(0, $db->table('jst_oms_shipping')->where('source_id', 1000000777)->count()); + $this->assertSame(1, $db->table('jst_oms_shipping')->where('source_id', 1000000888)->count()); + $this->assertSame('江苏省', $db->table('jst_oms_shipping')->where('source_id', 6000)->value('receiver_state')); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property); From e26c1e467e3a15966a1243bb6b30e7f790ae4291 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Wed, 14 Oct 2026 09:56:10 +0800 Subject: [PATCH 44/44] =?UTF-8?q?=E4=B8=9A=E7=BB=A9=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=8F=96=E6=95=B0=EF=BC=9A=E5=90=88=E5=B9=B6=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E5=86=8D=E5=BD=93=E4=BD=9C=E5=8D=95=E7=AC=94?= =?UTF-8?q?=E9=87=91=E9=A2=9D=EF=BC=88=E5=AE=9E=E4=BB=98=E4=B8=8E=E5=95=86?= =?UTF-8?q?=E5=93=81=E4=BC=98=E5=85=88=E5=8F=96=E6=9C=AC=E5=8D=95=E9=9D=9E?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SyncStoreReceiptOrderMatches.php | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index e5164a41..0439bdb0 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -547,6 +547,8 @@ class SyncStoreReceiptOrderMatches extends Command // 子单的平台子订单号与主单不同时,按「子订单号 + 条码」的去重键拦不住,会把同一批 // 商品重复累加。整单没有任何主单行时退化为取全部行,避免把真实金额抹掉——与实付 // 「主单优先,无主单退化」保持同一套规则。 + // 合并发货行(merge_so_id 非空)会把组内其它订单的金额一起挂在本单号上: + // 当单号下存在「非合并行」时,商品与实付都只认非合并行,避免把合并组合计当成单笔金额。 $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_main_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_main_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); $mirror->statement( @@ -556,6 +558,15 @@ class SyncStoreReceiptOrderMatches extends Command STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no WHERE o.link_o_id IS NULL OR o.link_o_id = 0" ); + $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_plain_nos'); + $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_plain_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); + $mirror->statement( + "INSERT IGNORE INTO tmp_receipt_plain_nos (no) + SELECT DISTINCT o.so_id as no + FROM tmp_receipt_order_nos t + STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no + WHERE COALESCE(o.merge_so_id, '') = ''" + ); // 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含 // 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。 @@ -572,22 +583,26 @@ class SyncStoreReceiptOrderMatches extends Command STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id LEFT JOIN tmp_receipt_main_nos m ON m.no = o.so_id + LEFT JOIN tmp_receipt_plain_nos p ON p.no = o.so_id WHERE (o.link_o_id IS NULL OR o.link_o_id = 0) - OR m.no IS NULL + OR (m.no IS NULL AND (COALESCE(o.merge_so_id, '') = '' OR p.no IS NULL)) GROUP BY o.so_id, o.shop_name, dedup_key ) d GROUP BY so_id, shop_name" ); // 订单实付:取订单级字段(明细行上的实付会重复);拆单取主单(link_o_id 为空), - // 没有主单时退化为取该单号下的最大值。 + // 其次取「非合并行」(merge_so_id 为空——合并发货行带的是组内合计金额,不能算单笔), + // 都没有时退化为取该单号下的最大值。 $paid = $mirror->select( "SELECT so_id, shop_name, COALESCE( MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid END), + MAX(CASE WHEN COALESCE(merge_so_id, '') = '' THEN paid END), MAX(paid) ) as paid FROM ( - SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.{$paidColumn} as paid + SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, + COALESCE(o.merge_so_id, '') as merge_so_id, o.{$paidColumn} as paid FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no ) x