功能:合并淘系支付宝上游账单

This commit is contained in:
WuYongJie 2026-07-30 10:22:37 +08:00
parent b5097cd2df
commit 8a8ea93c63
6 changed files with 603 additions and 103 deletions

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Business;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Banking\MarketingTaobaoAlipayBill;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Routing\Controller;
@ -11,6 +10,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
@ -258,49 +258,76 @@ class Pages extends Controller
return null;
}
// 淘系支付宝 - 使用独立的流水账单表(8字段)
// 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列)
if ($tab === 'taobao-alipay') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$query = MarketingTaobaoAlipayBill::query();
$repository = app(TmallTaobaoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
if ($storeName !== '') {
$query->where('store_name', $storeName);
}
if ($fromDate !== '') {
$query->where('transaction_date', '>=', $fromDate);
}
if ($toDate !== '') {
$query->where('transaction_date', '<=', $toDate);
}
$result = $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
)
);
$summary = $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
)
);
$storeOptions = $cache->remember(
'taobao-alipay-bills.options',
[],
fn () => $repository->storeOptions()
);
// 筛选项下拉用的店铺列表(去重)
$storeOptions = MarketingTaobaoAlipayBill::select('store_name')
->whereNotNull('store_name')
->distinct()
->orderBy('store_name')
->pluck('store_name')
->toArray();
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_taobao_alipay_bills.partials.embedded-table',
'bills' => $query->orderBy('transaction_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25))
->appends([
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
]),
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}

View File

