diff --git a/config/business_data.php b/config/business_data.php index b5fae3c5..01a618bf 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -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' => '阿里巴巴账单'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 8a3ec9be..7399f7e2 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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']], diff --git a/database/migrations/2026_09_07_100000_create_store_receiving_alipay_bill_mirror.php b/database/migrations/2026_09_07_100000_create_store_receiving_alipay_bill_mirror.php new file mode 100644 index 00000000..4b5bbba3 --- /dev/null +++ b/database/migrations/2026_09_07_100000_create_store_receiving_alipay_bill_mirror.php @@ -0,0 +1,62 @@ +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'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index cbbda492..30956928 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` | diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 92382aff..10fa1b90 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -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) diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php new file mode 100644 index 00000000..5f83275b --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -0,0 +1,156 @@ +>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array} */ + 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); + } +} diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 1190d90d..09823b67 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -76,6 +76,7 @@
+
账单明细
{{ total }} 条
@@ -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; } diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index f18e0d2c..97422752 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -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') diff --git a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php index 1caf66d1..63cf644e 100644 --- a/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php +++ b/tests/Unit/EcommerceFinance/BusinessDataPlacementTest.php @@ -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); diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php new file mode 100644 index 00000000..7e0163e1 --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -0,0 +1,145 @@ + [ + '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' => '淘系', + ]; + } +} diff --git a/tests/Unit/View/TaobaoAlipayBillsTableTest.php b/tests/Unit/View/TaobaoAlipayBillsTableTest.php index e267676c..917eb89b 100644 --- a/tests/Unit/View/TaobaoAlipayBillsTableTest.php +++ b/tests/Unit/View/TaobaoAlipayBillsTableTest.php @@ -16,6 +16,8 @@ class TaobaoAlipayBillsTableTest extends TestCase $this->assertIsString($source); $this->assertStringContainsString('基础信息', $source); $this->assertStringContainsString('assertStringContainsString('v-if="error" class="tb-error"', $source); + $this->assertStringContainsString('e.response.data.message', $source); $this->assertStringNotContainsString('财务备注', $source); $this->assertStringNotContainsString('订单号', $source); $this->assertStringNotContainsString('金额', $source);