diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index beccc706..154b470a 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -7,6 +7,8 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Modules\EcommerceFinance\AccountBillColumns; use Modules\EcommerceFinance\Models\AccountBill; +use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; +use RuntimeException; class AccountBills extends Controller { @@ -25,6 +27,12 @@ class AccountBills extends Controller abort(404, '未知的账户类型。'); } + if ($billType === 'douyin') { + return $request->boolean('export') + ? $this->export($request, $billType) + : $this->douyinIndex($request); + } + $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'bill_item' => ['nullable', 'string', 'max:255'], @@ -72,7 +80,7 @@ class AccountBills extends Controller $query->orderBy($sort, $dir); if ($request->boolean('export')) { - return $this->export($request, $query, $billType); + return $this->exportQuery($request, $query, $billType); } $perPage = min(max((int) $request->get('limit', 10), 1), 100); @@ -102,11 +110,43 @@ class AccountBills extends Controller return response()->json($data); } - private function export(Request $request, $query, string $billType) + public function export(Request $request, string $bill_type) + { + $this->authorizeStoreReceivingBills(); + + abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); + + if ($bill_type === 'douyin') { + return $this->exportDouyin($request); + } + + $filters = $request->validate([ + 'store_name' => ['nullable', 'string', 'max:255'], + 'bill_item' => ['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'], + ]); + $query = $this->filteredQuery($request, $bill_type); + $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); + + return $this->exportQuery($request, $query, $bill_type); + } + + private function exportQuery(Request $request, $query, string $billType) { $definition = AccountBillColumns::for($billType); $available = collect($definition['columns'] ?? [])->keyBy('field'); $requested = $request->input('columns', []); + if (is_string($requested)) { + $requested = array_filter(array_map('trim', explode(',', $requested))); + } + if (! is_array($requested)) { + $requested = []; + } $columns = $requested === [] ? $available->values() : collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values(); @@ -126,7 +166,10 @@ class AccountBills extends Controller })->all()); } fclose($csv); - }, $billType . '_account_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + }, $billType . '_account_bills.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); } private function columnValue(AccountBill $row, string $field): mixed @@ -148,6 +191,175 @@ class AccountBills extends Controller return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text; } + private function filteredQuery(Request $request, string $billType) + { + $query = AccountBill::query() + ->where('company_id', company_id()) + ->where('bill_type', $billType); + + if ($store = $request->get('store_name')) { + $query->where('store_name', $store); + } + + if ($item = $request->get('bill_item')) { + $query->where('bill_item', $item); + } + + 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): void { + $like = '%' . $keyword . '%'; + $q->where('record_code', 'like', $like) + ->orWhere('order_no', 'like', $like) + ->orWhere('remark', 'like', $like) + ->orWhere('bill_item', 'like', $like); + }); + } + + return $query; + } + + private function douyinIndex(Request $request) + { + $filters = $this->douyinFilters($request); + $sort = $this->sort($request); + $direction = $this->direction($request); + $page = max((int) $request->get('page', 1), 1); + $perPage = min(max((int) $request->get('limit', 10), 1), 100); + + try { + $data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate( + $filters, + $sort, + $direction, + $page, + $perPage, + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。', + 'data' => [], + 'total' => 0, + 'current_page' => 1, + 'last_page' => 1, + 'per_page' => $perPage, + 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'columns' => AccountBillColumns::for('douyin'), + ], 503); + } + + $data['columns'] = AccountBillColumns::for('douyin'); + + return response()->json($data); + } + + private function exportDouyin(Request $request) + { + $filters = $this->douyinFilters($request); + $definition = AccountBillColumns::for('douyin'); + $columns = collect($definition['columns'] ?? []); + $requested = $request->query('columns', []); + if (is_string($requested)) { + $requested = array_filter(array_map('trim', explode(',', $requested))); + } + if (! is_array($requested)) { + $requested = []; + } + $requested = collect($requested)->map(static fn (string $field): string => trim($field))->filter()->unique()->values(); + if ($requested->isEmpty()) { + $selected = $columns; + } else { + $columnsByField = $columns->keyBy('field'); + $selected = $requested + ->map(static fn (string $field) => $columnsByField->get($field)) + ->filter(static fn ($column): bool => is_array($column)) + ->values(); + abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。'); + } + + $sort = $this->sort($request); + $direction = $this->direction($request); + try { + $rows = app(StoreReceivingDouyinBillMirrorRepository::class) + ->exportRows($filters, $sort, $direction); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + throw $exception; + } + + return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503); + } + + $headers = $selected->pluck('label')->all(); + + return response()->streamDownload(function () use ($rows, $selected, $headers): void { + $csv = fopen('php://output', 'w'); + fputs($csv, "\xEF\xBB\xBF"); + fputcsv($csv, $headers); + foreach ($rows as $row) { + fputcsv($csv, $selected->map( + fn (array $column): string => $this->exportArrayValue($row, $column) + )->all()); + } + fclose($csv); + }, 'douyin_account_bills.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array */ + private function douyinFilters(Request $request): array + { + $filters = $request->validate([ + 'store_name' => ['nullable', 'string', 'max:255'], + 'bill_item' => ['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'], + ]); + + return array_map(static fn (mixed $value): string => trim((string) $value), $filters); + } + + private function sort(Request $request): string + { + return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true) + ? (string) $request->get('sort') + : 'occurred_at'; + } + + private function direction(Request $request): string + { + return $request->get('dir') === 'asc' ? 'asc' : 'desc'; + } + + private function exportArrayValue(array $row, array $column): string + { + $field = (string) ($column['field'] ?? ''); + $value = str_starts_with($field, 'payload.') + ? data_get($row['payload'] ?? [], substr($field, 8)) + : data_get($row, $field); + + $text = trim((string) ($value ?? '')); + if (preg_match('/^\d{12,}$/', $text) === 1) { + return '="' . $text . '"'; + } + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } + public function show(AccountBill $bill) { $this->authorizeStoreReceivingBills(); diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 14471a83..34520ed5 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -104,7 +104,14 @@ class AlipayBills extends Controller unset($filters['columns']); try { - $rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters)); + $sortable = ['record_code', 'occurred_at', 'income', 'expense', 'balance']; + $sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at'; + $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; + $rows = $repository->exportRows( + array_map(static fn (mixed $value): string => trim((string) $value), $filters), + $sort, + $dir, + ); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { throw $exception; diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 5b7952c1..9eacb227 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -20,6 +20,26 @@ class StoreReceivingAlipayBillMirrorRepository /** @return array{data:array>,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 + { + return $this->paginateChannel($filters, $sort, $direction, $page, $perPage, null, 'taobao'); + } + + /** + * Read one upstream platform channel from the shared original-bill mirror. + * The mirror is shared by several account pages; callers must provide a + * fixed source channel rather than accepting arbitrary SQL/filter input. + * + * @return array{data:array>,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 paginateChannel( + array $filters, + string $sort, + string $direction, + int $page, + int $perPage, + ?string $channel, + string $mapping = 'taobao', + ): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset) @@ -39,16 +59,17 @@ class StoreReceivingAlipayBillMirrorRepository ][$sort] ?? 'b.fee_time'; $query = $this->billQuery(); + $this->applyChannel($query, $channel); $this->applyFilters($query, $filters); $state = $this->mirror->state($dataset) ?? []; // The synchronizer persists an exact COUNT in the same transaction that // marks a run successful. Never substitute a database row estimate. $hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== ''); $recordedCount = $state['mirror_row_count'] ?? null; - $useRecordedCount = ! $hasFilters && ($state['status'] ?? null) === 'succeeded' + $useRecordedCount = $channel === null && ! $hasFilters && ($state['status'] ?? null) === 'succeeded' && $recordedCount !== null && ctype_digit((string) $recordedCount); $total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count(); - $summary = $this->summary($query, $filters); + $summary = $this->summary($query, $filters, $channel); $billRows = $query ->select('b.*') @@ -59,7 +80,7 @@ class StoreReceivingAlipayBillMirrorRepository ->all(); $shopNames = $this->shopNames($billRows); $rows = collect($billRows) - ->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null)) + ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping)) ->all(); $income = $summary['income']; $expense = $summary['expense']; @@ -84,7 +105,19 @@ class StoreReceivingAlipayBillMirrorRepository } /** @return iterable> */ - public function exportRows(array $filters): iterable + public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable + { + return $this->exportChannelRows($filters, $sort, $direction, null, 'taobao'); + } + + /** @return iterable> */ + public function exportChannelRows( + array $filters, + string $sort = 'occurred_at', + string $direction = 'asc', + ?string $channel = null, + string $mapping = 'taobao', + ): iterable { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset) @@ -93,12 +126,21 @@ class StoreReceivingAlipayBillMirrorRepository } $query = $this->billQuery(); + $this->applyChannel($query, $channel); $this->applyFilters($query, $filters); $shopNames = $this->allShopNames(); + $direction = $direction === 'desc' ? 'desc' : 'asc'; + $sortColumn = [ + 'record_code' => 'b.Id', + 'occurred_at' => 'b.fee_time', + 'income' => 'b.amount', + 'expense' => 'b.amount', + 'balance' => 'b.amount', + ][$sort] ?? 'b.fee_time'; 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)); + ->orderBy($sortColumn, $direction)->orderBy('b.Id', $direction)->cursor() + ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping)); } private function billQuery(): Builder @@ -107,9 +149,9 @@ class StoreReceivingAlipayBillMirrorRepository } /** @return array{income:float,expense:float} */ - private function summary(Builder $billQuery, array $filters): array + private function summary(Builder $billQuery, array $filters, ?string $channel = null): array { - if ($this->dailySummarySupports($filters)) { + if ($channel === null && $this->dailySummarySupports($filters)) { $summary = $this->dailySummary($filters); if ($summary !== null) { return $summary; @@ -210,6 +252,13 @@ class StoreReceivingAlipayBillMirrorRepository } } + private function applyChannel(Builder $query, ?string $channel): void + { + if ($channel !== null && $channel !== '') { + $query->where('b.channel', $channel); + } + } + /** @param array $rows @return array */ private function shopNames(array $rows): array { @@ -269,6 +318,54 @@ class StoreReceivingAlipayBillMirrorRepository ]; } + /** @return array */ + private function mapRowFor(object $row, ?string $shopName, string $mapping): array + { + if ($mapping !== 'douyin') { + return $this->mapRow($row, $shopName); + } + + $amount = abs((float) ($row->amount ?? 0)); + $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; + $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; + $direction = $this->directionName($row->inout_type); + + return [ + 'id' => (string) $row->Id, + 'record_code' => (string) $row->Id, + 'store_name' => $shopName, + 'finance_remark' => null, + 'order_no' => $row->so_id ?: $row->p_auto_id, + 'amount' => $amount, + 'custom_remark' => null, + 'bill_item' => $row->fee_name, + 'occurred_at' => $this->dateTime($row->fee_time), + 'income' => $income, + 'expense' => $expense, + 'balance' => $income - $expense, + 'remark' => null, + 'modifier' => '聚水潭', + 'created_at' => $this->dateTime($row->createTime), + 'updated_at' => $this->dateTime($row->updateTime), + 'payload' => [ + 'merchant_name' => $shopName, + 'move_serial_no' => $row->auto_id ?: $row->primary_key, + 'order_no_2' => $row->p_auto_id ?: $row->so_id, + 'online_order_no' => $row->so_id, + 'sub_order_no' => $row->shop_i_id, + 'goods_id' => $row->sku_id ?: $row->shop_sku_id, + 'account_type' => $direction, + 'move_summary' => $row->fee_name, + 'order_placed_at' => $this->dateTime($row->createTime), + 'business_type' => $row->combine_type, + 'settle_account' => $row->channel, + 'move_scene' => '头条放心购', + 'talent_id' => null, + 'talent_name' => null, + ], + ]; + } + private function directionCode(string $value): string { return match ($value) { diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php new file mode 100644 index 00000000..fa6092a9 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php @@ -0,0 +1,40 @@ + */ + public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array + { + return $this->source->paginateChannel( + $filters, + $sort, + $direction, + $page, + $perPage, + self::SOURCE_CHANNEL, + 'douyin', + ); + } + + /** @return iterable> */ + public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable + { + return $this->source->exportChannelRows( + $filters, + $sort, + $direction, + self::SOURCE_CHANNEL, + 'douyin', + ); + } +} diff --git a/resources/assets/js/views/business/pages.js b/resources/assets/js/views/business/pages.js index 0c0da74f..33beb2b7 100644 --- a/resources/assets/js/views/business/pages.js +++ b/resources/assets/js/views/business/pages.js @@ -5,8 +5,9 @@ * * 店铺收款账户账单(store-receiving-bills)下各 tab: * - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像) - * - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu - * → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发) + * - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / dewu + * → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分) + * - douyin → AccountBills 展示共享原始账单镜像中的头条放心购渠道 */ require('./../../bootstrap'); diff --git a/resources/views/business/pages/partials/finance-table-interactions.blade.php b/resources/views/business/pages/partials/finance-table-interactions.blade.php new file mode 100644 index 00000000..bfbd07ca --- /dev/null +++ b/resources/views/business/pages/partials/finance-table-interactions.blade.php @@ -0,0 +1,708 @@ +@once + + +@php + $financeTableContext = [ + 'user' => auth()->id() ?? 'guest', + 'section' => $section['slug'] ?? '', + 'page' => $page['slug'] ?? '', + 'tab' => $activeTab['slug'] ?? '', + ]; +@endphp + +@endonce diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index 540626c1..de53b9c8 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -1071,6 +1071,8 @@ @endif + @include('business.pages.partials.finance-table-interactions') + @if ($isMarketingAccountBillsPage) @push('scripts_start') diff --git a/routes/admin.php b/routes/admin.php index ab0f06de..3629b24b 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -25,6 +25,8 @@ Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'] Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index']) ->name('ecommerce-finance.account-bills.index'); +Route::get('ecommerce-finance/account-bills/type/{bill_type}/export', [AccountBills::class, 'export']) + ->name('ecommerce-finance.account-bills.export'); Route::get('ecommerce-finance/account-bills/{bill}', [AccountBills::class, 'show']) ->name('ecommerce-finance.account-bills.show'); diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 1a701b38..74e03258 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -8,6 +8,7 @@ use Modules\EcommerceFinance\View\OrderDataPageData; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; +use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; use Tests\Feature\FeatureTestCase; class PagesTest extends FeatureTestCase @@ -116,6 +117,94 @@ class PagesTest extends FeatureTestCase ->assertSee('public/js/business/pages.min.js', false); } + public function testDouyinAccountBillsEndpointReadsTheFixedTouTiaoChannel(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->with( + [], 'occurred_at', 'desc', 1, 10, + )->andReturn([ + 'current_page' => 1, + 'last_page' => 1, + 'per_page' => 10, + 'total' => 1, + 'data' => [[ + 'id' => '9001', + 'record_code' => '9001', + 'store_name' => '放心购旗舰店', + 'income' => 88.50, + 'expense' => 0.0, + 'payload' => [ + 'settle_account' => 'TouTiaoFXGZJ', + 'move_scene' => '头条放心购', + ], + ]], + 'summary' => ['income' => 88.50, 'expense' => 0.0, 'balance' => 88.50, 'count' => 1], + 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'], + ]); + $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); + + $this->loginAs() + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin'])) + ->assertOk() + ->assertJsonPath('data.0.payload.move_scene', '头条放心购') + ->assertJsonPath('data.0.payload.settle_account', 'TouTiaoFXGZJ') + ->assertJsonPath('columns.title', '抖音账户') + ->assertJsonPath('summary.count', 1); + } + + public function testDouyinAccountBillsExportUsesSelectedColumnOrder(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); + $repository->shouldReceive('exportRows')->once()->with([], 'occurred_at', 'desc')->andReturn([[ + 'record_code' => '9001', + 'payload' => [ + 'move_scene' => '头条放心购', + 'settle_account' => 'TouTiaoFXGZJ', + ], + ]]); + $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [ + 'bill_type' => 'douyin', + 'columns' => 'payload.move_scene,record_code,payload.settle_account', + ])); + + $response->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8') + ->assertDownload('douyin_account_bills.csv'); + $csv = $response->streamedContent(); + $this->assertStringContainsString('动账场景,记录编码,结算账户', $csv); + $this->assertStringContainsString('头条放心购,9001,TouTiaoFXGZJ', $csv); + } + + public function testDouyinAccountBillsReturnsARetryableResponseWhenMirrorIsNotReady(): void + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andThrow( + new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready') + ); + $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); + + $this->loginAs() + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin'])) + ->assertStatus(503) + ->assertJsonPath('message', '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。'); + } + public function testTaobaoAlipayBillsExportUsesTheMirrorRepository() { $this->user->attachPermission( @@ -127,7 +216,7 @@ class PagesTest extends FeatureTestCase static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店' && ($filters['start'] ?? '') === '2026-08-01' && ($filters['end'] ?? '') === '2026-08-31' - ))->andReturn([[ + ), 'occurred_at', 'desc')->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', diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php index 0b1c3355..aa4001a1 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; +use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; use Tests\TestCase; @@ -193,6 +194,34 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase $this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']); } + public function test_douyin_reads_only_toutiao_fangxin_gou_rows_and_maps_flow_fields(): void + { + $this->markReady(); + $db = DB::connection('business_mirror'); + $db->table('ops_jst_shop_info')->insert([ + 'Id' => 10, 'shop_id' => 7, 'shop_name' => '放心购旗舰店', + ]); + $db->table('ops_jst_original_bill_info')->insert([ + $this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25, 'TouTiaoFXGZJ'), + $this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10, 'CommonBillTGCPay'), + ]); + + $douyin = new StoreReceivingDouyinBillMirrorRepository($this->repository); + $result = $douyin->paginate([], 'occurred_at', 'desc', 1, 10); + + $this->assertSame(1, $result['total']); + $this->assertSame('1', $result['data'][0]['record_code']); + $this->assertSame('放心购旗舰店', $result['data'][0]['store_name']); + $this->assertSame('TouTiaoFXGZJ', $result['data'][0]['payload']['settle_account']); + $this->assertSame('头条放心购', $result['data'][0]['payload']['move_scene']); + $this->assertSame(100.25, $result['summary']['income']); + $this->assertSame(0.0, $result['summary']['expense']); + + $rows = iterator_to_array($douyin->exportRows([], 'occurred_at', 'asc')); + $this->assertCount(1, $rows); + $this->assertSame('1', $rows[0]['record_code']); + } + private function markReady(): void { foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) { @@ -205,10 +234,17 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase } } - private function row(int $id, int $shopId, string $time, string $direction, float $amount): array + private function row( + int $id, + int $shopId, + string $time, + string $direction, + float $amount, + string $channel = 'CommonBillTGCPay', + ): array { return [ - 'Id' => $id, 'shop_id' => $shopId, 'channel' => 'CommonBillTGCPay', + 'Id' => $id, 'shop_id' => $shopId, 'channel' => $channel, '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,