@ -0,0 +1,244 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 淘系(天猫+淘宝)支付宝账户流水 - 子功能页面数据仓库
*
* 数据来源:上游 JianSun.dbo.tmall_spread_bills
* 表1列:店铺名称 | 记账日期 | 交易日期 | 金额 | 余额 | 备注 | finType | 收支类型 | 交易类型
*
* 字段映射(上游表 -> 表1 列):
* shopName -> 店铺名称
* uniTransTime -> 记账日期(datetime)
* transTime -> 交易日期(date)
* amount(分) -> 金额(元)—— 上游以「分」存储,返回时除以 100
* balance(分) -> 余额(元)—— 上游以「分」存储,返回时除以 100
* comment -> 备注
* finType -> finType(CREDIT/DEBIT 原值)
* finType 派生 -> 收支类型(CREDIT=支出, DEBIT=收入)
* bizType/tradeType -> 交易类型(CASH_DEDUCT=扣款, INTEL_CHARGE_POST=充值 等)
*
* 金额单位换算:上游 amount/balance 均以「分」存储,前端期望「元」,
* 因此 normalize() 中统一除以 100,summary() 也基于「元」单位汇总。
*/
class TmallTaobaoSpreadBillRepository
{
private const TABLE = '[dbo].[tmall_spread_bills]';
private const SOURCE = 'jiansun';
/** finType -> 中文收支类型(CREDIT=支出, DEBIT=收入,与天猫平台口径一致)*/
private const FIN_TYPE_MAP = [
'CREDIT' => '支出',
'DEBIT' => '收入',
];
/** bizType -> 中文交易类型(天猫平台 bizType 枚举)*/
private const BIZ_TYPE_MAP = [
'CASH_DEDUCT' => '扣款',
'INTEL_CHARGE_POST' => '充值',
'TRANSFER_IN' => '转入',
'TRANSFER_OUT' => '转出',
'REFUND' => '退款',
'WITHDRAW' => '提现',
];
/** tradeType 兜底映射 */
private const TRADE_TYPE_MAP = [
'DEDUCT' => '扣款',
'CHARGE' => '充值',
'TRANSFER_IN' => '转入',
'TRANSFER_OUT' => '转出',
'REFUND' => '退款',
'WITHDRAW' => '提现',
];
private UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
$this->query = $factory->forSource(self::SOURCE);
}
/**
* 店铺下拉选项(全部去重)
*
* @return string[]
*/
public function storeOptions(): array
{
$rows = $this->query->select(
'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName'
);
return array_map(static fn (array $r) => (string) $r['shopName'], $rows);
}
/**
* 分页查询淘系支付宝账户流水,返回表1 9 列结构。
*
* SQL Server 2012 兼容:用 ROW_NUMBER() OVER 做分页,避免 OFFSET/FETCH。
*
* @param string|null $flowType 收支类型筛选:income=收入,expense=支出
* @return array{rows: array<int,array<string,mixed>>, total: int}
*/
public function paginate(
?string $storeName = null,
?string $fromDate = null, // Y-m-d(按交易日期 transTime 过滤)
?string $toDate = null, // Y-m-d
?string $flowType = null, // 'income' | 'expense' | null
int $page = 1,
int $pageSize = 25
): array {
$page = max(1, $page);
$pageSize = max(1, min(200, $pageSize));
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// 总数
$total = (int) $this->query->scalar(
'SELECT COUNT(*) FROM ' . self::TABLE . $whereSql,
$params
);
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
// 分页(按记账日期 uniTransTime DESC, id DESC 排序,与列表展示方向一致)
$offsetStart = ($page - 1) * $pageSize + 1;
$offsetEnd = $page * $pageSize;
$pagedSql = 'SELECT * FROM ('
. 'SELECT src.id, src.shopName, src.uniTransTime, src.transTime, '
. 'src.amount, src.balance, src.comment, src.finType, '
. 'src.bizType, src.tradeType, '
. 'ROW_NUMBER() OVER (ORDER BY src.uniTransTime DESC, src.id DESC) AS rn '
. 'FROM ' . self::TABLE . ' src' . $whereSql
. ') AS t WHERE t.rn BETWEEN ? AND ? ORDER BY t.rn';
$rawRows = $this->query->select(
$pagedSql,
array_merge($params, [$offsetStart, $offsetEnd])
);
return [
'rows' => array_map(fn (array $r) => $this->normalize($r), $rawRows),
'total' => $total,
];
}
/**
* 当前筛选条件下的汇总(金额单位:元)。
*
* @param string|null $flowType 'income'/'expense'/null
* @return array{income_total: float, expense_total: float, net: float, count: int}
*/
public function summary(
?string $storeName = null,
?string $fromDate = null,
?string $toDate = null,
?string $flowType = null
): array {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// amount 单位为「分」,汇总时除以 100 转为「元」
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, '
. 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
// 分 -> 元
$income = ((float) ($row['income_total_raw'] ?? 0)) / 100;
$expense = ((float) ($row['expense_total_raw'] ?? 0)) / 100;
return [
'income_total' => $income,
'expense_total' => $expense,
'net' => $income - $expense,
'count' => (int) ($row['cnt'] ?? 0),
];
}
/**
* 构造 WHERE 子句。
*
* @param string|null $flowType 'income'/'expense'/null
* @return array{0:string,1:array<int,mixed>}
*/
private function buildWhere(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType = null
): array {
$conditions = [];
$params = [];
if ($storeName !== null && $storeName !== '') {
$conditions[] = 'shopName = ?';
$params[] = $storeName;
}
// 日期按交易日期 transTime 过滤;Y-m-d 校验后字符串比较 date 即可
if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) {
$conditions[] = 'transTime >= ?';
$params[] = $fromDate;
}
if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) {
$conditions[] = 'transTime <= ?';
$params[] = $toDate;
}
// 收支类型筛选:income -> finType='DEBIT'(收入),expense -> finType='CREDIT'(支出)
if ($flowType === 'income') {
$conditions[] = 'finType = \'DEBIT\'';
} elseif ($flowType === 'expense') {
$conditions[] = 'finType = \'CREDIT\'';
}
$whereSql = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
return [$whereSql, $params];
}
/**
* 把上游行归一化为表1 9 列结构(前端可直接消费)。
* 金额单位从「分」转为「元」。
*/
private function normalize(array $row): array
{
$finType = (string) ($row['finType'] ?? '');
$bizType = (string) ($row['bizType'] ?? '');
$tradeType = (string) ($row['tradeType'] ?? '');
// 交易类型:优先 bizType,其次 tradeType,最后返回原始 bizType
$transactionType = self::BIZ_TYPE_MAP[$bizType]
?? self::TRADE_TYPE_MAP[$tradeType]
?? $bizType;
return [
// 表1 9 列
'store_name' => (string) ($row['shopName'] ?? ''),
'bookkeeping_time' => (string) ($row['uniTransTime'] ?? ''),
'transaction_date' => (string) ($row['transTime'] ?? ''),
'amount' => ((float) ($row['amount'] ?? 0)) / 100,
'balance' => ((float) ($row['balance'] ?? 0)) / 100,
'comment' => (string) ($row['comment'] ?? ''),
'fin_type' => $finType,
'income_expense_type' => self::FIN_TYPE_MAP[$finType] ?? '',
'transaction_type' => $transactionType,
// 原始字段保留
'biz_type_raw' => $bizType,
'trade_type_raw' => $tradeType,
];
}
}

