Merge branch 'feature/qiuxiang-jingxi-bill-tab'

This commit is contained in:
qiuxiang 2026-09-28 19:26:16 +08:00
commit cdc8164aa5
11 changed files with 406 additions and 42 deletions

View File

@ -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账户'],

View File

@ -0,0 +1,24 @@
<?php
/*
|--------------------------------------------------------------------------
| 京东账单平台口径
|--------------------------------------------------------------------------
|
| 京东日账单导出没有平台字段(单据类型两边都有,费用项/账户代码/门店编码等为空),
| 只能按店铺名区分京喜与京东:「京东账单」排除京喜店铺,「京喜账单」只取京喜店铺。
| 两个口径互补,笔数与金额之和恒等于拆分前,不修改任何底层数据。
| 口径的读取与收窄逻辑见 Modules\EcommerceFinance\JdBillPlatform 与 JdDailyBillRepository。
|
*/
return [
// 京喜店铺在日账单「店铺」列里的固定关键字(京喜自营工厂店、京喜小店等均含此词)。
'jingxi_store_name_keyword' => '京喜',
// 口径切换起始日期(按记账日期/费用结算时间,格式 Y-m-d)。null = 全量历史。
// 改成 '2026-09-01' 后:该日期起的京喜行归入京喜账单,之前的京喜行仍留在京东账单,总数保持守恒。
'scope_start_date' => null,
];

View File

@ -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. 在胜算按费用结算时间查询完整月份,记录笔数及收入、支出合计;下载该查询的全部异步导出分片。

View File

@ -18,6 +18,40 @@ namespace Modules\EcommerceFinance;
*/
class AccountBillColumns
{
/**
* 京东账单 / 京喜账单共用列定义(顺序、字段名、导出口径完全一致,只按平台拆分数据)。
*
* @var array<int,array{0:string,1:string,2:string}>
*/
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<string,array{title:string,groups?:array<int,array{0:string,1:int}>,columns:array<int,array{0:string,1:string,2:string}>}>
*/
@ -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' => [

View File

@ -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' => '得物',

View File

@ -0,0 +1,84 @@
<?php
namespace Modules\EcommerceFinance;
/**
* 京东账单的平台口径(单一数据源)。
*
* 京东日账单导出里没有平台字段:单据类型(订单/取消退款单/售后服务单/非销售单)两边都有,
* 费用项与账户代码、京东门店编码、品牌门店编号、门店名称均为空,账单项目只有「直营服务费」
* 单独属于京喜。全量 699,447 笔实测里只有店铺维度能可靠区分:
* 京喜 213 个店铺(488,464 笔)与京东 46 个店铺(210,983 笔)互不重叠,店铺号也不跨两边。
*
* 因此「京东账单」与「京喜账单」共用同一份取数逻辑,只用店铺名关键字拆数据:
* - 京东账单 = 排除京喜店铺
* - 京喜账单 = 只取京喜店铺
* 两个口径落点互补,笔数与金额之和恒等于拆分前,且不修改任何底层数据。
*/
final class JdBillPlatform
{
/** 店铺收款账户账单 › 京东账单。 */
public const BILL_TYPE_JD = 'jd-transactions';
/** 店铺收款账户账单 › 京喜账单。 */
public const BILL_TYPE_JINGXI = 'jingxi';
/** 口径配置(关键字、起始日期)统一在 config/jd_bill_platform.php,这里只读不定义。 */
private const CONFIG_KEY = 'jd_bill_platform';
/** @return list<string> 与京东日账单共用取数与渲染逻辑的页签。 */
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(),
];
}
}

View File

@ -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';

View File

@ -17,7 +17,7 @@
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
</label>
<div class="tw-filter-field tw-date" :class="{ 'is-stable-date': usesStableDateControl }">
<span class="tw-filter-label">{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}</span>
<span class="tw-filter-label">{{ isJdBillTab || billType === 'jd-wallet' ? '费用结算时间' : '发生时间' }}</span>
<div class="tw-date-row">
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('startDate')">
<flat-picker ref="startDate" :value="filters.start" :config="startDateConfig" aria-label="开始日期" placeholder="开始日期" @on-change="(_, value) => changeDate('start', value)" />
@ -39,7 +39,7 @@
<p v-if="filtersDirty && !loading" class="tw-note" role="status">筛选条件已修改,查询后可导出对应结果。</p>
</form>
<section class="tw-panel" :aria-label="`${columnsTitle || '账户'}账单明细`">
<div v-if="['jd-transactions','jd-wallet'].includes(billType) && sync" class="tw-note">来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}</div>
<div v-if="isJdBillTab && sync" class="tw-note">来源:{{ sync.source }} · 已接入月份:{{ (sync.periods || []).join('、') }} · 导入时间:{{ sync.last_succeeded_at }}</div>
<div v-if="loadError" class="tw-alert" role="alert"><span>账单加载失败,请重试</span><button type="button" @click="fetch">重新加载</button></div>
<div class="tw-toolbar">
<div><strong>{{ sourceGroups.length ? '账单明细' : columnsTitle }}</strong><span v-if="!loading" class="tw-count">{{ total }} 条</span></div>
@ -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();},

View File

@ -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);

View File

@ -0,0 +1,170 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Services\JdDailyBillImporter;
use PHPUnit\Framework\TestCase;
/**
* 京东账单 › 京喜账单的剪切口径:
* 拆分后两个页签的笔数与金额之和恒等于拆分前,且不修改底层数据。
*/
class JdJingxiBillSplitTest extends TestCase
{
private array $files = [];
protected function setUp(): void
{
parent::setUp();
$app = require dirname(__DIR__, 3) . '/bootstrap/app.php';
$app->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',
]);
}
}

View File

@ -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);
}
}