From 1257f5a8354bdd29d3a2be5d04dbb690de7cf29f Mon Sep 17 00:00:00 2001 From: chenxi Date: Fri, 11 Sep 2026 12:24:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=85=A5=E9=98=BF=E9=87=8C?= =?UTF-8?q?=E5=B7=B4=E5=B7=B4=E8=B4=A6=E6=88=B7=E4=BD=99=E9=A2=9D=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportAlibabaAccountBalances.php | 46 ++++ app/Http/Controllers/Business/Pages.php | 38 ++-- ...eate_alibaba_account_balance_snapshots.php | 49 +++++ .../AlibabaAccountBalanceRepository.php | 104 +++++++++ .../AlibabaAccountBalanceWorkbookImporter.php | 207 ++++++++++++++++++ .../partials/embedded-table.blade.php | 2 +- .../MarketingAccountBillsTabsTest.php | 79 +++++++ .../AlibabaAccountBalanceRepositoryTest.php | 120 ++++++++++ 8 files changed, 630 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/ImportAlibabaAccountBalances.php create mode 100644 database/migrations/2026_09_11_123000_create_alibaba_account_balance_snapshots.php create mode 100644 modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php create mode 100644 modules/EcommerceFinance/Services/AlibabaAccountBalanceWorkbookImporter.php create mode 100644 tests/Unit/EcommerceFinance/AlibabaAccountBalanceRepositoryTest.php diff --git a/app/Console/Commands/ImportAlibabaAccountBalances.php b/app/Console/Commands/ImportAlibabaAccountBalances.php new file mode 100644 index 00000000..2f5bb1ff --- /dev/null +++ b/app/Console/Commands/ImportAlibabaAccountBalances.php @@ -0,0 +1,46 @@ +argument('path'); + + try { + $sourceName = trim((string) $this->option('source-name')); + $result = $importer->import( + $path, + $sourceName !== '' ? $sourceName : basename($path), + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $action = $this->option('dry-run') ? '校验完成' : '导入完成'; + $this->info(sprintf( + '%s:共 %d 条,新增 %d 条,更新 %d 条。', + $action, + $result['total'], + $result['created'], + $result['updated'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a1f35b66..93303aeb 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -25,6 +25,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Carbon; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; +use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; @@ -1310,7 +1311,7 @@ class Pages extends Controller 'pending' => false, ]; - if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) { + if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true)) { return $content; } @@ -1325,11 +1326,14 @@ class Pages extends Controller 'month' => $month !== '' ? $month : null, ]; $isTaobao = $tab === 'taobao-alipay'; - $repository = $isTaobao - ? app(TmallTaobaoSpreadBillRepository::class) - : app(PinduoduoSpreadBillRepository::class); + $isAlibaba = $tab === 'taobao-wechat'; + $repository = match ($tab) { + 'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class), + 'taobao-wechat' => app(AlibabaAccountBalanceRepository::class), + default => app(PinduoduoSpreadBillRepository::class), + }; $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; - $cache = $this->upstreamCaches->forSource('jiansun'); + $cache = $isAlibaba ? null : $this->upstreamCaches->forSource('jiansun'); $usingMirror = $repository->usesBusinessMirror(); try { @@ -1339,7 +1343,7 @@ class Pages extends Controller $page, $pageSize, ); - $result = $usingMirror + $result = ($usingMirror || $isAlibaba) ? $resolver() : $cache->remember( $scope, @@ -1354,13 +1358,14 @@ class Pages extends Controller $result = ['rows' => [], 'total' => 0]; } catch (Throwable $exception) { report($exception); - $content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。'; + $platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : '拼多多'); + $content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。'; $result = ['rows' => [], 'total' => 0]; } try { $resolver = fn () => $repository->storeOptions(); - $storeOptions = $usingMirror + $storeOptions = ($usingMirror || $isAlibaba) ? $resolver() : $cache->remember( $isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options', @@ -1406,7 +1411,7 @@ class Pages extends Controller } abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404); - abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404); + abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true), 404); $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); $month = trim((string) $request->query('month', '')); @@ -1416,9 +1421,12 @@ class Pages extends Controller 'month' => $month !== '' ? $month : null, ]; $isTaobao = $tab === 'taobao-alipay'; - $repository = $isTaobao - ? app(TmallTaobaoSpreadBillRepository::class) - : app(PinduoduoSpreadBillRepository::class); + $isAlibaba = $tab === 'taobao-wechat'; + $repository = match ($tab) { + 'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class), + 'taobao-wechat' => app(AlibabaAccountBalanceRepository::class), + default => app(PinduoduoSpreadBillRepository::class), + }; $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; $redirectParameters = array_filter([ 'section' => $section, @@ -1430,7 +1438,7 @@ class Pages extends Controller try { $resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000); - $result = $repository->usesBusinessMirror() + $result = ($repository->usesBusinessMirror() || $isAlibaba) ? $resolver() : $this->upstreamCaches->forSource('jiansun')->remember( $scope, @@ -1448,6 +1456,8 @@ class Pages extends Controller } $rows = $result['rows'] ?? []; + $filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : 'pinduoduo'); + return response()->streamDownload(function () use ($rows): void { $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); @@ -1465,7 +1475,7 @@ class Pages extends Controller } fclose($csv); - }, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [ + }, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } diff --git a/database/migrations/2026_09_11_123000_create_alibaba_account_balance_snapshots.php b/database/migrations/2026_09_11_123000_create_alibaba_account_balance_snapshots.php new file mode 100644 index 00000000..bb6a887b --- /dev/null +++ b/database/migrations/2026_09_11_123000_create_alibaba_account_balance_snapshots.php @@ -0,0 +1,49 @@ +connection()); + + if ($schema->hasTable('alibaba_account_balance_snapshots')) { + return; + } + + $schema->create('alibaba_account_balance_snapshots', function (Blueprint $table): void { + $table->id(); + $table->date('balance_date'); + $table->string('store_name', 255); + $table->decimal('opening_balance', 20, 6)->nullable(); + $table->decimal('income_total', 20, 6)->nullable(); + $table->decimal('expense_total', 20, 6)->nullable(); + $table->decimal('closing_balance', 20, 6)->nullable(); + $table->decimal('system_closing_balance', 20, 6)->nullable(); + $table->decimal('balance_difference', 20, 6)->nullable(); + $table->text('difference_reason')->nullable(); + $table->string('verification_status', 64)->nullable(); + $table->text('notes')->nullable(); + $table->string('source_file_name', 255); + $table->char('source_sha256', 64); + $table->dateTime('imported_at', 6); + $table->timestamps(6); + + $table->unique(['balance_date', 'store_name'], 'alibaba_balance_date_store_unique'); + $table->index(['store_name', 'balance_date'], 'alibaba_balance_store_date_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('alibaba_account_balance_snapshots'); + } + + private function connection(): string + { + return (string) config('upstream_mirrors.connection', 'business_mirror'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php b/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php new file mode 100644 index 00000000..e17ea696 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php @@ -0,0 +1,104 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror') + ); + } + + public function usesBusinessMirror(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } + + /** @return string[] */ + public function storeOptions(): array + { + if (! $this->usesBusinessMirror()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->whereNotNull('store_name') + ->where('store_name', '<>', '') + ->distinct() + ->orderBy('store_name') + ->pluck('store_name') + ->map(static fn (mixed $value): string => (string) $value) + ->all(); + } + + /** @return array{rows:array>,total:int} */ + public function paginateMonthlyBalances( + ?string $storeName = null, + ?string $month = null, + int $page = 1, + int $pageSize = 25, + ): array { + if (! $this->usesBusinessMirror()) { + return ['rows' => [], 'total' => 0]; + } + + $page = max($page, 1); + $pageSize = min(max($pageSize, 1), 5000); + $query = $this->connection->table(self::TABLE); + $this->applyFilters($query, $storeName, $month); + $total = (clone $query)->count(); + + if ($total === 0) { + return ['rows' => [], 'total' => 0]; + } + + $rows = $query + ->orderByDesc('balance_date') + ->orderBy('store_name') + ->forPage($page, $pageSize) + ->get([ + 'balance_date', 'store_name', 'opening_balance', 'income_total', + 'expense_total', 'closing_balance', 'system_closing_balance', + 'balance_difference', 'difference_reason', 'verification_status', 'notes', + ]) + ->map(static fn (object $row): array => [ + 'date' => (string) $row->balance_date, + 'store_name' => (string) $row->store_name, + 'opening_balance' => $row->opening_balance === null ? null : (float) $row->opening_balance, + 'income_total' => $row->income_total === null ? null : (float) $row->income_total, + 'expense_total' => $row->expense_total === null ? null : (float) $row->expense_total, + 'closing_balance' => $row->closing_balance === null ? null : (float) $row->closing_balance, + 'system_closing_balance' => $row->system_closing_balance === null ? null : (float) $row->system_closing_balance, + 'balance_difference' => $row->balance_difference === null ? null : (float) $row->balance_difference, + 'difference_reason' => (string) ($row->difference_reason ?? ''), + 'verification_status' => (string) ($row->verification_status ?? ''), + 'notes' => (string) ($row->notes ?? ''), + ]) + ->all(); + + return ['rows' => $rows, 'total' => $total]; + } + + private function applyFilters(Builder $query, ?string $storeName, ?string $month): void + { + if ($storeName !== null && $storeName !== '') { + $query->where('store_name', $storeName); + } + + if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $query->where('balance_date', '>=', $month . '-01') + ->where('balance_date', '<', date('Y-m-d', strtotime($month . '-01 +1 month'))); + } + } +} diff --git a/modules/EcommerceFinance/Services/AlibabaAccountBalanceWorkbookImporter.php b/modules/EcommerceFinance/Services/AlibabaAccountBalanceWorkbookImporter.php new file mode 100644 index 00000000..b1a5c7ec --- /dev/null +++ b/modules/EcommerceFinance/Services/AlibabaAccountBalanceWorkbookImporter.php @@ -0,0 +1,207 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror') + ); + } + + /** @return array{created:int,updated:int,skipped:int,total:int} */ + public function import(string $path, ?string $sourceFileName = null, bool $dryRun = false): array + { + if (! is_file($path) || ! is_readable($path)) { + throw new InvalidArgumentException('余额文件不存在或不可读取。'); + } + + $rows = $this->readRows($path); + if ($rows === []) { + throw new InvalidArgumentException('余额文件中没有可导入的数据行。'); + } + + if (! Schema::connection($this->connection->getName())->hasTable(AlibabaAccountBalanceRepository::TABLE)) { + throw new RuntimeException('阿里巴巴余额快照表尚未创建,请先执行数据库迁移。'); + } + + $created = 0; + $updated = 0; + $sourceFileName = mb_substr(basename($sourceFileName ?: $path), 0, 255); + $sourceSha256 = hash_file('sha256', $path); + $now = now(); + + $this->connection->transaction(function () use ( + $rows, $sourceFileName, $sourceSha256, $now, $dryRun, &$created, &$updated + ): void { + foreach ($rows as $row) { + $identity = [ + 'balance_date' => $row['balance_date'], + 'store_name' => $row['store_name'], + ]; + $exists = $this->connection->table(AlibabaAccountBalanceRepository::TABLE) + ->where($identity) + ->exists(); + $exists ? $updated++ : $created++; + + if ($dryRun) { + continue; + } + + $values = $row + [ + 'source_file_name' => $sourceFileName, + 'source_sha256' => $sourceSha256, + 'imported_at' => $now, + 'updated_at' => $now, + ]; + + if ($exists) { + $this->connection->table(AlibabaAccountBalanceRepository::TABLE) + ->where($identity) + ->update($values); + } else { + $this->connection->table(AlibabaAccountBalanceRepository::TABLE) + ->insert($values + ['created_at' => $now]); + } + } + }); + + return ['created' => $created, 'updated' => $updated, 'skipped' => 0, 'total' => count($rows)]; + } + + /** @return array> */ + private function readRows(string $path): array + { + $spreadsheet = IOFactory::load($path); + $worksheet = $spreadsheet->getSheetByName('阿里巴巴账户') ?: $spreadsheet->getActiveSheet(); + $headerRow = $this->findHeaderRow($worksheet); + $rows = []; + $seen = []; + + for ($rowNumber = $headerRow + 1; $rowNumber <= $worksheet->getHighestDataRow(); $rowNumber++) { + $values = []; + foreach (range(1, count(self::HEADERS)) as $column) { + $values[] = $worksheet->getCell([$column, $rowNumber])->getCalculatedValue(); + } + + if (collect($values)->every(static fn (mixed $value): bool => $value === null || trim((string) $value) === '')) { + continue; + } + + $date = $this->date($values[0] ?? null, $rowNumber); + $storeName = trim((string) ($values[1] ?? '')); + if ($storeName === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”不能为空。"); + } + if (mb_strlen($storeName) > 255) { + throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”超过 255 个字符。"); + } + + $key = $date . "\0" . $storeName; + if (isset($seen[$key])) { + throw new InvalidArgumentException("第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和店铺名称重复。"); + } + $seen[$key] = $rowNumber; + + $rows[] = [ + 'balance_date' => $date, + 'store_name' => $storeName, + 'opening_balance' => $this->number($values[2] ?? null, $rowNumber, '期初余额'), + 'income_total' => $this->number($values[3] ?? null, $rowNumber, '本期收入'), + 'expense_total' => $this->number($values[4] ?? null, $rowNumber, '本期支出'), + 'closing_balance' => $this->number($values[5] ?? null, $rowNumber, '期末余额'), + 'system_closing_balance' => $this->number($values[6] ?? null, $rowNumber, '系统期末余额'), + 'balance_difference' => $this->number($values[7] ?? null, $rowNumber, '余额差异'), + 'difference_reason' => $this->text($values[8] ?? null), + 'verification_status' => $this->text($values[9] ?? null, 64, $rowNumber, '核对状态'), + 'notes' => $this->text($values[10] ?? null), + ]; + } + + $spreadsheet->disconnectWorksheets(); + + return $rows; + } + + private function findHeaderRow(Worksheet $worksheet): int + { + $maximum = min($worksheet->getHighestDataRow(), 20); + for ($row = 1; $row <= $maximum; $row++) { + $headers = []; + foreach (range(1, count(self::HEADERS)) as $column) { + $headers[] = trim((string) $worksheet->getCell([$column, $row])->getValue()); + } + + if ($headers === self::HEADERS) { + return $row; + } + } + + throw new InvalidArgumentException('未找到阿里巴巴账户余额的 11 列标准表头。'); + } + + private function date(mixed $value, int $rowNumber): string + { + try { + $date = is_numeric($value) + ? CarbonImmutable::instance(ExcelDate::excelToDateTimeObject((float) $value)) + : CarbonImmutable::parse((string) $value); + } catch (\Throwable) { + throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。"); + } + + if (! $date->isLastOfMonth()) { + throw new InvalidArgumentException("第 {$rowNumber} 行“日期”必须是月末日期。"); + } + + return $date->format('Y-m-d'); + } + + private function number(mixed $value, int $rowNumber, string $label): ?float + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + $normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); + if (! is_numeric($normalized)) { + throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效金额。"); + } + + return round((float) $normalized, 6); + } + + private function text(mixed $value, ?int $limit = null, ?int $rowNumber = null, ?string $label = null): ?string + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + $text = trim((string) $value); + if ($limit !== null && mb_strlen($text) > $limit) { + throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”超过 {$limit} 个字符。"); + } + + return $text; + } +} diff --git a/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php index 007e67d5..67b18f5d 100644 --- a/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php +++ b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php @@ -4,7 +4,7 @@ '系统期末余额', '余额差异', '差异原因', '核对状态', '备注', ]; $balances = $balances ?? collect(); - $supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'pinduoduo'], true); + $supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true); @endphp