View File

@ -8,6 +8,7 @@ use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
/**
* 将活跃查询登记项还原成仓库调用,供定时任务刷新共享快照。
@ -19,6 +20,7 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
private readonly EdgeBandingRepository $edgeBanding,
private readonly GoodsReconciliationRepository $goods,
private readonly PinduoduoSpreadBillRepository $pinduoduo,
private readonly TmallTaobaoSpreadBillRepository $taobaoAlipay,
private readonly SupplierPayableRepository $supplier,
) {
}
@ -76,6 +78,22 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher
),
'pinduoduo-bills.options' => $this->pinduoduo->storeOptions(),
'taobao-alipay-bills.list' => $this->taobaoAlipay->paginate(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$limit
),
'taobao-alipay-bills.summary' => $this->taobaoAlipay->summary(
$storeName,
$fromDate,
$toDate,
$flowType
),
'taobao-alipay-bills.options' => $this->taobaoAlipay->storeOptions(),
'return-reconciliations.list' => $this->supplier->returnReconciliations($filters, $page, $limit),
'return-reconciliations.summary' => $this->supplier->returnReconciliationSummary($filters),
'return-reconciliations.options' => $this->supplier->filterOptionsForReturn(),

View File

@ -1,4 +1,5 @@
<!-- 淘系支付宝账户 - 嵌入在业务页面中的表格视图 -->
<!-- 淘系(天猫+淘宝)支付宝账户 - 嵌入在业务页面中的表格视图 -->
{{-- 列顺序按表1:店铺名称 | 记账日期 | 交易日期 | 金额 | 余额 | 备注 | finType | 收支类型 | 交易类型 --}}
<style>
.bill-table-wrapper { overflow-x: auto; border-radius: 0.5rem; border: 1px solid var(--vip-border, #D8D2C7); }
.bill-table { min-width: 100%; white-space: nowrap; font-size: 0.78125rem; }
@ -15,50 +16,74 @@
.bill-filter-primary:hover { background: #6A5129; }
.bill-filter-secondary { border: 1px solid var(--vip-border, #D8D2C7); background: #fff; color: #171717; }
.bill-filter-secondary:hover { background: var(--vip-content-accent-soft, #F7F0DE); }
.tb-pagination-wrap ul.pagination { display: flex; flex-direction: row; align-items: center; gap: 4px; margin: 0; padding: 0; list-style: none; }
.tb-pagination-wrap .page-item { margin: 0; }
.tb-pagination-wrap .page-link { display: inline-flex; min-width: 34px; height: 34px; align-items: center; justify-content: center; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; padding: 0 10px; color: #171717; font-size: 0.8125rem; text-decoration: none; }
.tb-pagination-wrap a.page-link:hover { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-soft, #F7F0DE); color: var(--vip-content-accent-dark, #856632); }
.tb-pagination-wrap .page-item.active .page-link { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.tb-pagination-wrap .page-item.disabled .page-link { color: #595959; cursor: not-allowed; }
.tb-pagination-wrap div.lg\:flex > div:first-child { display: none !important; }
.tb-pagination-wrap div.lg\:flex { flex-flow: row wrap; align-items: center; justify-content: center; gap: 12px; }
.tb-pagination-wrap .pagination-xs { display: flex; width: 100%; justify-content: center; margin-top: 0; }
.tb-pagination-wrap .pagination-xs > nav,
.tb-pagination-wrap .pagination-xs > nav > ul { margin-right: auto; margin-left: auto; }
</style>
@php $pageBillIds = $bills->pluck('id')->map(fn($id) => (string) $id)->values(); @endphp
@php
// 汇总数据(来自 Pages.php 的 $summary 变量,金额单位为元)
$summary = $summary ?? ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0];
$hasSummary = ($summary['count'] ?? 0) > 0;
$incomeWan = ((float) ($summary['income_total'] ?? 0)) / 10000;
$expenseWan = ((float) ($summary['expense_total'] ?? 0)) / 10000;
$netWan = ((float) ($summary['net'] ?? 0)) / 10000;
@endphp
<div class="w-full" x-data="{ selected: [] }">
<form id="marketing-taobao-alipay-bills-export-form" method="GET"
action="{{ route('marketing-taobao-alipay-bills.export') }}"></form>
{{-- 操作工具栏 --}}
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 border-b border-gray-200 pb-4">
<div></div>
<div class="flex items-center gap-3">
@can('update-banking-accounts')
<button type="submit" form="marketing-taobao-alipay-bills-export-form"
class="bill-filter-secondary">
<span class="material-icons text-base" aria-hidden="true">download</span>
{{ trans('marketing_taobao_alipay_bills.bulk_download') }}
</button>
@endcan
</div>
</div>
@if ($errors->has('import'))
<div class="mb-4 border-l-4 border-red-500 bg-red-50 px-4 py-3 text-sm text-red-700">
@foreach ($errors->get('import') as $message)
<p>{{ $message }}</p>
@endforeach
<div class="w-full">
{{-- 汇总卡:当前筛选条件下的收入/支出/净额/笔数,金额单位为万元 --}}
@if ($hasSummary)
<div class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div class="text-xs font-medium text-gray-500">收入合计(万元)</div>
<div class="mt-1 text-lg font-semibold text-green-600 tabular-nums">
+ {{ number_format($incomeWan, 2) }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div class="text-xs font-medium text-gray-500">支出合计(万元)</div>
<div class="mt-1 text-lg font-semibold text-red-600 tabular-nums">
- {{ number_format($expenseWan, 2) }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div class="text-xs font-medium text-gray-500">净额(收入 - 支出,万元)</div>
<div class="mt-1 text-lg font-semibold tabular-nums {{ $netWan >= 0 ? 'text-gray-900' : 'text-red-600' }}">
{{ $netWan >= 0 ? '+' : '' }} {{ number_format($netWan, 2) }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div class="text-xs font-medium text-gray-500">流水笔数</div>
<div class="mt-1 text-lg font-semibold text-gray-900 tabular-nums">
{{ number_format((int) ($summary['count'] ?? 0)) }}
</div>
</div>
</div>
@endif
{{-- 筛选器:按店铺 + 日期范围 --}}
{{-- 筛选器:按店铺 + 收支类型 + 交易日期范围 --}}
@php
$filters = $filters ?? ['store_name' => '', 'from' => '', 'to' => '', 'store_options' => []];
$filters = $filters ?? ['store_name' => '', 'from' => '', 'to' => '', 'flow_type' => '', 'store_options' => []];
$storeOptions = is_array($filters['store_options'] ?? null) ? $filters['store_options'] : [];
$currentStore = (string) ($filters['store_name'] ?? '');
$currentFrom = (string) ($filters['from'] ?? '');
$currentTo = (string) ($filters['to'] ?? '');
$currentFlowType = (string) ($filters['flow_type'] ?? '');
@endphp
<form method="GET" action="{{ request()->url() }}"
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<div class="flex flex-wrap items-end gap-4">
<div>
<label for="filter-store" class="mb-1 block text-xs font-medium text-gray-600">店铺名称</label>
<select id="filter-store" name="store_name"
<label for="tb-filter-store" class="mb-1 block text-xs font-medium text-gray-600">店铺名称</label>
<select id="tb-filter-store" name="store_name"
class="bill-filter-control min-w-[200px]">
<option value="">全部店铺</option>
@foreach ($storeOptions as $opt)
@ -67,28 +92,35 @@
</select>
</div>
<div>
<label for="filter-from" class="mb-1 block text-xs font-medium text-gray-600">交易日期 起</label>
<input id="filter-from" type="date" name="from" value="{{ $currentFrom }}"
<label for="tb-filter-flow-type" class="mb-1 block text-xs font-medium text-gray-600">收支类型</label>
<select id="tb-filter-flow-type" name="flow_type"
class="bill-filter-control min-w-[120px]">
<option value="">全部</option>
<option value="income" @selected($currentFlowType === 'income')>收入</option>
<option value="expense" @selected($currentFlowType === 'expense')>支出</option>
</select>
</div>
<div>
<label for="tb-filter-from" class="mb-1 block text-xs font-medium text-gray-600">交易日期 起</label>
<input id="tb-filter-from" type="date" name="from" value="{{ $currentFrom }}"
class="bill-filter-control" />
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs font-medium text-gray-600">交易日期 止</label>
<input id="filter-to" type="date" name="to" value="{{ $currentTo }}"
<label for="tb-filter-to" class="mb-1 block text-xs font-medium text-gray-600">交易日期 止</label>
<input id="tb-filter-to" type="date" name="to" value="{{ $currentTo }}"
class="bill-filter-control" />
</div>
<div class="flex items-end gap-2">
<button type="submit"
class="bill-filter-primary">
<button type="submit" class="bill-filter-primary">
<span class="material-icons text-base" aria-hidden="true">search</span>
筛选
查询
</button>
<a href="{{ request()->url() }}"
class="bill-filter-secondary">
<a href="{{ request()->url() }}" class="bill-filter-secondary">
<span class="material-icons text-base" aria-hidden="true">clear</span>
清除
</a>
</div>
@if ($currentStore !== '' || $currentFrom !== '' || $currentTo !== '')
@if ($currentStore !== '' || $currentFrom !== '' || $currentTo !== '' || $currentFlowType !== '')
<div class="ml-auto self-center text-xs text-gray-500">
共匹配 {{ $bills->total() }} 条
</div>
@ -96,62 +128,71 @@
</div>
</form>
{{-- 8列数据表格 --}}
{{-- 9列数据表格(按表1顺序)--}}
<div class="bill-table-wrapper">
<table class="bill-table divide-y divide-gray-200">
<thead>
<tr>
<th class="w-12 text-center">
<input type="checkbox"
aria-label="{{ trans('marketing_taobao_alipay_bills.select_all') }}"
@if ($pageBillIds->isEmpty()) disabled @endif
x-on:change="selected = $event.target.checked ? @js($pageBillIds) : []"
x-bind:checked="selected.length === {{ $pageBillIds->count() }} && selected.length > 0"
class="h-4 w-4 rounded border-gray-300 text-green" />
</th>
<th>{{ trans('marketing_taobao_alipay_bills.store_name') }}</th>
<th>{{ trans('marketing_taobao_alipay_bills.bookkeeping_time') }}</th>
<th>{{ trans('marketing_taobao_alipay_bills.transaction_date') }}</th>
<th>{{ trans('marketing_taobao_alipay_bills.income_expense_type') }}</th>
<th>{{ trans('marketing_taobao_alipay_bills.transaction_type') }}</th>
<th class="text-right">{{ trans('marketing_taobao_alipay_bills.operation_amount') }}</th>
<th class="text-right">{{ trans('marketing_taobao_alipay_bills.balance_after') }}</th>
<th>{{ trans('marketing_taobao_alipay_bills.notes') }}</th>
<th>店铺名称</th>
<th>记账日期</th>
<th>交易日期</th>
<th class="text-right">金额(元)</th>
<th class="text-right">余额(元)</th>
<th>备注</th>
<th>finType</th>
<th>收支类型</th>
<th>交易类型</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@if ($bills->isEmpty())
<tr>
<td colspan="9" class="px-5 py-10 text-center text-gray-400">
{{ trans('marketing_taobao_alipay_bills.no_records') }}
暂无账单数据
</td>
</tr>
@else
@foreach ($bills as $bill)
@php
// 兼容数组(上游 Repository)和对象(本地 Model)两种结构
$getStore = is_array($bill) ? ($bill['store_name'] ?? '') : $bill->store_name;
$getBookTime = is_array($bill) ? ($bill['bookkeeping_time'] ?? '') : ($bill->bookkeeping_time ? $bill->bookkeeping_time->format('Y-m-d H:i:s') : '');
$getTransDate = is_array($bill) ? ($bill['transaction_date'] ?? '') : ($bill->transaction_date ? $bill->transaction_date->format('Y-m-d') : '');
$getAmount = is_array($bill) ? ($bill['amount'] ?? 0) : $bill->operation_amount;
$getBalance = is_array($bill) ? ($bill['balance'] ?? 0) : $bill->balance_after;
$getComment = is_array($bill) ? ($bill['comment'] ?? '') : $bill->notes;
$getFinType = is_array($bill) ? ($bill['fin_type'] ?? '') : '';
$getType = is_array($bill) ? ($bill['income_expense_type'] ?? '') : $bill->income_expense_type;
$getTradeType = is_array($bill) ? ($bill['transaction_type'] ?? '') : $bill->transaction_type;
@endphp
<tr>
<td class="text-center">
<input type="checkbox" name="ids[]" value="{{ $bill->id }}"
form="marketing-taobao-alipay-bills-export-form"
x-model="selected"
aria-label="{{ trans('marketing_taobao_alipay_bills.select_bill') }}"
class="h-4 w-4 rounded border-gray-300 text-green" />
<td>{{ $getStore }}</td>
<td>{{ $getBookTime }}</td>
<td>{{ $getTransDate }}</td>
<td class="amount-cell font-semibold">{{ number_format((float) $getAmount, 2) }}</td>
<td class="amount-cell">{{ number_format((float) $getBalance, 2) }}</td>
<td class="max-w-xs truncate" title="{{ $getComment }}">
{{ \Illuminate\Support\Str::limit($getComment, 20) }}
</td>
<td>{{ $bill->store_name }}</td>
<td>{{ $bill->bookkeeping_time?->format('Y-m-d H:i:s') }}</td>
<td>{{ $bill->transaction_date->format('Y-m-d') }}</td>
<td>
@if ($bill->income_expense_type === '收入' || $bill->income_expense_type === 'income')
<span class="type-income">{{ $bill->income_expense_type }}</span>
@if ($getFinType === 'DEBIT')
<span class="type-income">{{ $getFinType }}</span>
@elseif ($getFinType === 'CREDIT')
<span class="type-expense">{{ $getFinType }}</span>
@else
<span class="type-expense">{{ $bill->income_expense_type }}</span>
{{ $getFinType }}
@endif
</td>
<td>{{ $bill->transaction_type }}</td>
<td class="amount-cell font-semibold">{{ number_format($bill->operation_amount, 2) }}</td>
<td class="amount-cell">{{ number_format($bill->balance_after, 2) }}</td>
<td class="max-w-xs truncate" title="{{ $bill->notes }}">
{{ \Illuminate\Support\Str::limit($bill->notes, 20) }}
<td>
@if ($getType === '收入' || $getType === 'income')
<span class="type-income">{{ $getType }}</span>
@elseif ($getType === '支出' || $getType === 'expense')
<span class="type-expense">{{ $getType }}</span>
@else
{{ $getType }}
@endif
</td>
<td>{{ $getTradeType }}</td>
</tr>
@endforeach
@endif
@ -160,6 +201,8 @@
</div>
@unless ($bills->isEmpty())
<div class="mt-5">{{ $bills->links() }}</div>
<div class="tb-pagination-wrap mt-5">
<x-pagination :items="$bills" />
</div>
@endunless
</div>

View File

@ -7,6 +7,7 @@ use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Modules\EcommerceFinance\Services\JianSunSnapshotRefresher;
use PHPUnit\Framework\TestCase;
@ -54,8 +55,16 @@ class JianSunSnapshotRefresherTest extends TestCase
$edgeBanding = (new ReflectionClass(EdgeBandingRepository::class))->newInstanceWithoutConstructor();
$goods = (new ReflectionClass(GoodsReconciliationRepository::class))->newInstanceWithoutConstructor();
$taobaoAlipay = (new ReflectionClass(TmallTaobaoSpreadBillRepository::class))->newInstanceWithoutConstructor();
$supplier = (new ReflectionClass(SupplierPayableRepository::class))->newInstanceWithoutConstructor();
$refresher = new JianSunSnapshotRefresher($cache, $edgeBanding, $goods, $pinduoduo, $supplier);
$refresher = new JianSunSnapshotRefresher(
$cache,
$edgeBanding,
$goods,
$pinduoduo,
$taobaoAlipay,
$supplier
);
$status = $refresher->refresh([
'scope' => 'pinduoduo-bills.list',
@ -77,4 +86,76 @@ class JianSunSnapshotRefresherTest extends TestCase
$pinduoduo->arguments
);
}
public function test_taobao_alipay_filters_are_preserved_during_background_refresh(): void
{
$cache = new class extends JianSunQueryCache {
public mixed $value = null;
public function __construct()
{
}
public function refresh(string $scope, array $parameters, Closure $resolver): string
{
$this->value = $resolver();
return 'fresh';
}
};
$taobaoAlipay = new class extends TmallTaobaoSpreadBillRepository {
public array $arguments = [];
public function __construct()
{
}
public function paginate(
?string $storeName = null,
?string $fromDate = null,
?string $toDate = null,
?string $flowType = null,
int $page = 1,
int $pageSize = 25
): array {
$this->arguments = func_get_args();
return ['rows' => [], 'total' => 0];
}
};
$edgeBanding = (new ReflectionClass(EdgeBandingRepository::class))->newInstanceWithoutConstructor();
$goods = (new ReflectionClass(GoodsReconciliationRepository::class))->newInstanceWithoutConstructor();
$pinduoduo = (new ReflectionClass(PinduoduoSpreadBillRepository::class))->newInstanceWithoutConstructor();
$supplier = (new ReflectionClass(SupplierPayableRepository::class))->newInstanceWithoutConstructor();
$refresher = new JianSunSnapshotRefresher(
$cache,
$edgeBanding,
$goods,
$pinduoduo,
$taobaoAlipay,
$supplier
);
$status = $refresher->refresh([
'scope' => 'taobao-alipay-bills.list',
'parameters' => [
'filters' => [
'store_name' => '测试店铺',
'from' => '2026-07-01',
'to' => '2026-07-23',
'flow_type' => 'income',
],
'page' => 2,
'limit' => 25,
],
]);
$this->assertSame('fresh', $status);
$this->assertSame(
['测试店铺', '2026-07-01', '2026-07-23', 'income', 2, 25],
$taobaoAlipay->arguments
);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class TmallTaobaoSpreadBillRepositoryTest extends TestCase
{
public function test_filters_and_cent_amounts_are_normalized_consistently(): void
{
$query = new class('jiansun') extends UpstreamReadOnlyQuery {
/** @var array<int,array{method:string,sql:string,bindings:array}> */
public array $calls = [];
public function scalar(string $sql, array $bindings = []): mixed
{
$this->calls[] = compact('sql', 'bindings') + ['method' => 'scalar'];
return 1;
}
public function select(string $sql, array $bindings = []): array
{
$this->calls[] = compact('sql', 'bindings') + ['method' => 'select'];
return [[
'shopName' => '测试店铺',
'uniTransTime' => '2026-07-23 12:00:00',
'transTime' => '2026-07-23',
'amount' => 15050,
'balance' => 990050,
'comment' => '测试流水',
'finType' => 'DEBIT',
'bizType' => 'INTEL_CHARGE_POST',
'tradeType' => '',
]];
}
public function first(string $sql, array $bindings = []): ?array
{
$this->calls[] = compact('sql', 'bindings') + ['method' => 'first'];
return [
'income_total_raw' => 15050,
'expense_total_raw' => 0,
'cnt' => 1,
];
}
};
$factory = new class($query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query)
{
}
public function forSource(string $source): UpstreamReadOnlyQuery
{
TestCase::assertSame('jiansun', $source);
return $this->query;
}
};
$repository = new TmallTaobaoSpreadBillRepository($factory);
$page = $repository->paginate('测试店铺', '2026-07-01', '2026-07-23', 'income', 1, 25);
$summary = $repository->summary('测试店铺', '2026-07-01', '2026-07-23', 'income');
$this->assertSame(1, $page['total']);
$this->assertSame(150.5, $page['rows'][0]['amount']);
$this->assertSame(9900.5, $page['rows'][0]['balance']);
$this->assertSame('收入', $page['rows'][0]['income_expense_type']);
$this->assertSame('充值', $page['rows'][0]['transaction_type']);
$this->assertSame(150.5, $summary['income_total']);
$this->assertSame(150.5, $summary['net']);
$this->assertStringContainsString('finType = \'DEBIT\'', $query->calls[0]['sql']);
$this->assertSame(
['测试店铺', '2026-07-01', '2026-07-23'],
$query->calls[0]['bindings']
);
$this->assertStringContainsString('finType = \'DEBIT\'', $query->calls[1]['sql']);
$this->assertStringContainsString('finType = \'DEBIT\'', $query->calls[2]['sql']);
}
}