功能:合并淘系支付宝上游账单
This commit is contained in:
parent
b5097cd2df
commit
8a8ea93c63
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user