From a0c99c3e8d6a67c1908a037f9837cbe40481bdb8 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Mon, 28 Sep 2026 19:25:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BA=97=E9=93=BA=E6=94=B6?= =?UTF-8?q?=E6=AC=BE=E4=BA=AC=E5=96=9C=E8=B4=A6=E5=8D=95=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E5=B9=B6=E4=BB=8E=E4=BA=AC=E4=B8=9C=E8=B4=A6=E5=8D=95=E5=88=87?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在京东账单右侧新增京喜账单页签,复用京东日账单的取数、列定义、筛选、排序、分页与导出,只增加平台维度过滤:京东账单排除京喜店铺,京喜账单只取京喜店铺。平台口径集中在 config/jd_bill_platform.php 与 JdBillPlatform(含口径切换起始日期参数,默认全量历史),仓库通过 forPlatform() 收窄,两个口径互补、总额守恒,底层镜像数据不修改不删除。真实镜像实测:京喜 488,464 笔 + 京东 210,983 笔 = 拆分前 699,447 笔,收入/支出/差额逐项误差为 0。 --- config/business_navigation.php | 1 + config/jd_bill_platform.php | 24 +++ docs/京东日账单接入.md | 8 + .../EcommerceFinance/AccountBillColumns.php | 69 ++++--- .../Http/Controllers/AccountBills.php | 18 +- modules/EcommerceFinance/JdBillPlatform.php | 84 +++++++++ .../Repositories/JdDailyBillRepository.php | 39 ++++ .../js/components/business/AccountBills.vue | 12 +- resources/assets/js/views/business/pages.js | 6 +- .../JdJingxiBillSplitTest.php | 170 ++++++++++++++++++ tests/Unit/View/AccountBillsTableTest.php | 17 +- 11 files changed, 406 insertions(+), 42 deletions(-) create mode 100644 config/jd_bill_platform.php create mode 100644 modules/EcommerceFinance/JdBillPlatform.php create mode 100644 tests/Unit/EcommerceFinance/JdJingxiBillSplitTest.php diff --git a/config/business_navigation.php b/config/business_navigation.php index 15eb6883..b727f42f 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -425,6 +425,7 @@ $navigation = [ ['slug' => 'pinduoduo', 'title' => '拼多多账户'], ['slug' => 'jd-wallet', 'title' => '京东网银钱包账户'], ['slug' => 'jd-transactions', 'title' => '京东账单'], + ['slug' => 'jingxi', 'title' => '京喜账单'], ['slug' => 'douyin', 'title' => '抖音账户'], ['slug' => 'dewu', 'title' => '得物账户'], ['slug' => 'vipshop', 'title' => '唯品会MP账户'], diff --git a/config/jd_bill_platform.php b/config/jd_bill_platform.php new file mode 100644 index 00000000..004145e2 --- /dev/null +++ b/config/jd_bill_platform.php @@ -0,0 +1,24 @@ + '京喜', + + // 口径切换起始日期(按记账日期/费用结算时间,格式 Y-m-d)。null = 全量历史。 + // 改成 '2026-09-01' 后:该日期起的京喜行归入京喜账单,之前的京喜行仍留在京东账单,总数保持守恒。 + 'scope_start_date' => null, + +]; diff --git a/docs/京东日账单接入.md b/docs/京东日账单接入.md index 9d279fbf..276f37ba 100644 --- a/docs/京东日账单接入.md +++ b/docs/京东日账单接入.md @@ -4,6 +4,14 @@ 当前来源是胜算完整月份原始导出,尚未接入自动增量接口。页面标明已接入月份与导入时间,不将未导入月份解释为源端无数据。旧库 `jd_billFromShop2024` 尚未通过完整性核对,不作为正式来源。 +## 平台口径:京东账单与京喜账单 + +同一份日账单同时支撑「京东账单」和「京喜账单」两个页签,共用取数逻辑、列定义、筛选、排序、分页与导出,只按平台口径拆分数据。日账单导出没有平台字段:单据类型(订单/取消退款单/售后服务单/非销售单)两边都有,费用项、账户代码、京东门店编码、品牌门店编号、门店名称均为空,账单项目只有「直营服务费」单独属于京喜;实测全量 699,447 笔中只有店铺维度可靠区分(京喜 213 个店铺 488,464 笔与京东 46 个店铺 210,983 笔互不重叠,店铺号也不跨两边)。 + +口径集中定义在 `config/jd_bill_platform.php`(京喜店铺名关键字、口径切换起始日期),由 `Modules\EcommerceFinance\JdBillPlatform` 读取,`JdDailyBillRepository::forPlatform()` 在查询层收窄:京东账单排除京喜店铺,京喜账单只取京喜店铺。两个口径互补,笔数与金额之和恒等于拆分前,底层镜像数据不修改、不删除。 + +`scope_start_date` 默认 `null`(全量历史);改为某个 `Y-m-d` 后,该记账日期起的京喜行归入京喜账单,之前的京喜行仍留在京东账单,总数保持守恒。新增第三个小平台时,在同一配置与 `JdBillPlatform` 中扩展口径即可,不要在各处散写 if-else。 + ## 同步及回滚 1. 在胜算按费用结算时间查询完整月份,记录笔数及收入、支出合计;下载该查询的全部异步导出分片。 diff --git a/modules/EcommerceFinance/AccountBillColumns.php b/modules/EcommerceFinance/AccountBillColumns.php index f43f3129..013e5bdb 100644 --- a/modules/EcommerceFinance/AccountBillColumns.php +++ b/modules/EcommerceFinance/AccountBillColumns.php @@ -18,6 +18,40 @@ namespace Modules\EcommerceFinance; */ class AccountBillColumns { + /** + * 京东账单 / 京喜账单共用列定义(顺序、字段名、导出口径完全一致,只按平台拆分数据)。 + * + * @var array + */ + private const JD_BILL_COLUMNS = [ + ['记录编码', 'record_code', 'num'], + ['店铺', 'store_name', 'text'], + ['自定义备注', 'custom_remark', 'text'], + ['账单项目', 'bill_item', 'tag'], + ['线上单号', 'payload.online_order_no', 'num'], + ['单据编号', 'payload.document_no', 'num'], + ['订单编号', 'order_no', 'num'], + ['商户订单号', 'payload.merchant_order_no', 'num'], + ['单据类型', 'payload.document_type', 'text'], + ['商品编码', 'payload.product_id', 'num'], + ['商品名称', 'payload.product_name', 'text'], + ['费用结算时间', 'occurred_at', 'date'], + ['费用发生时间', 'payload.cost_happened_at', 'date'], + ['费用项', 'payload.cost_name', 'text'], + ['收入金额', 'income', 'money_in'], + ['支出金额', 'expense', 'money_out'], + ['币种', 'payload.currency', 'text'], + ['钱包结算备注', 'payload.settle_remark', 'text'], + ['备注', 'remark', 'text'], + ['店铺号', 'payload.shop_no', 'num'], + ['京东门店编码', 'payload.jd_shop_no', 'num'], + ['品牌门店编号', 'payload.brand_shop_no', 'num'], + ['门店名称', 'payload.shop_name', 'text'], + ['账户代码', 'payload.account_code', 'text'], + ['修改时间', 'updated_at', 'date'], + ['修改人', 'modifier', 'text'], + ]; + /** * @return array,columns:array}> */ @@ -156,36 +190,15 @@ class AccountBillColumns ], ], + // 京喜账单与京东账单共用同一份列定义,只按平台口径拆分数据。 'jd-transactions' => [ 'title' => '京东账单', - 'columns' => [ - ['记录编码', 'record_code', 'num'], - ['店铺', 'store_name', 'text'], - ['自定义备注', 'custom_remark', 'text'], - ['账单项目', 'bill_item', 'tag'], - ['线上单号', 'payload.online_order_no', 'num'], - ['单据编号', 'payload.document_no', 'num'], - ['订单编号', 'order_no', 'num'], - ['商户订单号', 'payload.merchant_order_no', 'num'], - ['单据类型', 'payload.document_type', 'text'], - ['商品编码', 'payload.product_id', 'num'], - ['商品名称', 'payload.product_name', 'text'], - ['费用结算时间', 'occurred_at', 'date'], - ['费用发生时间', 'payload.cost_happened_at', 'date'], - ['费用项', 'payload.cost_name', 'text'], - ['收入金额', 'income', 'money_in'], - ['支出金额', 'expense', 'money_out'], - ['币种', 'payload.currency', 'text'], - ['钱包结算备注', 'payload.settle_remark', 'text'], - ['备注', 'remark', 'text'], - ['店铺号', 'payload.shop_no', 'num'], - ['京东门店编码', 'payload.jd_shop_no', 'num'], - ['品牌门店编号', 'payload.brand_shop_no', 'num'], - ['门店名称', 'payload.shop_name', 'text'], - ['账户代码', 'payload.account_code', 'text'], - ['修改时间', 'updated_at', 'date'], - ['修改人', 'modifier', 'text'], - ], + 'columns' => self::JD_BILL_COLUMNS, + ], + + 'jingxi' => [ + 'title' => '京喜账单', + 'columns' => self::JD_BILL_COLUMNS, ], 'douyin' => [ diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index e97ae04f..16ba81d9 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -6,6 +6,7 @@ use App\Utilities\SidebarPermissions; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Modules\EcommerceFinance\AccountBillColumns; +use Modules\EcommerceFinance\JdBillPlatform; use Modules\EcommerceFinance\Models\AccountBill; use Modules\EcommerceFinance\Repositories\JdDailyBillRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository; @@ -16,6 +17,9 @@ use RuntimeException; class AccountBills extends Controller { + /** 走共享业务镜像的账户类型(其余类型读本系统账单表 ec_account_bills)。 */ + private const MIRROR_TABS = ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jingxi', 'jd-wallet']; + /** * 通用账单列表(店铺收款账户账单 › 各账户类型 tab)。 * 经 admin 路由组(web 会话)暴露,前端带 CSRF 调用。 @@ -31,7 +35,7 @@ class AccountBills extends Controller abort(404, '未知的账户类型。'); } - if (in_array($billType, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) { + if (in_array($billType, self::MIRROR_TABS, true)) { return $request->boolean('export') ? $this->export($request, $billType) : $this->mirrorIndex($request, $billType); @@ -128,7 +132,7 @@ class AccountBills extends Controller abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); - if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) { + if (in_array($bill_type, self::MIRROR_TABS, true)) { return $this->exportMirror($request, $bill_type); } @@ -165,7 +169,7 @@ class AccountBills extends Controller ]); } - abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true), 404, '该账户类型没有可用的筛选选项。'); + abort_unless(in_array($bill_type, self::MIRROR_TABS, true), 404, '该账户类型没有可用的筛选选项。'); $label = $this->mirrorLabel($bill_type); @@ -412,8 +416,13 @@ class AccountBills extends Controller private function mirrorRepository(string $billType): object { + if (JdBillPlatform::isJdBillTab($billType)) { + // 京东账单与京喜账单共用同一个京东日账单仓库,只用平台口径区分数据。 + return app(JdDailyBillRepository::class) + ->forPlatform(JdBillPlatform::platformForBillType($billType)); + } + return match ($billType) { - 'jd-transactions' => app(JdDailyBillRepository::class), 'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class), 'pinduoduo' => app(StoreReceivingPinduoduoBillMirrorRepository::class), 'dewu' => app(StoreReceivingDewuBillMirrorRepository::class), @@ -425,6 +434,7 @@ class AccountBills extends Controller { return match ($billType) { 'jd-transactions' => '京东账单', + 'jingxi' => '京喜账单', 'jd-wallet' => '京东钱包', 'pinduoduo' => '拼多多', 'dewu' => '得物', diff --git a/modules/EcommerceFinance/JdBillPlatform.php b/modules/EcommerceFinance/JdBillPlatform.php new file mode 100644 index 00000000..3be97c19 --- /dev/null +++ b/modules/EcommerceFinance/JdBillPlatform.php @@ -0,0 +1,84 @@ + 与京东日账单共用取数与渲染逻辑的页签。 */ + public static function tabs(): array + { + return [self::BILL_TYPE_JD, self::BILL_TYPE_JINGXI]; + } + + public static function isJdBillTab(string $billType): bool + { + return in_array($billType, self::tabs(), true); + } + + /** 页签对应的平台口径:'jingxi'、'jd',非京东系列页签返回 null。 */ + public static function platformForBillType(string $billType): ?string + { + return match ($billType) { + self::BILL_TYPE_JINGXI => 'jingxi', + self::BILL_TYPE_JD => 'jd', + default => null, + }; + } + + /** 京喜店铺名关键字。 */ + public static function storeNameKeyword(): string + { + $keyword = trim((string) config(self::CONFIG_KEY . '.jingxi_store_name_keyword', '京喜')); + + return $keyword === '' ? '京喜' : $keyword; + } + + /** 口径切换起始日期;null = 全量历史。 */ + public static function scopeStartDate(): ?string + { + $start = config(self::CONFIG_KEY . '.scope_start_date'); + + return is_string($start) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) === 1 ? $start : null; + } + + /** + * 店铺维度的过滤条件。 + * + * @param 'jingxi'|'jd'|null $platform + * @return array{mode:string,keyword:string,start_date:?string}|null mode: only=只取该平台,exclude=排除该平台 + */ + public static function storeNameScope(?string $platform): ?array + { + if (! in_array($platform, ['jingxi', 'jd'], true)) { + return null; + } + + return [ + 'mode' => $platform === 'jingxi' ? 'only' : 'exclude', + 'keyword' => self::storeNameKeyword(), + 'start_date' => self::scopeStartDate(), + ]; + } +} diff --git a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php index 388d11bc..cabb79fa 100644 --- a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php +++ b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php @@ -4,12 +4,25 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; +use Modules\EcommerceFinance\JdBillPlatform; use RuntimeException; class JdDailyBillRepository { + /** 平台口径(JdBillPlatform::platformForBillType);null = 不做平台拆分,保持原行为。 */ + private ?string $platform = null; + public function __construct(private readonly DatabaseManager $databases) {} + /** 京东账单/京喜账单共用本仓库,用平台口径区分数据范围。 */ + public function forPlatform(?string $platform): static + { + $clone = clone $this; + $clone->platform = $platform; + + return $clone; + } + public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { $periods = $this->periods(); @@ -68,6 +81,7 @@ class JdDailyBillRepository private function query(array $filters, \Illuminate\Support\Collection $periods): Builder { $q = $this->db()->table('jd_daily_bill_rows')->whereIn('generation', $periods->pluck('active_generation')->all()); + $this->applyPlatformScope($q); foreach (['store_name', 'bill_item'] as $field) { if (($filters[$field] ?? '') !== '') $q->where($field, $filters[$field]); } @@ -80,6 +94,31 @@ class JdDailyBillRepository return $q; } + /** 按平台口径收窄数据;两个口径互补,笔数与金额之和恒等于拆分前。 */ + private function applyPlatformScope(Builder $query): void + { + $scope = JdBillPlatform::storeNameScope($this->platform); + if ($scope === null) { + return; + } + + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $scope['keyword']) . '%'; + $start = $scope['start_date']; + + if ($scope['mode'] === 'only') { + $query->where('store_name', 'like', $like); + if ($start !== null) $query->where('occurred_at', '>=', $start . ' 00:00:00'); + + return; + } + + // 起始日期之前取到的京喜行仍留在京东账单,保证两边相加等于拆分前。 + $query->where(function (Builder $sub) use ($like, $start): void { + $sub->where('store_name', 'not like', $like); + if ($start !== null) $sub->orWhere('occurred_at', '<', $start . ' 00:00:00'); + }); + } + private function ordered(Builder $q, string $sort, string $direction): Builder { $sort = in_array($sort, ['occurred_at', 'record_code', 'income', 'expense'], true) ? $sort : 'occurred_at'; diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 43f4be91..ddbf6e50 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -17,7 +17,7 @@
- {{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }} + {{ isJdBillTab || billType === 'jd-wallet' ? '费用结算时间' : '发生时间' }}
@@ -39,7 +39,7 @@

筛选条件已修改,查询后可导出对应结果。

-
来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}
+
来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}
{{ sourceGroups.length ? '账单明细' : columnsTitle }}{{ total }} 条
@@ -74,6 +74,8 @@ import flatPicker from 'vue-flatpickr-component'; import { Mandarin } from 'flatpickr/dist/l10n/zh'; import 'flatpickr/dist/flatpickr.css'; const SORTABLE = ['occurred_at','income','expense','balance','record_code']; +// 京东账单与京喜账单共用同一份列定义与同一套交互,只在数据口径上拆分。 +const JD_BILL_TABS = ['jd-transactions','jingxi']; const dateConfig = {dateFormat:'Y-m-d',locale:Mandarin,disableMobile:true,allowInput:false,onReady:(_,value,picker)=>picker.calendarContainer.classList.add('account-bill-calendar')}; const emptyFilters = billType => ({store_name:'',bill_item:'',start:'',end:'',keyword:'',keyword_match:billType==='douyin'?'exact-record':'contains'}); export default { @@ -81,10 +83,12 @@ export default { data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};}, computed:{ isDouyin(){return this.billType==='douyin';}, + // 费用结算时间标签、来源说明与收支净额统计口径按京东系列页签(京东账单/京喜账单)统一。 + isJdBillTab(){return JD_BILL_TABS.indexOf(this.billType)!==-1;}, // Every account-bill tab uses the same explicit picker path. Keeping one // control avoids browser-native date inputs appearing on older tabs. usesStableDateControl(){return true;}, - usesMirrorOptions(){return ['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1;}, + usesMirrorOptions(){return ['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1;}, invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);}, startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};}, endDateConfig(){return {...dateConfig,minDate:this.filters.start||null};}, @@ -92,7 +96,7 @@ export default { canExport(){return !this.loading&&!this.loadError&&!this.invalidDateRange&&!this.filtersDirty&&this.total>0&&(this.exportMode==='filtered-all'||this.visibleColumns.length>0);}, visibleColumns(){return this.configuredColumns.filter(column=>column.visible);}, visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);}, - stats(){const netLabel=['jd-transactions','vipshop-jx'].indexOf(this.billType)!==-1;return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:netLabel?'收支净额':(this.billType==='jd-wallet'?'账户余额合计(最新)':'期末余额(最新)'),value:this.money(this.summary.balance),note:netLabel?'按当前筛选':(this.billType==='jd-wallet'?'筛选结果中每个店铺账户最新一笔':'最新一条记录'),className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];}, + stats(){const netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1;return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:netLabel?'收支净额':(this.billType==='jd-wallet'?'账户余额合计(最新)':'期末余额(最新)'),value:this.money(this.summary.balance),note:netLabel?'按当前筛选':(this.billType==='jd-wallet'?'筛选结果中每个店铺账户最新一笔':'最新一条记录'),className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];}, rangeStart(){return this.total===0?0:(this.currentPage-1)*this.perPage+1;},rangeEnd(){return Math.min(this.currentPage*this.perPage,this.total);} }, mounted(){if(this.usesMirrorOptions)this.fetchFilterOptions();this.fetch();}, diff --git a/resources/assets/js/views/business/pages.js b/resources/assets/js/views/business/pages.js index fb9e24d8..39539ca2 100644 --- a/resources/assets/js/views/business/pages.js +++ b/resources/assets/js/views/business/pages.js @@ -5,7 +5,7 @@ * * 店铺收款账户账单(store-receiving-bills)下各 tab: * - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像) - * - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / dewu / vipshop / vipshop-jx + * - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / jingxi / dewu / vipshop / vipshop-jx * → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分) * - douyin → AccountBills 展示共享原始账单镜像中的头条放心购渠道 */ @@ -16,8 +16,8 @@ import Vue from 'vue'; import TaobaoAlipayBills from './../../components/business/TaobaoAlipayBills.vue'; import AccountBills from './../../components/business/AccountBills.vue'; -const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop', 'vipshop-jx']; -const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop', 'vipshop-jx']; +const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx']; +const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx']; function pageContext(pathname = window.location.pathname) { const parts = (pathname || '').split('/').filter(Boolean); diff --git a/tests/Unit/EcommerceFinance/JdJingxiBillSplitTest.php b/tests/Unit/EcommerceFinance/JdJingxiBillSplitTest.php new file mode 100644 index 00000000..10a281ff --- /dev/null +++ b/tests/Unit/EcommerceFinance/JdJingxiBillSplitTest.php @@ -0,0 +1,170 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + DB::purge('business_mirror'); + (require database_path('migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php'))->up(); + } + + protected function tearDown(): void + { + foreach ($this->files as $file) unlink($file); + parent::tearDown(); + } + + public function test_split_keeps_京东_and_京喜_totals_equal_to_the_unsplit_bill(): void + { + $this->importFixture(); + + $all = $this->repository(null)->paginate([], 'occurred_at', 'desc', 1, 10); + $jd = $this->repository(JdBillPlatform::BILL_TYPE_JD)->paginate([], 'occurred_at', 'desc', 1, 10); + $jingxi = $this->repository(JdBillPlatform::BILL_TYPE_JINGXI)->paginate([], 'occurred_at', 'desc', 1, 10); + + // 全量共 6 笔;京喜 4 笔(含京喜小店),京东账单 2 笔。 + $this->assertSame(6, $all['total']); + $this->assertSame(4, $jingxi['total']); + $this->assertSame(2, $jd['total']); + + foreach (['count', 'income', 'expense', 'balance'] as $field) { + $this->assertSame( + round($all['summary'][$field], 2), + round($jd['summary'][$field] + $jingxi['summary'][$field], 2), + "拆分后 {$field} 不守恒", + ); + } + } + + public function test_same_day_rows_are_only_visible_in_the_jingxi_tab(): void + { + $this->importFixture(); + + $filters = ['start' => '2026-09-23', 'end' => '2026-09-23', 'store_name' => '京喜-安隅京喜自营工厂店']; + $jingxi = $this->repository(JdBillPlatform::BILL_TYPE_JINGXI)->paginate($filters, 'occurred_at', 'desc', 1, 10); + $jd = $this->repository(JdBillPlatform::BILL_TYPE_JD)->paginate($filters, 'occurred_at', 'desc', 1, 10); + + $this->assertSame(2, $jingxi['total']); + $this->assertSame(['京喜-安隅京喜自营工厂店'], array_unique(array_column($jingxi['data'], 'store_name'))); + $this->assertSame(0, $jd['total']); + $this->assertSame([], $jd['data']); + } + + public function test_cutoff_date_keeps_earlier_jingxi_rows_in_the_jd_bill(): void + { + $this->importFixture(); + config(['jd_bill_platform.scope_start_date' => '2026-09-23']); + + $all = $this->repository(null)->paginate([], 'occurred_at', 'desc', 1, 10); + $jd = $this->repository(JdBillPlatform::BILL_TYPE_JD)->paginate([], 'occurred_at', 'desc', 1, 10); + $jingxi = $this->repository(JdBillPlatform::BILL_TYPE_JINGXI)->paginate([], 'occurred_at', 'desc', 1, 10); + + // 只有 09-23 起的京喜行被切走,09-10 的京喜行仍留在京东账单,总数守恒。 + $this->assertSame(3, $jingxi['total']); + $this->assertSame(3, $jd['total']); + $this->assertSame($all['total'], $jd['total'] + $jingxi['total']); + $this->assertSame( + round($all['summary']['income'], 2), + round($jd['summary']['income'] + $jingxi['summary']['income'], 2), + ); + } + + public function test_filter_options_follow_the_tab_scope(): void + { + $this->importFixture(); + + $jingxi = $this->repository(JdBillPlatform::BILL_TYPE_JINGXI)->filterOptions(); + $jd = $this->repository(JdBillPlatform::BILL_TYPE_JD)->filterOptions(); + + $this->assertSame(['京喜-安隅京喜自营工厂店', '京喜小店-简尚家居家具制造店'], $jingxi['shops']); + $this->assertSame(['京东-慕加旗舰店'], $jd['shops']); + } + + public function test_export_follows_the_tab_scope(): void + { + $this->importFixture(); + + $jingxi = array_column(iterator_to_array($this->repository(JdBillPlatform::BILL_TYPE_JINGXI)->exportRows([], 'occurred_at', 'asc')), 'store_name'); + $jd = array_column(iterator_to_array($this->repository(JdBillPlatform::BILL_TYPE_JD)->exportRows([], 'occurred_at', 'asc')), 'store_name'); + + $this->assertCount(4, $jingxi); + $this->assertCount(2, $jd); + $this->assertSame([], array_intersect($jingxi, $jd)); + } + + public function test_jingxi_tab_reuses_the_jd_bill_columns_and_sits_next_to_it(): void + { + $jd = AccountBillColumns::for(JdBillPlatform::BILL_TYPE_JD); + $jingxi = AccountBillColumns::for(JdBillPlatform::BILL_TYPE_JINGXI); + + $this->assertSame('京喜账单', $jingxi['title']); + $this->assertSame($jd['columns'], $jingxi['columns']); + $this->assertSame([], $jingxi['groups']); + + $tabs = collect(collect(config('business_navigation'))->firstWhere('slug', 'billing-transactions')['pages']) + ->firstWhere('slug', 'store-receiving-bills')['tabs']; + $slugs = array_column($tabs, 'slug'); + $this->assertSame('jingxi', $slugs[array_search('jd-transactions', $slugs, true) + 1]); + } + + private function repository(?string $platform): JdDailyBillRepository + { + return app(JdDailyBillRepository::class)->forPlatform( + $platform === null ? null : JdBillPlatform::platformForBillType($platform), + ); + } + + /** 6 笔:京喜 4 笔(含「京喜小店」店铺)、京东 2 笔,横跨 2026-09-10 与 2026-09-23 两个记账日期。 */ + private function importFixture(): void + { + $rows = [ + $this->row('1', '京喜-安隅京喜自营工厂店', '2026-09-10 09:00:00', '10.00', '0.00'), + $this->row('2', '京喜-安隅京喜自营工厂店', '2026-09-23 09:00:00', '20.00', '0.00'), + $this->row('3', '京喜-安隅京喜自营工厂店', '2026-09-23 10:00:00', '0.00', '5.00'), + $this->row('4', '京喜小店-简尚家居家具制造店', '2026-09-23 11:00:00', '7.00', '0.00'), + $this->row('5', '京东-慕加旗舰店', '2026-09-23 12:00:00', '3.00', '0.00'), + $this->row('6', '京东-慕加旗舰店', '2026-09-10 12:00:00', '0.00', '2.00'), + ]; + $this->import($rows, 6, '40.00', '7.00'); + } + + private function import(array $rows, int $count, string $income, string $expense): array + { + $path = tempnam(sys_get_temp_dir(), 'jd-jingxi-'); + $this->files[] = $path; + $file = fopen($path, 'wb'); + fputcsv($file, array_keys($rows[0])); + foreach ($rows as $row) fputcsv($file, array_values($row)); + fclose($file); + + return app(JdDailyBillImporter::class)->import($path, '2026-09', $count, $income, $expense); + } + + private function row(string $code, string $store, string $occurredAt, string $income, string $expense): array + { + return array_replace(array_fill_keys(array_values(JdDailyBillImporter::PAYLOAD_FIELDS), ''), [ + '记录编码' => $code, '店铺' => $store, '账单项目' => '交易服务费', '订单编号' => '360640400000000' . $code, + '单据编号' => '1010012608312386678178000' . $code, '费用结算时间' => $occurredAt, + '费用发生时间' => $occurredAt, '收入金额' => $income, '支出金额' => $expense, '币种' => 'CNY', + ]); + } +} diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index 66d948de..3cee2573 100644 --- a/tests/Unit/View/AccountBillsTableTest.php +++ b/tests/Unit/View/AccountBillsTableTest.php @@ -14,7 +14,7 @@ class AccountBillsTableTest extends TestCase $this->assertStringContainsString("usesStableDateControl(){return true;}", $source); $this->assertStringContainsString("{ 'is-stable-date': usesStableDateControl }", $source); $this->assertStringContainsString('.tw-date.is-stable-date .tw-date-control', $source); - $this->assertStringContainsString("['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1", $source); + $this->assertStringContainsString("['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1", $source); $this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source); $this->assertStringContainsString('page-key="store-receiving-bills"', $source); $this->assertStringContainsString(':tab-key="billType"', $source); @@ -30,12 +30,23 @@ class AccountBillsTableTest extends TestCase $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/views/business/pages.js'); $this->assertStringContainsString( - "const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];", + "const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];", $source, ); $this->assertStringContainsString( - "const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];", + "const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];", $source, ); } + + public function test_jd_and_jingxi_tabs_share_the_same_date_and_summary_behaviour(): void + { + $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue'); + + $this->assertStringContainsString("const JD_BILL_TABS = ['jd-transactions','jingxi'];", $source); + $this->assertStringContainsString("isJdBillTab(){return JD_BILL_TABS.indexOf(this.billType)!==-1;}", $source); + $this->assertStringContainsString("{{ isJdBillTab || billType === 'jd-wallet' ? '费用结算时间' : '发生时间' }}", $source); + $this->assertStringContainsString('v-if="isJdBillTab && sync"', $source); + $this->assertStringContainsString("netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1", $source); + } }