diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index b21688b2..dde88efc 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -13,6 +13,7 @@ use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; +use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; @@ -705,6 +706,89 @@ class Pages extends Controller return null; } + // Alibaba 页面沿用历史 taobao-wechat 路由,数据来自 JianSun.dbo.alibaba_bill。 + if ($tab === 'taobao-wechat') { + $request = request(); + $shopName = mb_substr(trim((string) $request->query('shop_name')), 0, 191); + $productType = mb_substr(trim((string) $request->query('product_type')), 0, 191); + $fromDate = $this->dateFilter($request->query('from')); + $toDate = $this->dateFilter($request->query('to')); + $page = max(1, (int) $request->query('page', 1)); + $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); + $filters = [ + 'shop_name' => $shopName !== '' ? $shopName : null, + 'product_type' => $productType !== '' ? $productType : null, + 'from' => $fromDate !== '' ? $fromDate : null, + 'to' => $toDate !== '' ? $toDate : null, + ]; + $repository = app(AlibabaBillRepository::class); + $cache = $this->upstreamCaches->forSource('jiansun'); + + try { + $result = $cache->remember( + 'marketing-account-bills.alibaba.list', + ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], + fn () => $repository->paginate( + $filters['shop_name'], $filters['product_type'], + $filters['from'], $filters['to'], $page, $pageSize, + ), + ); + $summary = $cache->remember( + 'marketing-account-bills.alibaba.summary', + ['filters' => $filters], + fn () => $repository->summary( + $filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'], + ), + ); + $options = $cache->remember( + 'marketing-account-bills.alibaba.options', + [], + fn () => $repository->filterOptions(), + ); + } catch (ServiceUnavailableHttpException $exception) { + return [ + 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', + 'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [ + 'path' => $request->url(), 'query' => $request->query(), + ]), + 'filters' => $filters, + 'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0], + 'options' => ['shops' => [], 'product_types' => []], + 'error' => $exception->getMessage(), + ]; + } catch (Throwable $exception) { + report($exception); + + return [ + 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', + 'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [ + 'path' => $request->url(), 'query' => $request->query(), + ]), + 'filters' => $filters, + 'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0], + 'options' => ['shops' => [], 'product_types' => []], + 'error' => 'Alibaba 账单数据暂时无法读取,请稍后重试。', + ]; + } + + $bills = new LengthAwarePaginator( + Collection::make($result['rows'] ?? []), + (int) ($result['total'] ?? 0), + $pageSize, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + + return [ + 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', + 'bills' => $bills, + 'filters' => $filters, + 'summary' => $summary, + 'options' => $options, + 'error' => null, + ]; + } + // 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列) if ($tab === 'taobao-alipay') { $request = request(); diff --git a/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php new file mode 100644 index 00000000..f1dea8d9 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php @@ -0,0 +1,138 @@ +query = $factory->forSource(self::SOURCE); + } + + /** @return array{shops: string[], product_types: string[]} */ + public function filterOptions(): array + { + $shops = $this->query->select( + 'SELECT DISTINCT TOP (500) shopName FROM ' . self::TABLE + . ' WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> \'\' ORDER BY shopName' + ); + $types = $this->query->select( + 'SELECT DISTINCT TOP (500) tradeTypeName FROM ' . self::TABLE + . ' WHERE tradeTypeName IS NOT NULL AND LTRIM(RTRIM(tradeTypeName)) <> \'\' ORDER BY tradeTypeName' + ); + + return [ + 'shops' => array_values(array_map(static fn (array $row): string => (string) ($row['shopName'] ?? ''), $shops)), + 'product_types' => array_values(array_map(static fn (array $row): string => (string) ($row['tradeTypeName'] ?? ''), $types)), + ]; + } + + /** @return array{rows: array>, total: int} */ + public function paginate( + ?string $shopName = null, + ?string $productType = null, + ?string $fromDate = null, + ?string $toDate = null, + int $page = 1, + int $pageSize = 25, + ): array { + $page = max(1, $page); + $pageSize = min(max(1, $pageSize), 100); + [$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate); + $total = (int) $this->query->scalar('SELECT COUNT(*) FROM ' . self::TABLE . ' src' . $where, $bindings); + + if ($total === 0) { + return ['rows' => [], 'total' => 0]; + } + + $start = (($page - 1) * $pageSize) + 1; + $end = $page * $pageSize; + $sql = 'SELECT * FROM (' + . 'SELECT src.id, src.shopName, src.tradeDay, src.tradeTypeName, ' + . 'src.tradeAMTIn, src.tradeAMTOut, ' + . 'ROW_NUMBER() OVER (ORDER BY src.tradeDay DESC, src.id DESC) AS rn ' + . 'FROM ' . self::TABLE . ' src' . $where + . ') AS t WHERE t.rn BETWEEN ? AND ? ORDER BY t.rn'; + + $rows = $this->query->select($sql, array_merge($bindings, [$start, $end])); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalize($row), $rows), + 'total' => $total, + ]; + } + + /** @return array{income_total: float, expense_total: float, net: float, count: int} */ + public function summary( + ?string $shopName = null, + ?string $productType = null, + ?string $fromDate = null, + ?string $toDate = null, + ): array { + [$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate); + $row = $this->query->first( + 'SELECT COALESCE(SUM(tradeAMTIn), 0) AS income_total, ' + . 'COALESCE(SUM(tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt ' + . 'FROM ' . self::TABLE . ' src' . $where, + $bindings, + ) ?? []; + $income = round((float) ($row['income_total'] ?? 0), 2); + $expense = round((float) ($row['expense_total'] ?? 0), 2); + + return [ + 'income_total' => $income, + 'expense_total' => $expense, + 'net' => round($income - $expense, 2), + 'count' => (int) ($row['cnt'] ?? 0), + ]; + } + + /** @return array{0: string, 1: array} */ + private function where(?string $shopName, ?string $productType, ?string $fromDate, ?string $toDate): array + { + $conditions = []; + $bindings = []; + if ($shopName !== null && $shopName !== '') { + $conditions[] = 'src.shopName = ?'; + $bindings[] = $shopName; + } + if ($productType !== null && $productType !== '') { + $conditions[] = 'src.tradeTypeName = ?'; + $bindings[] = $productType; + } + if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) { + $conditions[] = 'CAST(src.tradeDay AS date) >= CAST(? AS date)'; + $bindings[] = $fromDate; + } + if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) { + $conditions[] = 'CAST(src.tradeDay AS date) <= CAST(? AS date)'; + $bindings[] = $toDate; + } + + return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $bindings]; + } + + private function normalize(array $row): array + { + return [ + 'shop_name' => (string) ($row['shopName'] ?? ''), + 'trade_day' => (string) ($row['tradeDay'] ?? ''), + 'product_type' => (string) ($row['tradeTypeName'] ?? ''), + 'income_amount' => round((float) ($row['tradeAMTIn'] ?? 0), 2), + 'expense_amount' => round((float) ($row['tradeAMTOut'] ?? 0), 2), + ]; + } +} diff --git a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php index 09c07280..c0a38bbe 100644 --- a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php +++ b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php @@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services; use InvalidArgumentException; use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher; +use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; @@ -24,6 +25,7 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher private readonly EdgeBandingRepository $edgeBanding, private readonly GoodsReconciliationRepository $goods, private readonly InventoryRepository $inventory, + private readonly AlibabaBillRepository $alibaba, private readonly PinduoduoSpreadBillRepository $pinduoduo, private readonly ProductPriceRepository $productPrices, private readonly RawBoardPayablesRepository $rawBoardPayables, @@ -55,11 +57,14 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher $filters = (array) ($parameters['filters'] ?? []); $page = max((int) ($parameters['page'] ?? 1), 1); $limit = min(max((int) ($parameters['limit'] ?? 25), 1), 100); - $storeName = isset($filters['store_name']) ? (string) $filters['store_name'] : null; + $storeName = isset($filters['shop_name']) + ? (string) $filters['shop_name'] + : (isset($filters['store_name']) ? (string) $filters['store_name'] : null); $fromDate = isset($filters['from']) ? (string) $filters['from'] : null; $toDate = isset($filters['to']) ? (string) $filters['to'] : null; $flowType = isset($filters['flow_type']) ? (string) $filters['flow_type'] : null; $flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : null; + $productType = isset($filters['product_type']) ? (string) $filters['product_type'] : null; return match ($scope) { 'edge-banding.prices' => $this->edgeBanding->prices($filters, $page, $limit), @@ -72,6 +77,22 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher 'inventory.board-details' => $this->inventory->boardInventoryDetails($filters, $page, $limit), + 'marketing-account-bills.alibaba.list' => $this->alibaba->paginate( + $storeName, + $productType, + $fromDate, + $toDate, + $page, + $limit, + ), + 'marketing-account-bills.alibaba.summary' => $this->alibaba->summary( + $storeName, + $productType, + $fromDate, + $toDate, + ), + 'marketing-account-bills.alibaba.options' => $this->alibaba->filterOptions(), + 'raw-board.prices' => $this->rawBoardPayables->prices($filters, $page, $limit), 'raw-board.reconciliations' => $this->rawBoardPayables->reconciliations($filters, $page, $limit), diff --git a/resources/views/banking/marketing_alibaba_bills/partials/embedded-table.blade.php b/resources/views/banking/marketing_alibaba_bills/partials/embedded-table.blade.php new file mode 100644 index 00000000..4bb57a4b --- /dev/null +++ b/resources/views/banking/marketing_alibaba_bills/partials/embedded-table.blade.php @@ -0,0 +1,109 @@ +{{-- Alibaba/1688 营销账户账单:店铺名称 | 交易时间 | 产品类型 | 入账金额 | 出帐金额 --}} + + +@php + $summary = $summary ?? ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0]; + $filters = $filters ?? ['shop_name' => '', 'product_type' => '', 'from' => '', 'to' => '']; + $options = $options ?? ['shops' => [], 'product_types' => []]; + $shops = is_array($options['shops'] ?? null) ? $options['shops'] : []; + $productTypes = is_array($options['product_types'] ?? null) ? $options['product_types'] : []; +@endphp + +
+ @if (!empty($error)) + + @endif + +
+ @foreach ([ + ['label' => '入账合计(元)', 'value' => (float) ($summary['income_total'] ?? 0), 'class' => 'alibaba-bill-income', 'prefix' => '+ '], + ['label' => '出帐合计(元)', 'value' => (float) ($summary['expense_total'] ?? 0), 'class' => 'alibaba-bill-expense', 'prefix' => '- '], + ['label' => '净额(入账 - 出帐,元)', 'value' => (float) ($summary['net'] ?? 0), 'class' => ((float) ($summary['net'] ?? 0) >= 0 ? 'text-gray-900' : 'alibaba-bill-expense'), 'prefix' => ((float) ($summary['net'] ?? 0) >= 0 ? '+ ' : '')], + ['label' => '流水笔数', 'value' => (int) ($summary['count'] ?? 0), 'class' => 'text-gray-900', 'prefix' => ''], + ] as $metric) +
+
{{ $metric['label'] }}
+
+ {{ $metric['prefix'] }}{{ number_format($metric['value'], $metric['label'] === '流水笔数' ? 0 : 2) }} +
+
+ @endforeach +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 清除 +
+ @if (($filters['shop_name'] ?? '') !== '' || ($filters['product_type'] ?? '') !== '' || ($filters['from'] ?? '') !== '' || ($filters['to'] ?? '') !== '') +
共匹配 {{ $bills->total() }} 条
+ @endif +
+
+ +
+ + + + @forelse ($bills as $bill) + + + + + + + + @empty + + @endforelse + +
店铺名称交易时间产品类型入账金额(元)出帐金额(元)
{{ $bill['shop_name'] ?? '' }}{{ $bill['trade_day'] ?? '' }}{{ $bill['product_type'] ?? '' }}{{ number_format((float) ($bill['income_amount'] ?? 0), 2) }}{{ number_format((float) ($bill['expense_amount'] ?? 0), 2) }}
当前筛选条件没有账单数据,请调整店铺、产品类型或交易时间。
+
+ + @unless ($bills->isEmpty()) +
+ @endunless +
diff --git a/tests/Unit/EcommerceFinance/AlibabaBillRepositoryTest.php b/tests/Unit/EcommerceFinance/AlibabaBillRepositoryTest.php new file mode 100644 index 00000000..aaf3bec5 --- /dev/null +++ b/tests/Unit/EcommerceFinance/AlibabaBillRepositoryTest.php @@ -0,0 +1,76 @@ +calls[] = ['sql' => $sql, 'bindings' => $bindings]; + + return 1; + } + + public function select(string $sql, array $bindings = []): array + { + $this->calls[] = ['sql' => $sql, 'bindings' => $bindings]; + + return [[ + 'id' => 9, + 'shopName' => '测试店铺', + 'tradeDay' => '2026-06-30 12:00:00', + 'tradeTypeName' => '全站投放', + 'tradeAMTIn' => 12.345, + 'tradeAMTOut' => 67.891, + ]]; + } + + public function first(string $sql, array $bindings = []): ?array + { + $this->calls[] = ['sql' => $sql, 'bindings' => $bindings]; + + return ['income_total' => 12.345, 'expense_total' => 67.891, 'cnt' => 1]; + } + }; + $factory = new class($query) extends UpstreamReadOnlyQueryFactory { + public function __construct(private readonly UpstreamReadOnlyQuery $query) + { + } + + public function forSource(string $source): UpstreamReadOnlyQuery + { + TestCase::assertSame('jiansun', $source); + + return $this->query; + } + }; + + $repository = new AlibabaBillRepository($factory); + $result = $repository->paginate('测试店铺', '全站投放', '2026-06-30', '2026-06-30', 2, 250); + $summary = $repository->summary('测试店铺', '全站投放', '2026-06-30', '2026-06-30'); + + $this->assertSame(1, $result['total']); + $this->assertSame('测试店铺', $result['rows'][0]['shop_name']); + $this->assertSame(12.35, $result['rows'][0]['income_amount']); + $this->assertSame(67.89, $result['rows'][0]['expense_amount']); + $this->assertSame(-55.54, $summary['net']); + $this->assertStringContainsString('tradeTypeName = ?', $query->calls[0]['sql']); + $this->assertStringContainsString('CAST(src.tradeDay AS date)', $query->calls[0]['sql']); + $this->assertSame(101, $query->calls[1]['bindings'][4]); + $this->assertSame(200, $query->calls[1]['bindings'][5]); + } +} diff --git a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php index 6f2756a9..d35c3513 100644 --- a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php +++ b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\EcommerceFinance; use Closure; +use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; @@ -19,6 +20,51 @@ use ReflectionClass; class JianSunSnapshotRefresherTest extends TestCase { + public function test_alibaba_filters_are_preserved_during_background_refresh(): void + { + $alibaba = new class extends AlibabaBillRepository { + public array $arguments = []; + + public function __construct() + { + } + + public function paginate( + ?string $shopName = null, + ?string $productType = null, + ?string $fromDate = null, + ?string $toDate = null, + int $page = 1, + int $pageSize = 25, + ): array { + $this->arguments = func_get_args(); + + return ['rows' => [], 'total' => 0]; + } + }; + + $refresher = $this->refresher(alibaba: $alibaba); + $status = $refresher->refresh([ + 'scope' => 'marketing-account-bills.alibaba.list', + 'parameters' => [ + 'filters' => [ + 'shop_name' => '测试店铺', + 'product_type' => '全站投放', + 'from' => '2026-06-30', + 'to' => '2026-07-01', + ], + 'page' => 3, + 'limit' => 50, + ], + ]); + + $this->assertSame('fresh', $status); + $this->assertSame( + ['测试店铺', '全站投放', '2026-06-30', '2026-07-01', 3, 50], + $alibaba->arguments, + ); + } + public function test_pinduoduo_flow_filter_is_preserved_during_background_refresh(): void { $pinduoduo = new class extends PinduoduoSpreadBillRepository { @@ -173,6 +219,7 @@ class JianSunSnapshotRefresherTest extends TestCase private function refresher( ?InventoryRepository $inventory = null, + ?AlibabaBillRepository $alibaba = null, ?PinduoduoSpreadBillRepository $pinduoduo = null, ?ProductPriceRepository $productPrices = null, ?RawBoardPayablesRepository $rawBoardPayables = null, @@ -197,6 +244,7 @@ class JianSunSnapshotRefresherTest extends TestCase $this->placeholder(EdgeBandingRepository::class), $this->placeholder(GoodsReconciliationRepository::class), $inventory ?? $this->placeholder(InventoryRepository::class), + $alibaba ?? $this->placeholder(AlibabaBillRepository::class), $pinduoduo ?? $this->placeholder(PinduoduoSpreadBillRepository::class), $productPrices ?? $this->placeholder(ProductPriceRepository::class), $rawBoardPayables ?? $this->placeholder(RawBoardPayablesRepository::class),