feat: 整合淘系支付宝账单共享业务镜像 (#109)

This commit is contained in:
WuYongJie 2026-09-07 14:41:37 +08:00
parent a989734b33
commit 050ad0ab97
11 changed files with 449 additions and 99 deletions

View File

@ -93,6 +93,7 @@ return [
'jiansun.dbo.jst_small_payment_order_map' => ['role' => 'operational', 'target_store' => 'mariadb', 'consumer' => '小额打款工单'],
// Account bills and supplier pages are bounded serving datasets today.
'jiansun.dbo.alipay_Bill_Detail' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/淘系支付宝账户'],
'jiansun.dbo.tmall_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '淘宝推广账单'],
'jiansun.dbo.pdd_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '拼多多推广账单'],
'jiansun.dbo.alibaba_bill' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '阿里巴巴账单'],

View File

@ -301,6 +301,24 @@ return [
],
// JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。
'jiansun.dbo.alipay_Bill_Detail' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'alipay_Bill_Detail',
'target' => 'js_alipay_bill_detail',
'keys' => ['Id'],
'watermark' => null,
'columns' => [
'Id', 'accountNum', 'businessNum', 'merchantOrder', 'commodityName',
'happenTime', 'oppAccount', 'inAmount', 'outAmount', 'accountAmount',
'tradeChannel', 'businessType', 'remark', 'shopName', 'date',
'busiBillSource', 'busiDescription', 'busiNum', 'busiBasicNum',
'buyerRemark', 'storeNum', 'appId', 'account', 'bindingType',
],
'indexes' => [
['happenTime', 'Id'],
],
],
'jiansun.dbo.tmall_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'tmall_spread_bills', 'target' => 'js_tmall_spread_bills', 'keys' => ['id']],
'jiansun.dbo.pdd_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pdd_spread_bills', 'target' => 'js_pdd_spread_bills', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.alibaba_bill' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'alibaba_bill', 'target' => 'js_alibaba_bill', 'keys' => ['id']],

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('js_alipay_bill_detail')) {
return;
}
$schema->create('js_alipay_bill_detail', function (Blueprint $table): void {
$table->char('_mirror_key', 64)->primary();
$table->integer('Id')->nullable();
$table->string('accountNum', 128)->nullable();
$table->string('businessNum', 128)->nullable();
$table->string('merchantOrder', 255)->nullable();
$table->text('commodityName')->nullable();
$table->dateTime('happenTime', 6)->nullable();
$table->string('oppAccount', 255)->nullable();
$table->decimal('inAmount', 20, 6)->nullable();
$table->decimal('outAmount', 20, 6)->nullable();
$table->decimal('accountAmount', 20, 6)->nullable();
$table->string('tradeChannel', 255)->nullable();
$table->string('businessType', 255)->nullable();
$table->text('remark')->nullable();
$table->string('shopName', 255)->nullable();
$table->date('date')->nullable();
$table->string('busiBillSource', 255)->nullable();
$table->text('busiDescription')->nullable();
$table->string('busiNum', 255)->nullable();
$table->string('busiBasicNum', 255)->nullable();
$table->text('buyerRemark')->nullable();
$table->string('storeNum', 255)->nullable();
$table->string('appId', 128)->nullable();
$table->string('account', 255)->nullable();
$table->string('bindingType', 128)->nullable();
$table->uuid('_mirror_generation')->nullable()->index();
$table->dateTime('_mirror_synced_at', 6)->index();
$table->index(['happenTime', 'Id'], 'alipay_detail_happen_id_index');
$table->index(['shopName', 'happenTime', 'Id'], 'alipay_detail_shop_happen_id_index');
$table->index(['appId', 'happenTime', 'Id'], 'alipay_detail_app_happen_id_index');
$table->index(['businessType', 'happenTime', 'Id'], 'alipay_detail_type_happen_id_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('js_alipay_bill_detail');
}
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
};

View File

@ -21,6 +21,7 @@
| 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 |
| 中央同步接入 | 店铺维度报表从 `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)
@ -51,6 +52,7 @@
| 业务范围 | 上游对象 |
| --- | --- |
| 店铺收款账单 | `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` |

View File

@ -6,6 +6,8 @@ use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Models\AlipayBill;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use RuntimeException;
class AlipayBills extends Controller
{
@ -13,97 +15,46 @@ class AlipayBills extends Controller
* 淘系支付宝账户账单列表(店铺收款账户账单 › 淘系支付宝账户)。
* 经由 admin 路由组(web 会话)暴露,前端带 CSRF 调用即可。
*/
public function index(Request $request)
public function index(Request $request, StoreReceivingAlipayBillMirrorRepository $repository)
{
$this->authorizeStoreReceivingBills();
$query = AlipayBill::query()->where('company_id', company_id());
if ($store = $request->get('store_name')) {
$query->where('store_name', $store);
}
if ($alipay = $request->get('alipay_account')) {
$query->where('alipay_account', 'like', '%' . $alipay . '%');
}
if ($item = $request->get('bill_item')) {
$query->where('bill_item', $item);
}
if ($type = $request->get('business_type')) {
$query->where('business_type', $type);
}
if ($orderNo = $request->get('order_no')) {
$query->where('order_no', 'like', '%' . $orderNo . '%');
}
if ($financeRemark = $request->get('finance_remark')) {
$query->where('finance_remark', 'like', '%' . $financeRemark . '%');
}
if ($start = $request->get('start')) {
$query->where('occurred_at', '>=', $start);
}
if ($end = $request->get('end')) {
$query->where('occurred_at', '<=', $end . ' 23:59:59');
}
if ($keyword = $request->get('keyword')) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->where('goods_name', 'like', $like)
->orWhere('record_code', 'like', $like)
->orWhere('order_no', 'like', $like)
->orWhere('account_order_no', 'like', $like)
->orWhere('business_serial_no', 'like', $like)
->orWhere('merchant_order_no', 'like', $like)
->orWhere('online_order_no', 'like', $like)
->orWhere('finance_remark', 'like', $like)
->orWhere('remark', 'like', $like);
});
}
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
$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'],
]);
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$rows = $query->paginate($perPage);
$rows->getCollection()->transform(function (AlipayBill $bill) {
$data = $bill->toArray();
foreach (['occurred_at', 'created_at', 'updated_at'] as $field) {
$data[$field] = $bill->{$field}?->format('Y-m-d H:i:s');
try {
return response()->json($repository->paginate(
array_map(static fn (mixed $value): string => trim((string) $value), $filters),
$sort,
$dir,
max((int) $request->get('page', 1), 1),
min(max((int) $request->get('limit', 10), 1), 100),
));
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return $data;
});
// 汇总:基于当前筛选结果(分页前)
$summaryRow = (clone $query)->selectRaw(
'COALESCE(SUM(income), 0) as income, ' .
'COALESCE(SUM(expense), 0) as expense, ' .
'COUNT(*) as count'
)->first();
$latestBalance = (clone $query)
->orderByDesc('occurred_at')
->orderByDesc('id')
->value('balance');
$data = $rows->toArray();
$data['summary'] = [
'income' => (float) ($summaryRow->income ?? 0),
'expense' => (float) ($summaryRow->expense ?? 0),
'count' => (int) ($summaryRow->count ?? 0),
'balance' => $latestBalance !== null ? (float) $latestBalance : 0,
];
return response()->json($data);
return response()->json([
'message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。',
'data' => [],
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'per_page' => min(max((int) $request->get('limit', 10), 1), 100),
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
], 503);
}
}
public function show(AlipayBill $bill)

View File

@ -0,0 +1,156 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/** 店铺收款账单·淘系支付宝账户只读共享业务镜像。 */
class StoreReceivingAlipayBillMirrorRepository
{
public const DATASET = 'jiansun.dbo.alipay_Bill_Detail';
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {}
/** @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>} */
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)) {
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
}
$page = max($page, 1);
$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';
$query = $this->mirror->connection()->table($dataset->targetTable());
$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');
$rows = $query
->orderBy($sortColumn, $direction)
->orderBy('Id', $direction)
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
$state = $this->mirror->state($dataset) ?? [];
return [
'data' => $rows,
'total' => $total,
'current_page' => $page,
'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),
],
'sync' => [
'status' => $state['status'] ?? 'unknown',
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
],
];
}
private function applyFilters(Builder $query, array $filters): void
{
if (($filters['store_name'] ?? '') !== '') {
$query->where('shopName', $filters['store_name']);
}
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);
});
}
if (($filters['bill_item'] ?? '') !== '') {
$query->where('tradeChannel', $filters['bill_item']);
}
if (($filters['business_type'] ?? '') !== '') {
$query->where('businessType', $filters['business_type']);
}
if (($filters['start'] ?? '') !== '') {
$query->where('happenTime', '>=', $filters['start'] . ' 00:00:00');
}
if (($filters['end'] ?? '') !== '') {
$query->where('happenTime', '<', 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);
});
}
}
private function mapRow(object $row): array
{
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' => '',
'modifier' => '',
];
}
private function dateTime(mixed $value): string
{
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
}
private function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}
}

View File

@ -76,6 +76,7 @@
<!-- 数据表 -->
<div class="tb-panel">
<div v-if="error" class="tb-error" role="alert">{{ error }}</div>
<div class="tb-phead">
<div class="tb-title">账单明细</div>
<div class="tb-count" v-if="!loading">{{ total }} 条</div>
@ -192,6 +193,7 @@ export default {
data() {
return {
loading: false,
error: '',
rows: [],
summary: { income: 0, expense: 0, balance: 0, count: 0 },
total: 0,
@ -227,6 +229,7 @@ export default {
methods: {
async fetch() {
this.loading = true;
this.error = '';
const params = {
...this.filters,
sort: this.sort.key,
@ -249,6 +252,8 @@ export default {
this.rows = [];
this.total = 0;
this.summary = { income: 0, expense: 0, balance: 0, count: 0 };
this.error = (e.response && e.response.data && e.response.data.message)
|| '淘系支付宝账单暂时无法读取,请稍后重试。';
} finally {
this.loading = false;
}
@ -380,6 +385,15 @@ export default {
box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
overflow: hidden;
}
.tb-error {
margin: 1rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #f7f0de;
padding: 0.75rem 1rem;
color: #935f07;
font-size: 0.8125rem;
}
.tb-phead { display: flex; align-items: center; gap: 0.625rem; padding: 0.875rem 1rem; border-bottom: 1px solid #e6e9f0; }
.tb-title { font-weight: 600; font-size: 0.875rem; }
.tb-count { color: #9aa3b2; font-size: 0.75rem; }

View File

@ -3,8 +3,9 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Modules\EcommerceFinance\Models\AlipayBill;
use Mockery;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Tests\Feature\FeatureTestCase;
class PagesTest extends FeatureTestCase
@ -15,22 +16,20 @@ class PagesTest extends FeatureTestCase
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
AlipayBill::create([
'company_id' => $this->company->id,
'record_code' => 'time-format-test',
'store_name' => '测试店铺',
'bill_item' => '交易收款',
'business_type' => '交易付款',
'alipay_account' => '2088000000000000',
'counterparty_account' => '测试账号',
'account_order_no' => 'account-order-test',
'business_serial_no' => 'business-serial-test',
'goods_name' => '测试商品',
'income' => 1,
'expense' => 0,
'balance' => 1,
'occurred_at' => '2026-07-15 23:59:12',
$repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andReturn([
'current_page' => 1,
'last_page' => 1,
'per_page' => 10,
'total' => 1,
'data' => [[
'record_code' => 'time-format-test',
'occurred_at' => '2026-07-15 23:59:12',
]],
'summary' => ['income' => 1.0, 'expense' => 0.0, 'count' => 1, 'balance' => 1.0],
'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-07 09:00:00'],
]);
$this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')

View File

@ -75,7 +75,7 @@ class BusinessDataPlacementTest extends TestCase
$upstream = require base_path('config/upstream_mirrors.php');
$businessData = require base_path('config/business_data.php');
$this->assertCount(51, $upstream['datasets']);
$this->assertCount(52, $upstream['datasets']);
$upstreamKeys = array_keys($upstream['datasets']);
$policyKeys = array_keys($businessData['datasets']);
sort($upstreamKeys);

View File

@ -0,0 +1,145 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Tests\TestCase;
class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
{
private StoreReceivingAlipayBillMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'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',
'keys' => ['Id'],
],
],
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->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 {
$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->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();
});
$this->repository = new StoreReceivingAlipayBillMirrorRepository(
new BusinessMirrorDatasetRegistry(),
new GenericBusinessMirrorRepository(app('db')),
);
}
public function test_it_returns_filtered_pages_and_summary_using_attachment_field_names(): 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),
]);
$result = $this->repository->paginate(
['store_name' => '天猫-测试店', 'start' => '2026-08-01', 'end' => '2026-08-31'],
'occurred_at',
'desc',
2,
1,
);
$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(100.25, $result['summary']['income']);
$this->assertSame(20.10, $result['summary']['expense']);
$this->assertSame(480.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);
}
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',
]);
}
private function row(int $id, string $shop, string $time, float $income, float $expense, float $balance): 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' => '淘系',
];
}
}

View File

@ -16,6 +16,8 @@ class TaobaoAlipayBillsTableTest extends TestCase
$this->assertIsString($source);
$this->assertStringContainsString('<th colspan="4">基础信息</th>', $source);
$this->assertStringContainsString('<td :colspan="25"', $source);
$this->assertStringContainsString('v-if="error" class="tb-error"', $source);
$this->assertStringContainsString('e.response.data.message', $source);
$this->assertStringNotContainsString('<th>财务备注</th>', $source);
$this->assertStringNotContainsString('<th>订单号</th>', $source);
$this->assertStringNotContainsString('<th class="tb-num">金额</th>', $source);