淘系支付宝账单切换经营分析副本镜像 #129
@ -20,8 +20,8 @@
|
||||
| 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 |
|
||||
| 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 |
|
||||
| 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 |
|
||||
| 已登记,待首次同步 | `operations_replica/rpt.jst_originalBillInfo` 登记为通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。部署迁移只创建空表,不自动启动约 1400 万行的首次同步。 |
|
||||
| 已登记,待首次同步 | 店铺收款账单的淘系支付宝账户已登记 `jiansun.dbo.alipay_Bill_Detail`,页面按共享镜像分页、筛选和汇总;首次全量完成前显示同步中状态。 |
|
||||
| 已完成 | `operations_replica/rpt.jst_originalBillInfo` 已进入通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。 |
|
||||
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
|
||||
|
||||
## 经营分析副本库(operations_replica)
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
|
||||
| 业务范围 | 上游对象 |
|
||||
| --- | --- |
|
||||
| 店铺收款账单 | `dbo.alipay_Bill_Detail` |
|
||||
| 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` |
|
||||
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
|
||||
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
|
||||
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
|
||||
|
||||
@ -57,6 +57,40 @@ class AlipayBills extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function export(Request $request, StoreReceivingAlipayBillMirrorRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'alipay_account' => ['nullable', 'string', 'max:128'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'business_type' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters));
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json(['message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。'], 503);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($rows): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, ['记录编码', '原始店铺', '平台渠道', '账单项目', '账务订单号', '业务流水号', '商户订单号', '业务基础订单号', '商品编码', '发生时间', '收入(元)', '支出(元)', '收支净额(元)', '收支类型', '线上单号', '业务描述', '业务账单来源', '创建时间', '修改时间']);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, [$row['record_code'], $row['store_name'], $row['alipay_account'], $row['bill_item'], $row['account_order_no'], $row['business_serial_no'], $row['merchant_order_no'], $row['business_base_order_no'], $row['goods_name'], $row['occurred_at'], $row['income'], $row['expense'], $row['balance'], $row['business_type'], $row['online_order_no'], $row['business_description'], $row['business_bill_source'], $row['created_at'], $row['updated_at']]);
|
||||
}
|
||||
fclose($csv);
|
||||
}, 'taobao_alipay_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function show(AlipayBill $bill)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
@ -3,13 +3,15 @@
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/** 店铺收款账单·淘系支付宝账户只读共享业务镜像。 */
|
||||
/** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */
|
||||
class StoreReceivingAlipayBillMirrorRepository
|
||||
{
|
||||
public const DATASET = 'jiansun.dbo.alipay_Bill_Detail';
|
||||
public const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
|
||||
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
@ -20,7 +22,8 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
if (! $this->mirror->ready($dataset)
|
||||
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
|
||||
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
|
||||
}
|
||||
|
||||
@ -28,33 +31,32 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
||||
$sortColumn = [
|
||||
'record_code' => 'Id',
|
||||
'occurred_at' => 'happenTime',
|
||||
'income' => 'inAmount',
|
||||
'expense' => 'outAmount',
|
||||
'balance' => 'accountAmount',
|
||||
][$sort] ?? 'happenTime';
|
||||
'record_code' => 'b.Id',
|
||||
'occurred_at' => 'b.fee_time',
|
||||
'income' => 'b.amount',
|
||||
'expense' => 'b.amount',
|
||||
'balance' => 'b.amount',
|
||||
][$sort] ?? 'b.fee_time';
|
||||
|
||||
$query = $this->mirror->connection()->table($dataset->targetTable());
|
||||
$query = $this->billQuery();
|
||||
$this->applyFilters($query, $filters);
|
||||
$total = (clone $query)->count();
|
||||
$summaryRow = (clone $query)->selectRaw(
|
||||
'COALESCE(SUM(CASE WHEN inAmount > 0 THEN inAmount ELSE 0 END), 0) AS income, '
|
||||
. 'COALESCE(SUM(ABS(COALESCE(outAmount, 0))), 0) AS expense, COUNT(*) AS aggregate_count'
|
||||
)->first();
|
||||
$latestBalance = (clone $query)
|
||||
->orderByDesc('happenTime')
|
||||
->orderByDesc('Id')
|
||||
->value('accountAmount');
|
||||
$summary = $this->summary($query, $filters);
|
||||
|
||||
$rows = $query
|
||||
$billRows = $query
|
||||
->select('b.*')
|
||||
->orderBy($sortColumn, $direction)
|
||||
->orderBy('Id', $direction)
|
||||
->orderBy('b.Id', $direction)
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
$shopNames = $this->shopNames($billRows);
|
||||
$rows = collect($billRows)
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null))
|
||||
->all();
|
||||
$state = $this->mirror->state($dataset) ?? [];
|
||||
$income = $summary['income'];
|
||||
$expense = $summary['expense'];
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
@ -63,10 +65,10 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
'last_page' => max((int) ceil($total / $perPage), 1),
|
||||
'per_page' => $perPage,
|
||||
'summary' => [
|
||||
'income' => (float) ($summaryRow->income ?? 0),
|
||||
'expense' => (float) ($summaryRow->expense ?? 0),
|
||||
'balance' => (float) ($latestBalance ?? 0),
|
||||
'count' => (int) ($summaryRow->aggregate_count ?? 0),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => $income - $expense,
|
||||
'count' => $total,
|
||||
],
|
||||
'sync' => [
|
||||
'status' => $state['status'] ?? 'unknown',
|
||||
@ -75,75 +77,210 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportRows(array $filters): iterable
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
|
||||
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
|
||||
}
|
||||
|
||||
$query = $this->billQuery();
|
||||
$this->applyFilters($query, $filters);
|
||||
$shopNames = $this->allShopNames();
|
||||
|
||||
return $query->select('b.*')
|
||||
->orderBy('b.fee_time')->orderBy('b.Id')->cursor()
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null));
|
||||
}
|
||||
|
||||
private function billQuery(): Builder
|
||||
{
|
||||
return $this->mirror->connection()->table('ops_jst_original_bill_info as b');
|
||||
}
|
||||
|
||||
/** @return array{income:float,expense:float} */
|
||||
private function summary(Builder $billQuery, array $filters): array
|
||||
{
|
||||
if ($this->dailySummarySupports($filters)) {
|
||||
$summary = $this->dailySummary($filters);
|
||||
if ($summary !== null) {
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
$row = (clone $billQuery)->selectRaw(
|
||||
"COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, "
|
||||
. "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense"
|
||||
)->first();
|
||||
|
||||
return [
|
||||
'income' => round((float) ($row->income ?? 0), 2),
|
||||
'expense' => round((float) ($row->expense ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function dailySummarySupports(array $filters): bool
|
||||
{
|
||||
return ($filters['alipay_account'] ?? '') === ''
|
||||
&& ($filters['bill_item'] ?? '') === ''
|
||||
&& ($filters['keyword'] ?? '') === '';
|
||||
}
|
||||
|
||||
/** @return array{income:float,expense:float}|null */
|
||||
private function dailySummary(array $filters): ?array
|
||||
{
|
||||
$connection = $this->mirror->connection();
|
||||
$schema = Schema::connection($connection->getName());
|
||||
if (! $schema->hasTable('store_report_import_states') || ! $schema->hasTable('store_report_daily_rows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = $connection->table('store_report_import_states')
|
||||
->where('dataset_key', 'store-report.daily-sales')->first();
|
||||
$generation = trim((string) ($state->active_generation ?? ''));
|
||||
if ($generation === '' || empty($state->initial_completed_at)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $connection->table('store_report_daily_rows')->where('generation', $generation);
|
||||
if (($filters['store_name'] ?? '') !== '') {
|
||||
$query->where('store_name', $filters['store_name']);
|
||||
}
|
||||
if (($filters['start'] ?? '') !== '') {
|
||||
$query->where('occurred_on', '>=', $filters['start']);
|
||||
}
|
||||
if (($filters['end'] ?? '') !== '') {
|
||||
$query->where('occurred_on', '<=', $filters['end']);
|
||||
}
|
||||
$row = $query->selectRaw('COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first();
|
||||
$income = round((float) ($row->income ?? 0), 2);
|
||||
$expense = round((float) ($row->expense ?? 0), 2);
|
||||
|
||||
return match ($this->directionCode((string) ($filters['business_type'] ?? ''))) {
|
||||
'1' => ['income' => $income, 'expense' => 0.0],
|
||||
'-1' => ['income' => 0.0, 'expense' => $expense],
|
||||
default => ['income' => $income, 'expense' => $expense],
|
||||
};
|
||||
}
|
||||
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (($filters['store_name'] ?? '') !== '') {
|
||||
$query->where('shopName', $filters['store_name']);
|
||||
$shopIds = $this->mirror->connection()->table('ops_jst_shop_info')
|
||||
->where('shop_name', $filters['store_name'])
|
||||
->pluck('shop_id')
|
||||
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
|
||||
->all();
|
||||
if ($shopIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->whereIn('b.shop_id', $shopIds);
|
||||
}
|
||||
}
|
||||
if (($filters['alipay_account'] ?? '') !== '') {
|
||||
$value = '%' . $this->escapeLike((string) $filters['alipay_account']) . '%';
|
||||
$query->where(function (Builder $query) use ($value): void {
|
||||
$query->where('appId', 'like', $value)
|
||||
->orWhere('account', 'like', $value);
|
||||
});
|
||||
$query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%');
|
||||
}
|
||||
if (($filters['bill_item'] ?? '') !== '') {
|
||||
$query->where('tradeChannel', $filters['bill_item']);
|
||||
$query->where('b.fee_name', $filters['bill_item']);
|
||||
}
|
||||
if (($filters['business_type'] ?? '') !== '') {
|
||||
$query->where('businessType', $filters['business_type']);
|
||||
$query->where('b.inout_type', $this->directionCode((string) $filters['business_type']));
|
||||
}
|
||||
if (($filters['start'] ?? '') !== '') {
|
||||
$query->where('happenTime', '>=', $filters['start'] . ' 00:00:00');
|
||||
$query->where('b.fee_time', '>=', $filters['start'] . ' 00:00:00');
|
||||
}
|
||||
if (($filters['end'] ?? '') !== '') {
|
||||
$query->where('happenTime', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00');
|
||||
$query->where('b.fee_time', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00');
|
||||
}
|
||||
if (($filters['keyword'] ?? '') !== '') {
|
||||
$value = '%' . $this->escapeLike((string) $filters['keyword']) . '%';
|
||||
$query->where(function (Builder $query) use ($value): void {
|
||||
$query->where('commodityName', 'like', $value)
|
||||
->orWhere('accountNum', 'like', $value)
|
||||
->orWhere('businessNum', 'like', $value)
|
||||
->orWhere('merchantOrder', 'like', $value)
|
||||
->orWhere('storeNum', 'like', $value)
|
||||
->orWhere('remark', 'like', $value);
|
||||
foreach (['b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] as $column) {
|
||||
$query->orWhere($column, 'like', $value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function mapRow(object $row): array
|
||||
/** @param array<int,object> $rows @return array<string,string> */
|
||||
private function shopNames(array $rows): array
|
||||
{
|
||||
$shopIds = collect($rows)->pluck('shop_id')->filter()->unique()->values()->all();
|
||||
if ($shopIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->mirror->connection()->table('ops_jst_shop_info')
|
||||
->whereIn('shop_id', $shopIds)->pluck('shop_name', 'shop_id')
|
||||
->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function allShopNames(): array
|
||||
{
|
||||
return $this->mirror->connection()->table('ops_jst_shop_info')
|
||||
->whereNotNull('shop_id')->pluck('shop_name', 'shop_id')
|
||||
->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function mapRow(object $row, ?string $shopName): array
|
||||
{
|
||||
$amount = abs((float) ($row->amount ?? 0));
|
||||
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
||||
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
||||
|
||||
return [
|
||||
'id' => (string) $row->Id,
|
||||
'record_code' => (string) $row->Id,
|
||||
'store_name' => $row->shopName,
|
||||
'custom_remark' => $row->buyerRemark,
|
||||
'bill_item' => $row->tradeChannel,
|
||||
'alipay_account' => $row->appId ?: $row->account,
|
||||
'account_order_no' => $row->accountNum,
|
||||
'business_serial_no' => $row->businessNum,
|
||||
'merchant_order_no' => $row->merchantOrder,
|
||||
'business_base_order_no' => $row->busiBasicNum,
|
||||
'goods_name' => $row->commodityName,
|
||||
'counterparty_account' => $row->oppAccount,
|
||||
'occurred_at' => $this->dateTime($row->happenTime),
|
||||
'income' => max((float) ($row->inAmount ?? 0), 0),
|
||||
'expense' => abs((float) ($row->outAmount ?? 0)),
|
||||
'balance' => (float) ($row->accountAmount ?? 0),
|
||||
'business_type' => $row->businessType,
|
||||
'remark' => $row->remark,
|
||||
'online_order_no' => $row->storeNum,
|
||||
'online_real_store' => $row->shopName,
|
||||
'business_description' => $row->busiDescription,
|
||||
'business_bill_source' => $row->busiBillSource,
|
||||
'business_order_no' => $row->busiNum,
|
||||
'created_at' => '',
|
||||
'updated_at' => '',
|
||||
'store_name' => $shopName,
|
||||
'custom_remark' => null,
|
||||
'bill_item' => $row->fee_name,
|
||||
'alipay_account' => $row->channel,
|
||||
'account_order_no' => $row->primary_key,
|
||||
'business_serial_no' => $row->auto_id,
|
||||
'merchant_order_no' => $row->p_auto_id,
|
||||
'business_base_order_no' => $row->so_id,
|
||||
'goods_name' => $row->sku_id ?: $row->shop_sku_id,
|
||||
'counterparty_account' => null,
|
||||
'occurred_at' => $this->dateTime($row->fee_time),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => $income - $expense,
|
||||
'business_type' => $this->directionName($row->inout_type),
|
||||
'remark' => null,
|
||||
'online_order_no' => $row->so_id,
|
||||
'online_real_store' => $shopName,
|
||||
'business_description' => $row->combine_type,
|
||||
'business_bill_source' => $row->channel,
|
||||
'business_order_no' => $row->p_auto_id,
|
||||
'created_at' => $this->dateTime($row->createTime),
|
||||
'updated_at' => $this->dateTime($row->updateTime),
|
||||
'modifier' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function directionCode(string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
'收入', 'IN', 'in' => '1',
|
||||
'支出', 'OUT', 'out' => '-1',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function directionName(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'1' => '收入',
|
||||
'-1' => '支出',
|
||||
default => (string) ($value ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
private function dateTime(mixed $value): string
|
||||
{
|
||||
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
|
||||
|
||||
@ -13,9 +13,9 @@
|
||||
<div class="tb-stat-s">按当前筛选</div>
|
||||
</div>
|
||||
<div class="tb-stat">
|
||||
<div class="tb-stat-k">期末余额(最新)</div>
|
||||
<div class="tb-stat-k">收支净额</div>
|
||||
<div class="tb-stat-v">{{ money(summary.balance) }}</div>
|
||||
<div class="tb-stat-s">最新一条记录</div>
|
||||
<div class="tb-stat-s">收入合计 - 支出合计</div>
|
||||
</div>
|
||||
<div class="tb-stat">
|
||||
<div class="tb-stat-k">交易笔数</div>
|
||||
@ -31,30 +31,19 @@
|
||||
<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" />
|
||||
</div>
|
||||
<div class="tb-field">
|
||||
<label>支付宝账号</label>
|
||||
<input v-model.trim="filters.alipay_account" placeholder="如 2088**********" />
|
||||
<label>平台渠道</label>
|
||||
<input v-model.trim="filters.alipay_account" placeholder="如 CommonBillTGCPay" />
|
||||
</div>
|
||||
<div class="tb-field">
|
||||
<label>账单项目</label>
|
||||
<select v-model="filters.bill_item">
|
||||
<option value="">全部项目</option>
|
||||
<option>交易收款</option>
|
||||
<option>以旧换新自补平台过桥</option>
|
||||
<option>退款</option>
|
||||
<option>提现到银行卡</option>
|
||||
<option>服务费</option>
|
||||
<option>红包补贴</option>
|
||||
</select>
|
||||
<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" />
|
||||
</div>
|
||||
<div class="tb-field">
|
||||
<label>业务类型</label>
|
||||
<label>收支类型</label>
|
||||
<select v-model="filters.business_type">
|
||||
<option value="">全部类型</option>
|
||||
<option>交易付款</option>
|
||||
<option>交易分账</option>
|
||||
<option>退款</option>
|
||||
<option>提现</option>
|
||||
<option>手续费</option>
|
||||
<option>收入</option>
|
||||
<option>支出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tb-field tb-field-date">
|
||||
@ -65,12 +54,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="tb-field tb-field-kw">
|
||||
<label>关键词(商品 / 单号 / 备注)</label>
|
||||
<input v-model.trim="filters.keyword" placeholder="输入商品名称、订单号或备注" />
|
||||
<label>关键词(费用 / 订单 / 商品编码)</label>
|
||||
<input v-model.trim="filters.keyword" placeholder="输入费用名称、订单号或商品编码" />
|
||||
</div>
|
||||
<div class="tb-actions">
|
||||
<button class="tb-btn tb-btn-primary" @click="applyFilters">查询</button>
|
||||
<button class="tb-btn" @click="resetFilters">重置</button>
|
||||
<button class="tb-btn" @click="exportRows">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -97,17 +87,17 @@
|
||||
<th>原始店铺</th>
|
||||
<th>自定义备注</th>
|
||||
<th>账单项目</th>
|
||||
<th>支付宝账号</th>
|
||||
<th>平台渠道</th>
|
||||
<th>账务订单号</th>
|
||||
<th>业务流水号</th>
|
||||
<th>商户订单号</th>
|
||||
<th>业务基础订单号</th>
|
||||
<th>商品名称</th>
|
||||
<th>商品编码</th>
|
||||
<th>对方账号</th>
|
||||
<th @click="toggleSort('occurred_at')">发生时间 <span class="tb-sort">⇅</span></th>
|
||||
<th class="tb-num" @click="toggleSort('income')">收入 <span class="tb-sort">⇅</span></th>
|
||||
<th class="tb-num" @click="toggleSort('expense')">支出 <span class="tb-sort">⇅</span></th>
|
||||
<th class="tb-num" @click="toggleSort('balance')">余额 <span class="tb-sort">⇅</span></th>
|
||||
<th class="tb-num" @click="toggleSort('balance')">收支净额 <span class="tb-sort">⇅</span></th>
|
||||
<th>业务类型</th>
|
||||
<th>备注</th>
|
||||
<th>线上单号</th>
|
||||
@ -188,6 +178,7 @@ export default {
|
||||
|
||||
props: {
|
||||
apiBase: { type: String, required: true },
|
||||
exportBase: { type: String, required: true },
|
||||
},
|
||||
|
||||
data() {
|
||||
@ -283,6 +274,11 @@ export default {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
exportRows() {
|
||||
const params = new URLSearchParams(this.filters);
|
||||
window.location.href = `${this.exportBase}?${params.toString()}`;
|
||||
},
|
||||
|
||||
toggleSort(key) {
|
||||
if (this.sort.key === key) {
|
||||
this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
7
resources/assets/js/views/business/pages.js
vendored
7
resources/assets/js/views/business/pages.js
vendored
@ -4,7 +4,7 @@
|
||||
* 路由:/{company_id}/workspace/{section}/{page}/{tab}
|
||||
*
|
||||
* 店铺收款账户账单(store-receiving-bills)下各 tab:
|
||||
* - taobao-alipay → 独立实现 TaobaoAlipayBills(表 ec_alipay_bills)
|
||||
* - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发)
|
||||
*/
|
||||
@ -43,7 +43,10 @@ function resolveComponent(context) {
|
||||
: `/ecommerce-finance/account-bills/type/${encodeURIComponent(context.tab)}`;
|
||||
// 店铺收款账户账单 › 淘系支付宝账户(独立实现)
|
||||
if (context.page === 'store-receiving-bills' && context.tab === 'taobao-alipay') {
|
||||
return { Component: TaobaoAlipayBills, props: { apiBase: alipayBase } };
|
||||
const exportBase = context.companyId
|
||||
? `/${context.companyId}/ecommerce-finance/alipay-bills/export`
|
||||
: '/ecommerce-finance/alipay-bills/export';
|
||||
return { Component: TaobaoAlipayBills, props: { apiBase: alipayBase, exportBase } };
|
||||
}
|
||||
|
||||
// 店铺收款账户账单 › 其它账户类型(通用实现)
|
||||
|
||||
@ -18,6 +18,8 @@ Route::post('store-account-passwords/{account}/reveal', 'Business\StoreAccountPa
|
||||
|
||||
Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index'])
|
||||
->name('ecommerce-finance.alipay-bills.index');
|
||||
Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export'])
|
||||
->name('ecommerce-finance.alipay-bills.export');
|
||||
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
|
||||
->name('ecommerce-finance.alipay-bills.show');
|
||||
|
||||
|
||||
@ -117,6 +117,37 @@ class PagesTest extends FeatureTestCase
|
||||
->assertSee('public/js/business/pages.min.js', false);
|
||||
}
|
||||
|
||||
public function testTaobaoAlipayBillsExportUsesTheMirrorRepository()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class);
|
||||
$repository->shouldReceive('exportRows')->once()->with(Mockery::on(
|
||||
static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店'
|
||||
&& ($filters['start'] ?? '') === '2026-08-01'
|
||||
&& ($filters['end'] ?? '') === '2026-08-31'
|
||||
))->andReturn([[
|
||||
'record_code' => '1', 'store_name' => '天猫-经营店', 'alipay_account' => 'CommonBillTGCPay',
|
||||
'bill_item' => '交易佣金', 'account_order_no' => 'KEY-1', 'business_serial_no' => '1001',
|
||||
'merchant_order_no' => 'P-1', 'business_base_order_no' => 'ORDER-1', 'goods_name' => 'SKU-1',
|
||||
'occurred_at' => '2026-08-01 09:00:00', 'income' => 100.25, 'expense' => 0,
|
||||
'balance' => 100.25, 'business_type' => '收入', 'online_order_no' => 'ORDER-1',
|
||||
'business_description' => 'double', 'business_bill_source' => 'CommonBillTGCPay',
|
||||
'created_at' => '2026-08-02 09:00:00', 'updated_at' => '2026-08-02 10:00:00',
|
||||
]]);
|
||||
$this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('ecommerce-finance.alipay-bills.export', [
|
||||
'store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-31',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8')
|
||||
->assertDownload('taobao_alipay_bills.csv');
|
||||
}
|
||||
|
||||
public function testSupplierPayablesPageRendersItsSpecificContent()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
|
||||
@ -21,125 +21,167 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [
|
||||
StoreReceivingAlipayBillMirrorRepository::DATASET => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'alipay_Bill_Detail',
|
||||
'target' => 'js_alipay_bill_detail',
|
||||
'source_key' => 'operations_replica', 'schema' => 'rpt',
|
||||
'object' => 'jst_originalBillInfo', 'target' => 'ops_jst_original_bill_info',
|
||||
'keys' => ['Id'],
|
||||
],
|
||||
StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET => [
|
||||
'source_key' => 'operations_replica', 'schema' => 'rpt',
|
||||
'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_alipay_bill_detail', function (Blueprint $table): void {
|
||||
$schema->create('ops_jst_original_bill_info', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
foreach ([
|
||||
'accountNum', 'businessNum', 'merchantOrder', 'commodityName', 'oppAccount',
|
||||
'tradeChannel', 'businessType', 'remark', 'shopName', 'busiBillSource',
|
||||
'busiDescription', 'busiNum', 'busiBasicNum', 'buyerRemark', 'storeNum',
|
||||
'appId', 'account', 'bindingType',
|
||||
] as $column) {
|
||||
$table->integer('shop_id')->nullable();
|
||||
foreach (['channel', 'primary_key', 'fee_name', 'inout_type', 'p_auto_id', 'shop_i_id', 'shop_sku_id', 'sku_id', 'so_id', 'combine_type'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
$table->dateTime('happenTime')->nullable();
|
||||
$table->date('date')->nullable();
|
||||
$table->decimal('inAmount', 20, 6)->nullable();
|
||||
$table->decimal('outAmount', 20, 6)->nullable();
|
||||
$table->decimal('accountAmount', 20, 6)->nullable();
|
||||
$table->dateTime('fee_time')->nullable();
|
||||
$table->dateTime('createTime')->nullable();
|
||||
$table->dateTime('updateTime')->nullable();
|
||||
$table->bigInteger('auto_id')->nullable();
|
||||
$table->decimal('amount', 20, 6)->nullable();
|
||||
});
|
||||
$schema->create('ops_jst_shop_info', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
$table->integer('shop_id')->nullable();
|
||||
$table->string('shop_name')->nullable();
|
||||
});
|
||||
|
||||
$this->repository = new StoreReceivingAlipayBillMirrorRepository(
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
new GenericBusinessMirrorRepository(app('db')),
|
||||
new BusinessMirrorDatasetRegistry(), new GenericBusinessMirrorRepository(app('db')),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_returns_filtered_pages_and_summary_using_attachment_field_names(): void
|
||||
public function test_it_reads_operations_replica_rows_and_maps_shop_and_flow_fields(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('js_alipay_bill_detail')->insert([
|
||||
$this->row(1, '天猫-测试店', '2026-08-01 09:00:00', 100.25, 0, 500.25),
|
||||
$this->row(2, '天猫-测试店', '2026-08-02 09:00:00', 0, -20.10, 480.15),
|
||||
$this->row(3, '其他店铺', '2026-08-03 09:00:00', 88, 0, 88),
|
||||
DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([
|
||||
['Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店'],
|
||||
['Id' => 11, 'shop_id' => 8, 'shop_name' => '淘宝-其他店'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25),
|
||||
$this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10),
|
||||
$this->row(3, 8, '2026-08-03 09:00:00', '1', 88),
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate(
|
||||
['store_name' => '天猫-测试店', 'start' => '2026-08-01', 'end' => '2026-08-31'],
|
||||
'occurred_at',
|
||||
'desc',
|
||||
2,
|
||||
1,
|
||||
['store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-31'],
|
||||
'occurred_at', 'desc', 1, 10,
|
||||
);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame(2, $result['current_page']);
|
||||
$this->assertSame('1', $result['data'][0]['record_code']);
|
||||
$this->assertSame('2088000000000000', $result['data'][0]['alipay_account']);
|
||||
$this->assertSame('2', $result['data'][0]['record_code']);
|
||||
$this->assertSame('天猫-经营店', $result['data'][0]['store_name']);
|
||||
$this->assertSame('支出', $result['data'][0]['business_type']);
|
||||
$this->assertSame('CommonBillTGCPay', $result['data'][0]['alipay_account']);
|
||||
$this->assertSame(100.25, $result['summary']['income']);
|
||||
$this->assertSame(20.10, $result['summary']['expense']);
|
||||
$this->assertSame(480.15, $result['summary']['balance']);
|
||||
$this->assertSame(80.15, $result['summary']['balance']);
|
||||
$this->assertSame(2, $result['summary']['count']);
|
||||
$this->assertSame('2026-09-07 09:00:00', $result['sync']['last_succeeded_at']);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_unready_mirror_instead_of_querying_upstream_from_http(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('store_receiving_alipay_bill_mirror_not_ready');
|
||||
|
||||
$this->repository->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
}
|
||||
|
||||
public function test_export_uses_the_same_filters_and_mapping_as_the_page(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([
|
||||
'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25),
|
||||
$this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10),
|
||||
]);
|
||||
|
||||
$rows = iterator_to_array($this->repository->exportRows([
|
||||
'business_type' => '支出', 'alipay_account' => 'CommonBill', 'keyword' => 'SKU-2',
|
||||
]));
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('2', $rows[0]['record_code']);
|
||||
$this->assertSame(20.10, $rows[0]['expense']);
|
||||
}
|
||||
|
||||
public function test_common_filters_use_the_existing_daily_summary_acceleration(): void
|
||||
{
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([
|
||||
'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert(
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25),
|
||||
);
|
||||
Schema::connection('business_mirror')->create('store_report_import_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('store_report_daily_rows', function (Blueprint $table): void {
|
||||
$table->string('generation');
|
||||
$table->string('store_name');
|
||||
$table->date('occurred_on');
|
||||
$table->decimal('income', 20, 4);
|
||||
$table->decimal('expense', 20, 4);
|
||||
});
|
||||
DB::connection('business_mirror')->table('store_report_import_states')->insert([
|
||||
'dataset_key' => 'store-report.daily-sales', 'active_generation' => 'daily-v1',
|
||||
'initial_completed_at' => '2026-09-01 09:00:00',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
|
||||
'generation' => 'daily-v1', 'store_name' => '天猫-经营店', 'occurred_on' => '2026-08-01',
|
||||
'income' => 100.25, 'expense' => 20.10,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([
|
||||
'store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-01',
|
||||
], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(['income' => 100.25, 'expense' => 20.10, 'balance' => 80.15, 'count' => 1], $result['summary']);
|
||||
}
|
||||
|
||||
private function markReady(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => StoreReceivingAlipayBillMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-07 09:00:00',
|
||||
'status' => 'succeeded',
|
||||
'last_succeeded_at' => '2026-09-07 09:00:00',
|
||||
]);
|
||||
foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) {
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => $dataset,
|
||||
'initial_completed_at' => '2026-09-07 09:00:00',
|
||||
'status' => 'succeeded',
|
||||
'last_succeeded_at' => '2026-09-07 09:00:00',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function row(int $id, string $shop, string $time, float $income, float $expense, float $balance): array
|
||||
private function row(int $id, int $shopId, string $time, string $direction, float $amount): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id,
|
||||
'accountNum' => 'ACCOUNT-' . $id,
|
||||
'businessNum' => 'BUSINESS-' . $id,
|
||||
'merchantOrder' => 'MERCHANT-' . $id,
|
||||
'commodityName' => '商品' . $id,
|
||||
'happenTime' => $time,
|
||||
'oppAccount' => '对方账号',
|
||||
'inAmount' => $income,
|
||||
'outAmount' => $expense,
|
||||
'accountAmount' => $balance,
|
||||
'tradeChannel' => '交易收款',
|
||||
'businessType' => '交易付款',
|
||||
'remark' => '备注',
|
||||
'shopName' => $shop,
|
||||
'date' => substr($time, 0, 10),
|
||||
'busiBillSource' => '支付宝',
|
||||
'busiDescription' => '业务说明',
|
||||
'busiNum' => 'BILL-' . $id,
|
||||
'busiBasicNum' => 'BASE-' . $id,
|
||||
'buyerRemark' => '自定义备注',
|
||||
'storeNum' => 'ORDER-' . $id,
|
||||
'appId' => '2088000000000000',
|
||||
'account' => 'account@example.test',
|
||||
'bindingType' => '淘系',
|
||||
'Id' => $id, 'shop_id' => $shopId, 'channel' => 'CommonBillTGCPay',
|
||||
'primary_key' => 'KEY-' . $id, 'fee_name' => '交易佣金', 'inout_type' => $direction,
|
||||
'p_auto_id' => 'P-' . $id, 'shop_i_id' => 'STYLE-' . $id,
|
||||
'shop_sku_id' => 'SHOP-SKU-' . $id, 'sku_id' => 'SKU-' . $id,
|
||||
'so_id' => 'ORDER-' . $id, 'combine_type' => 'double',
|
||||
'fee_time' => $time, 'createTime' => '2026-08-04 09:00:00',
|
||||
'updateTime' => '2026-08-04 10:00:00', 'auto_id' => 1000 + $id, 'amount' => $amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ class TaobaoAlipayBillsTableTest extends TestCase
|
||||
$this->assertStringContainsString('<td :colspan="25"', $source);
|
||||
$this->assertStringContainsString('v-if="error" class="tb-error"', $source);
|
||||
$this->assertStringContainsString('e.response.data.message', $source);
|
||||
$this->assertStringContainsString('<label>平台渠道</label>', $source);
|
||||
$this->assertStringContainsString('@click="exportRows">导出</button>', $source);
|
||||
$this->assertStringContainsString('收支净额', $source);
|
||||
$this->assertStringNotContainsString('<th>财务备注</th>', $source);
|
||||
$this->assertStringNotContainsString('<th>订单号</th>', $source);
|
||||
$this->assertStringNotContainsString('<th class="tb-num">金额</th>', $source);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user