From acb58cb100f013586e21a8955396d6079b475a1f Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 22 Sep 2026 14:27:21 +0800 Subject: [PATCH] feat: import complete pinduoduo settlement bills --- .../ImportPinduoduoSettlementBills.php | 72 ++ ...reate_pinduoduo_settlement_bill_tables.php | 110 +++ .../Controllers/PinduoduoAccountBills.php | 178 +++++ .../PinduoduoSettlementBillRepository.php | 304 ++++++++ .../PinduoduoSettlementBillImporter.php | 709 ++++++++++++++++++ routes/admin.php | 7 + .../Business/PinduoduoReceivingBillsTest.php | 32 +- .../PinduoduoSettlementBillImporterTest.php | 348 +++++++++ ...uoSettlementAccountBillsRepositoryTest.php | 152 ++++ 9 files changed, 1896 insertions(+), 16 deletions(-) create mode 100644 app/Console/Commands/ImportPinduoduoSettlementBills.php create mode 100644 database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php create mode 100644 modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php create mode 100644 modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php create mode 100644 modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php create mode 100644 tests/Feature/Console/PinduoduoSettlementBillImporterTest.php create mode 100644 tests/Unit/EcommerceFinance/PinduoduoSettlementAccountBillsRepositoryTest.php diff --git a/app/Console/Commands/ImportPinduoduoSettlementBills.php b/app/Console/Commands/ImportPinduoduoSettlementBills.php new file mode 100644 index 00000000..fd32f2cf --- /dev/null +++ b/app/Console/Commands/ImportPinduoduoSettlementBills.php @@ -0,0 +1,72 @@ +option($option)) === '') { + $this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。"); + + return self::FAILURE; + } + } + + try { + $result = $importer->import( + (string) $this->argument('path'), + (string) $this->option('from'), + (string) $this->option('to'), + (int) $this->option('expected-count'), + (string) $this->option('expected-income'), + (string) $this->option('expected-expense'), + (int) $this->option('batch'), + ); + } catch (RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } catch (Throwable $exception) { + report($exception); + $this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。'); + + return self::FAILURE; + } + + $this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[ + $result['status'], + $result['row_count'], + $result['excluded_account_rows'], + $result['store_count'], + $result['bill_item_count'], + $result['income_total'], + $result['expense_total'], + $result['net_total'], + $result['period_start'] . ' 至 ' . $result['period_end'], + ]]); + $this->line('账户类型:' . collect($result['account_type_counts']) + ->map(fn (int $count, string $account): string => "{$account} {$count}") + ->implode(';')); + $this->line('SHA-256: ' . $result['source_sha256']); + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php new file mode 100644 index 00000000..3d1f0fb9 --- /dev/null +++ b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php @@ -0,0 +1,110 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('pdd_settlement_bill_imports')) { + $schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void { + $table->uuid('generation')->primary(); + $table->string('source_filename'); + $table->char('source_sha256', 64)->unique(); + $table->date('period_start'); + $table->date('period_end'); + $table->string('status', 32)->default('running'); + $table->unsignedBigInteger('expected_row_count'); + $table->decimal('expected_income_total', 20, 2); + $table->decimal('expected_expense_total', 20, 2); + $table->unsignedBigInteger('row_count')->default(0); + $table->unsignedBigInteger('excluded_account_rows')->default(0); + $table->unsignedInteger('store_count')->default(0); + $table->unsignedInteger('bill_item_count')->default(0); + $table->json('account_type_counts')->nullable(); + $table->decimal('income_total', 20, 2)->default(0); + $table->decimal('expense_total', 20, 2)->default(0); + $table->decimal('net_total', 20, 2)->default(0); + $table->string('last_error_code', 120)->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'period_start', 'period_end'], 'pdd_bill_import_period_idx'); + }); + } + + if (! $schema->hasTable('pdd_settlement_bill_staging')) { + $schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->uuid('generation'); + $this->billColumns($table); + $table->unique(['generation', 'record_code'], 'pdd_bill_stage_generation_record_uq'); + $table->index(['generation', 'occurred_at'], 'pdd_bill_stage_generation_time_idx'); + }); + } + + if (! $schema->hasTable('pdd_settlement_bill_rows')) { + $schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $this->billColumns($table, false); + $table->uuid('source_generation'); + $table->char('source_sha256', 64); + $table->timestamp('imported_at'); + + $table->index(['occurred_at', 'record_code'], 'pdd_bill_time_record_idx'); + $table->index(['store_name', 'occurred_at'], 'pdd_bill_store_time_idx'); + $table->index(['bill_item', 'occurred_at'], 'pdd_bill_item_time_idx'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('pdd_settlement_bill_rows'); + $schema->dropIfExists('pdd_settlement_bill_staging'); + $schema->dropIfExists('pdd_settlement_bill_imports'); + } + + private function billColumns(Blueprint $table, bool $includeRecordCode = true): void + { + if ($includeRecordCode) { + $table->string('record_code', 191); + } + $table->string('store_name')->nullable(); + $table->string('custom_remark')->nullable(); + $table->string('bill_item')->nullable(); + $table->string('merchant_order_no', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->string('business_type')->nullable(); + $table->string('business_description', 1000)->nullable(); + $table->string('account_name')->nullable(); + $table->dateTime('occurred_at'); + $table->decimal('income_amount', 20, 2)->default(0); + $table->decimal('expense_amount', 20, 2)->default(0); + $table->decimal('signed_amount', 20, 2)->default(0); + $table->string('remark', 1000)->nullable(); + $table->dateTime('source_updated_at')->nullable(); + $table->string('source_modified_by')->nullable(); + $table->unsignedBigInteger('source_row_number'); + } +}; diff --git a/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php new file mode 100644 index 00000000..c990561b --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php @@ -0,0 +1,178 @@ +authorizeStoreReceivingBills(); + + if ($request->boolean('export')) { + return $this->exportResponse($request, $repository); + } + + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + 'page' => ['nullable', 'integer', 'min:1'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + + try { + $data = $repository->paginateAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'desc'), + (int) ($validated['page'] ?? 1), + (int) ($validated['limit'] ?? 10), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。', + 'data' => [], + 'total' => 0, + 'current_page' => 1, + 'last_page' => 1, + 'per_page' => (int) ($validated['limit'] ?? 10), + 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'columns' => AccountBillColumns::for('pinduoduo'), + ], 503); + } + + $data['columns'] = AccountBillColumns::for('pinduoduo'); + + return response()->json($data); + } + + public function filterOptions(PinduoduoSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + try { + return response()->json($repository->accountBillFilterOptions()); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json([ + 'message' => '拼多多完整货款账单尚未完成首次导入。', + 'shops' => [], + 'bill_items' => [], + ], 503); + } + } + + public function export(Request $request, PinduoduoSettlementBillRepository $repository) + { + $this->authorizeStoreReceivingBills(); + + return $this->exportResponse($request, $repository); + } + + private function exportResponse(Request $request, PinduoduoSettlementBillRepository $repository) + { + $validated = $request->validate($this->filterRules() + [ + 'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'], + 'dir' => ['nullable', 'in:asc,desc'], + ]); + $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $definition = AccountBillColumns::for('pinduoduo'); + $available = collect($definition['columns'] ?? [])->keyBy('field'); + $requested = $request->query('columns', []); + if (is_string($requested)) { + $requested = array_filter(array_map('trim', explode(',', $requested))); + } + if (! is_array($requested) || count($requested) > 40) { + $requested = []; + } + $requested = collect($requested) + ->filter(static fn (mixed $value): bool => is_scalar($value)) + ->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128)) + ->filter() + ->unique(); + $columns = $requested->isEmpty() + ? $available->values() + : $requested->map(fn (string $field) => $available->get($field))->filter()->values(); + abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); + + try { + $rows = $repository->exportAccountBills( + $filters, + (string) ($validated['sort'] ?? 'occurred_at'), + (string) ($validated['dir'] ?? 'asc'), + ); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { + throw $exception; + } + + return response()->json(['message' => '拼多多完整货款账单尚未完成首次导入。'], 503); + } + + return response()->streamDownload(function () use ($rows, $columns): void { + $csv = fopen('php://output', 'w'); + fputs($csv, "\xEF\xBB\xBF"); + fputcsv($csv, $columns->pluck('label')->all()); + foreach ($rows as $row) { + fputcsv($csv, $columns->map( + fn (array $column): string => $this->exportValue($row, $column) + )->all()); + } + fclose($csv); + }, 'pinduoduo_account_bills.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array> */ + private function filterRules(): array + { + return [ + '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'], + ]; + } + + /** @param array $row */ + private function exportValue(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 = (string) ($value ?? ''); + if (preg_match('/^\d{12,}$/', $text) === 1) { + return '="' . $text . '"'; + } + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } + + private function authorizeStoreReceivingBills(): void + { + $section = collect(SidebarPermissions::navigationFor(user())) + ->firstWhere('slug', 'billing-transactions'); + $page = collect($section['pages'] ?? []) + ->firstWhere('slug', 'store-receiving-bills'); + + abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。'); + } +} diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php new file mode 100644 index 00000000..b3922e20 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php @@ -0,0 +1,304 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + public function ready(): bool + { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) { + return false; + } + + return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists(); + } + + /** @return string[] */ + public function storeOptions(): array + { + return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(); + } + + /** @return array{shops:list,bill_items:list} */ + public function accountBillFilterOptions(): array + { + $shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name') + ->map(static fn (mixed $value): string => (string) $value)->all(); + $billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '') + ->distinct()->orderBy('bill_item')->pluck('bill_item') + ->map(static fn (mixed $value): string => (string) $value)->all(); + + return ['shops' => $shops, 'bill_items' => $billItems]; + } + + /** @return array{rows:list>,total:int} */ + public function paginate( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + int $page, + int $pageSize, + ): array { + $page = max(1, $page); + $pageSize = min(max(1, $pageSize), 500); + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $total = (clone $query)->count(); + + $rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code') + ->forPage($page, $pageSize)->get()->map(function (object $row): array { + $signed = (float) $row->signed_amount; + $income = (float) $row->income_amount; + $expense = (float) $row->expense_amount; + $flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0); + + return [ + 'id' => $row->record_code, + 'shopName' => (string) ($row->store_name ?? ''), + 'amount' => abs($signed), + 'balance' => null, + 'summary' => (string) ($row->bill_item ?? ''), + 'flowType' => $flowType, + 'toAdvertiserFundType' => null, + 'createTime' => (string) $row->occurred_at, + 'source_primary_key' => (string) ($row->merchant_order_no ?? ''), + 'source_record_code' => (string) $row->record_code, + 'source_channel' => 'PinduoduoSettlementBill', + ]; + })->all(); + + return ['rows' => $rows, 'total' => $total]; + } + + /** @return array{income_total:float,expense_total:float,cnt:int} */ + public function summarize( + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): array { + $query = $this->query(); + $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); + $row = $query->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt' + )->first(); + + return [ + 'income_total' => (float) ($row->income_total ?? 0), + 'expense_total' => (float) ($row->expense_total ?? 0), + 'cnt' => (int) ($row->cnt ?? 0), + ]; + } + + /** @return array */ + public function paginateAccountBills( + array $filters, + string $sort, + string $direction, + int $page, + int $perPage, + ): array { + $page = max(1, $page); + $perPage = min(max(1, $perPage), 100); + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + + $summary = (clone $query)->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' + )->first(); + $total = (int) ($summary->row_count ?? 0); + $income = (float) ($summary->income_total ?? 0); + $expense = (float) ($summary->expense_total ?? 0); + + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'asc' ? 'asc' : 'desc'; + + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + $rows = $query->forPage($page, $perPage)->get() + ->map(fn (object $row): array => $this->mapAccountBillRow($row))->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + 'per_page' => $perPage, + 'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1, + 'to' => $total === 0 ? null : min($page * $perPage, $total), + 'summary' => [ + 'income' => $income, + 'expense' => $expense, + 'balance' => round($income - $expense, 2), + 'count' => $total, + ], + ]; + } + + /** @return iterable> */ + public function exportAccountBills(array $filters, string $sort, string $direction): iterable + { + $query = $this->query(); + $this->applyAccountBillFilters($query, $filters); + $sortColumn = $this->accountBillSortColumn($sort); + $direction = $direction === 'desc' ? 'desc' : 'asc'; + $query->orderBy($sortColumn, $direction); + if ($sortColumn !== 'record_code') { + $query->orderBy('record_code', $direction); + } + + return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row)); + } + + private function query(): Builder + { + if (! $this->ready()) { + throw new RuntimeException('pinduoduo_authoritative_bills_not_ready'); + } + + return $this->connection->table(self::ROW_TABLE); + } + + private function applyFilters( + Builder $query, + ?string $storeName, + ?string $fromDate, + ?string $toDate, + ?string $flowType, + ): void { + if ($storeName !== null && $storeName !== '') { + $query->where('store_name', $storeName); + } + $from = $this->date($fromDate); + if ($from !== null) { + $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + $to = $this->date($toDate); + if ($to !== null) { + $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + if ($flowType === 'income') { + $query->where('income_amount', '>', 0); + } elseif ($flowType === 'expense') { + $query->where('expense_amount', '>', 0); + } +} + /** @param array $filters */ + private function applyAccountBillFilters(Builder $query, array $filters): void + { + $storeName = trim((string) ($filters['store_name'] ?? '')); + if ($storeName !== '') { + $query->where('store_name', $storeName); + } + + $billItem = trim((string) ($filters['bill_item'] ?? '')); + if ($billItem !== '') { + $query->where('bill_item', $billItem); + } + + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); + if ($from !== null) { + $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); + } + + $to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null); + if ($to !== null) { + $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function (Builder $nested) use ($keyword): void { + $like = '%' . $keyword . '%'; + $nested->where('record_code', 'like', $like) + ->orWhere('merchant_order_no', 'like', $like) + ->orWhere('online_order_no', 'like', $like) + ->orWhere('remark', 'like', $like) + ->orWhere('custom_remark', 'like', $like) + ->orWhere('business_description', 'like', $like) + ->orWhere('bill_item', 'like', $like); + }); + } + } + + private function accountBillSortColumn(string $sort): string + { + return [ + 'occurred_at' => 'occurred_at', + 'income' => 'income_amount', + 'expense' => 'expense_amount', + 'record_code' => 'record_code', + ][$sort] ?? 'occurred_at'; + } + + /** @return array */ + private function mapAccountBillRow(object $row): array + { + $signedAmount = (float) $row->signed_amount; + + return [ + 'id' => (string) $row->record_code, + 'record_code' => (string) $row->record_code, + 'store_name' => (string) ($row->store_name ?? ''), + 'finance_remark' => (string) ($row->business_description ?? ''), + 'order_no' => (string) ($row->merchant_order_no ?? ''), + 'amount' => abs($signedAmount), + 'custom_remark' => (string) ($row->custom_remark ?? ''), + 'bill_item' => (string) ($row->bill_item ?? ''), + 'occurred_at' => (string) $row->occurred_at, + 'income' => (float) $row->income_amount, + 'expense' => (float) $row->expense_amount, + 'balance' => $signedAmount, + 'remark' => (string) ($row->remark ?? ''), + 'updated_at' => $row->source_updated_at !== null + ? (string) $row->source_updated_at + : null, + 'modifier' => (string) ($row->source_modified_by ?? ''), + 'payload' => [ + 'merchant_order_no' => (string) ($row->merchant_order_no ?? ''), + 'online_order_no' => (string) ($row->online_order_no ?? ''), + 'finance_type' => (string) ($row->business_type ?? ''), + 'business_description' => (string) ($row->business_description ?? ''), + 'type' => (string) ($row->account_name ?? ''), + ], + ]; + } + + private function date(?string $value): ?DateTimeImmutable + { + if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php new file mode 100644 index 00000000..0219e0f7 --- /dev/null +++ b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php @@ -0,0 +1,709 @@ + ['记录编码'], + 'store_name' => ['店铺', '原始店铺'], + 'custom_remark' => ['自定义备注'], + 'bill_item' => ['账单项目'], + 'merchant_order_no' => ['商户订单号'], + 'online_order_no' => ['线上单号'], + 'business_type' => ['财务类型', '业务类型'], + 'business_description' => ['业务描述'], + 'account_name' => ['类型', '账户'], + 'occurred_at' => ['发生时间'], + 'income_amount' => ['收入金额'], + 'expense_amount' => ['支出金额'], + 'signed_amount' => ['金额'], + 'remark' => ['备注'], + 'source_updated_at' => ['修改时间'], + 'source_modified_by' => ['修改人'], + ]; + + private readonly ConnectionInterface $connection; + + public function __construct(DatabaseManager $databases) + { + $this->connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{status:string,row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string} + */ + public function import( + string $path, + string $fromDate, + string $toDate, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + int $batchSize = 1000, + ): array { + $path = $this->validatedPath($path); + [$from, $to] = $this->period($fromDate, $toDate); + $this->assertSchemaReady(); + if ($expectedCount <= 0) { + throw new RuntimeException('权威笔数控制数必须大于零。'); + } + $expectedIncome = $this->controlAmount($expectedIncome, '收入'); + $expectedExpense = $this->controlAmount($expectedExpense, '支出'); + + $batchSize = min(max($batchSize, 100), 5000); + $hash = hash_file('sha256', $path); + if (! is_string($hash)) { + throw new RuntimeException('无法计算拼多多完整账单文件摘要。'); + } + + $existing = $this->connection->table(self::IMPORT_TABLE) + ->where('source_sha256', $hash) + ->first(); + if ($existing !== null && $existing->status === 'succeeded') { + $this->assertSameImportRequest( + $existing, + $from, + $to, + $expectedCount, + $expectedIncome, + $expectedExpense, + ); + if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) { + return $this->result((array) $existing, 'unchanged'); + } + } + + $generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid(); + $now = now(); + $import = [ + 'generation' => $generation, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'status' => 'running', + 'expected_row_count' => $expectedCount, + 'expected_income_total' => $expectedIncome, + 'expected_expense_total' => $expectedExpense, + 'row_count' => 0, + 'excluded_account_rows' => 0, + 'store_count' => 0, + 'bill_item_count' => 0, + 'account_type_counts' => null, + 'income_total' => '0.00', + 'expense_total' => '0.00', + 'net_total' => '0.00', + 'completed_at' => null, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + if ($existing === null) { + $this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]); + } else { + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import); + } + + $stats = null; + try { + $stats = $this->stage($path, $generation, $from, $to, $batchSize); + $this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense); + $this->activate($generation, $hash, $from, $to, $stats, $batchSize); + + return $this->result([ + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => $stats['account_type_counts'], + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'period_start' => $from->format('Y-m-d'), + 'period_end' => $to->format('Y-m-d'), + 'source_sha256' => $hash, + ], 'succeeded'); + } catch (Throwable $exception) { + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + $failure = [ + 'status' => 'failed', + 'last_error_code' => mb_substr(class_basename($exception), 0, 120), + 'updated_at' => now(), + ]; + if (is_array($stats)) { + $failure += [ + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + ]; + } + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure); + + throw $exception; + } + } + + private function validatedPath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new RuntimeException('拼多多完整账单文件不存在或不可读。'); + } + if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。'); + } + + return $realPath; + } + + /** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */ + private function period(string $fromDate, string $toDate): array + { + $from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate); + $to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate); + if ($from === false || $to === false + || $from->format('Y-m-d') !== $fromDate + || $to->format('Y-m-d') !== $toDate + || $from > $to) { + throw new RuntimeException('拼多多账单导入账期无效。'); + } + + return [$from, $to]; + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('拼多多完整账单表尚未完成迁移:' . $table); + } + } + } + + /** + * @return array{row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array,income_total:string,expense_total:string,net_total:string} + */ + private function stage( + string $path, + string $generation, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $batchSize, + ): array { + $archive = new ZipArchive(); + if ($archive->open($path) !== true) { + throw new RuntimeException('无法打开拼多多完整账单 XLSX 文件。'); + } + + try { + $worksheet = $this->firstWorksheet($archive); + $sharedStrings = $this->sharedStrings($path, $archive); + } finally { + $archive->close(); + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取拼多多完整账单工作表。'); + } + + $headerMap = null; + $batch = []; + $rowCount = 0; + $excluded = 0; + $incomeTotal = '0.00'; + $expenseTotal = '0.00'; + $stores = []; + $billItems = []; + $accountTypeCounts = []; + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { + continue; + } + + $rowNumber = (int) ($reader->getAttribute('r') ?: 0); + $cells = $this->rowCells($reader->readOuterXml(), $sharedStrings); + if ($headerMap === null) { + $headerMap = $this->headerMap($cells); + continue; + } + + $values = []; + foreach ($headerMap as $header => $column) { + $values[$header] = trim((string) ($cells[$column] ?? '')); + } + if (implode('', $values) === '') { + continue; + } + $recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码'); + $accountName = $this->required($values, 'account_name', $rowNumber, '账户类型'); + $storeName = $this->nullable($values['store_name']); + $billItem = $this->nullable($values['bill_item']); + $occurredAt = $this->dateTime($values['occurred_at'], $rowNumber, '发生时间'); + if ($occurredAt < $from->format('Y-m-d') . ' 00:00:00' + || $occurredAt >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') { + throw new RuntimeException("第 {$rowNumber} 行发生时间超出指定账期。"); + } + + $income = $this->money($values['income_amount'], $rowNumber, '收入金额', false); + $expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true); + $signed = $this->money($values['signed_amount'], $rowNumber, '金额', false, true); + $calculated = bcsub($income, $expense, 2); + if (bccomp($calculated, $signed, 2) !== 0) { + throw new RuntimeException("第 {$rowNumber} 行收入、支出与金额不一致。"); + } + + $batch[] = [ + 'generation' => $generation, + 'record_code' => mb_substr($recordCode, 0, 191), + 'store_name' => $storeName, + 'custom_remark' => $this->nullable($values['custom_remark']), + 'bill_item' => $billItem, + 'merchant_order_no' => $this->nullable($values['merchant_order_no'], 191), + 'online_order_no' => $this->nullable($values['online_order_no'], 191), + 'business_type' => $this->nullable($values['business_type']), + 'business_description' => $this->nullable($values['business_description'], 1000), + // Keep the account type from the authoritative export. The + // complete Jushengsuan file contains both wallet and marketing + // account rows, and the finance page must retain both. + 'account_name' => mb_substr($accountName, 0, 255), + 'occurred_at' => $occurredAt, + 'income_amount' => $income, + 'expense_amount' => $expense, + 'signed_amount' => $signed, + 'remark' => $this->nullable($values['remark'], 1000), + 'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'), + 'source_modified_by' => $this->nullable($values['source_modified_by']), + 'source_row_number' => $rowNumber, + ]; + $rowCount++; + if ($storeName !== null) { + $stores[$storeName] = true; + } + if ($billItem !== null) { + $billItems[$billItem] = true; + } + $accountTypeCounts[$accountName] = ($accountTypeCounts[$accountName] ?? 0) + 1; + $incomeTotal = bcadd($incomeTotal, $income, 2); + $expenseTotal = bcadd($expenseTotal, $expense, 2); + + if (count($batch) >= $batchSize) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($headerMap === null) { + throw new RuntimeException('未找到聚胜算拼多多账单标准表头。'); + } + if ($batch !== []) { + $this->connection->table(self::STAGING_TABLE)->insert($batch); + } + if ($rowCount === 0) { + throw new RuntimeException('文件中没有拼多多货款账户数据。'); + } + + return [ + 'row_count' => $rowCount, + 'excluded_account_rows' => $excluded, + 'store_count' => count($stores), + 'bill_item_count' => count($billItems), + 'account_type_counts' => $accountTypeCounts, + 'income_total' => $incomeTotal, + 'expense_total' => $expenseTotal, + 'net_total' => bcsub($incomeTotal, $expenseTotal, 2), + ]; + } + + private function assertControls(array $stats, int $count, string $income, string $expense): void + { + if ($stats['row_count'] !== $count + || bccomp($stats['income_total'], $income, 2) !== 0 + || bccomp($stats['expense_total'], $expense, 2) !== 0) { + throw new RuntimeException(sprintf( + '权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。', + $stats['row_count'], + $stats['income_total'], + $stats['expense_total'], + $count, + $income, + $expense, + )); + } + } + + private function assertSameImportRequest( + object $existing, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $expectedCount, + string $expectedIncome, + string $expectedExpense, + ): void { + if ((string) $existing->period_start !== $from->format('Y-m-d') + || (string) $existing->period_end !== $to->format('Y-m-d') + || (int) $existing->expected_row_count !== $expectedCount + || bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0 + || bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) { + throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。'); + } + } + + private function activeRowCount( + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + ): int { + return $this->connection->table(self::ROW_TABLE) + ->where('source_sha256', $hash) + ->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->count(); + } + + private function activate( + string $generation, + string $hash, + DateTimeImmutable $from, + DateTimeImmutable $to, + array $stats, + int $batchSize, + ): void { + $now = now(); + $columns = [ + 'store_name', 'custom_remark', 'bill_item', 'merchant_order_no', 'online_order_no', + 'business_type', 'business_description', 'account_name', 'occurred_at', 'income_amount', + 'expense_amount', 'signed_amount', 'remark', 'source_updated_at', 'source_modified_by', + 'source_row_number', 'source_generation', 'source_sha256', 'imported_at', + ]; + + $this->connection->transaction(function () use ( + $generation, $hash, $from, $to, $stats, $batchSize, $columns, $now + ): void { + $this->connection->table(self::ROW_TABLE) + ->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00') + ->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') + ->delete(); + + $this->connection->table(self::STAGING_TABLE) + ->where('generation', $generation) + ->orderBy('id') + ->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void { + $payload = []; + foreach ($rows as $row) { + $payload[] = [ + 'record_code' => $row->record_code, + 'store_name' => $row->store_name, + 'custom_remark' => $row->custom_remark, + 'bill_item' => $row->bill_item, + 'merchant_order_no' => $row->merchant_order_no, + 'online_order_no' => $row->online_order_no, + 'business_type' => $row->business_type, + 'business_description' => $row->business_description, + 'account_name' => $row->account_name, + 'occurred_at' => $row->occurred_at, + 'income_amount' => $row->income_amount, + 'expense_amount' => $row->expense_amount, + 'signed_amount' => $row->signed_amount, + 'remark' => $row->remark, + 'source_updated_at' => $row->source_updated_at, + 'source_modified_by' => $row->source_modified_by, + 'source_row_number' => $row->source_row_number, + 'source_generation' => $generation, + 'source_sha256' => $hash, + 'imported_at' => $now, + ]; + } + $this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns); + }); + + $this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([ + 'status' => 'succeeded', + 'row_count' => $stats['row_count'], + 'excluded_account_rows' => $stats['excluded_account_rows'], + 'store_count' => $stats['store_count'], + 'bill_item_count' => $stats['bill_item_count'], + 'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'income_total' => $stats['income_total'], + 'expense_total' => $stats['expense_total'], + 'net_total' => $stats['net_total'], + 'completed_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + }); + + $this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete(); + } + + /** @return array */ + private function rowCells(string $xml, array $sharedStrings): array + { + $row = simplexml_load_string($xml); + if (! $row instanceof SimpleXMLElement) { + return []; + } + + $cells = []; + foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) { + $reference = (string) ($cell['r'] ?? ''); + if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { + continue; + } + $type = (string) ($cell['t'] ?? ''); + if ($type === 'inlineStr') { + $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; + $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); + } else { + $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; + $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; + $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; + } + $cells[$this->columnIndex($matches[1])] = $value; + } + + return $cells; + } + + /** @param array $cells @return array */ + private function headerMap(array $cells): ?array + { + $lookup = []; + foreach ($cells as $column => $value) { + $lookup[trim($value)] = $column; + } + + $mapping = []; + foreach (self::HEADER_ALIASES as $field => $aliases) { + foreach ($aliases as $alias) { + if (array_key_exists($alias, $lookup)) { + $mapping[$field] = $lookup[$alias]; + break; + } + } + if (! array_key_exists($field, $mapping)) { + return null; + } + } + + return $mapping; + } + + private function required(array $values, string $key, int $row, ?string $label = null): string + { + $value = trim((string) ($values[$key] ?? '')); + if ($value === '') { + throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。'); + } + + return $value; + } + + private function money( + string $value, + int $row, + string $field, + bool $absolute = false, + bool $allowNegative = false, + ): string { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + $value = $value === '' ? '0' : $value; + if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) { + throw new RuntimeException("第 {$row} 行{$field}不是有效金额。"); + } + if ($absolute && bccomp($value, '0', 6) < 0) { + $value = bcmul($value, '-1', 6); + } + + return bcadd($value, '0', 2); + } + + private function controlAmount(string $value, string $label): string + { + $value = str_replace([',', ',', '¥', '¥'], '', trim($value)); + if (! is_numeric($value) || bccomp($value, '0', 6) < 0) { + throw new RuntimeException("权威{$label}控制数无效。"); + } + + return bcadd($value, '0', 2); + } + + private function dateTime(string $value, int $row, string $field): string + { + $parsed = $this->parseDateTime($value); + if ($parsed === null) { + throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。"); + } + + return $parsed; + } + + private function optionalDateTime(string $value, int $row, string $field): ?string + { + if (trim($value) === '') { + return null; + } + + return $this->dateTime($value, $row, $field); + } + + private function parseDateTime(string $value): ?string + { + $value = trim($value); + try { + if (is_numeric($value)) { + $seconds = (int) round((((float) $value) - 25569) * 86400); + + return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s'); + } + + return Carbon::parse($value)->format('Y-m-d H:i:s'); + } catch (Throwable) { + return null; + } + } + + private function nullable(string $value, int $max = 255): ?string + { + $value = trim($value); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + private function columnIndex(string $letters): int + { + $index = 0; + foreach (str_split(strtoupper($letters)) as $letter) { + $index = ($index * 26) + ord($letter) - 64; + } + + return $index - 1; + } + + /** @return list */ + private function sharedStrings(string $path, ZipArchive $archive): array + { + if ($archive->locateName('xl/sharedStrings.xml') === false) { + return []; + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取 XLSX 共享文本。'); + } + + $strings = []; + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { + continue; + } + $item = simplexml_load_string($reader->readOuterXml()); + $nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : []; + $strings[] = implode('', array_map( + static fn (SimpleXMLElement $node): string => (string) $node, + $nodes, + )); + } + } finally { + $reader->close(); + } + + return $strings; + } + + private function firstWorksheet(ZipArchive $archive): string + { + $worksheets = []; + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = $archive->getNameIndex($index); + if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { + $worksheets[] = $name; + } + } + sort($worksheets, SORT_NATURAL); + if ($worksheets === []) { + throw new RuntimeException('XLSX 文件中没有工作表。'); + } + + return $worksheets[0]; + } + + private function zipUri(string $path, string $entry): string + { + return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; + } + + private function result(array $row, string $status): array + { + return [ + 'status' => $status, + 'row_count' => (int) ($row['row_count'] ?? 0), + 'excluded_account_rows' => (int) ($row['excluded_account_rows'] ?? 0), + 'store_count' => (int) ($row['store_count'] ?? 0), + 'bill_item_count' => (int) ($row['bill_item_count'] ?? 0), + 'account_type_counts' => $this->accountTypeCounts($row['account_type_counts'] ?? []), + 'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2), + 'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2), + 'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2), + 'period_start' => (string) ($row['period_start'] ?? ''), + 'period_end' => (string) ($row['period_end'] ?? ''), + 'source_sha256' => (string) ($row['source_sha256'] ?? ''), + ]; + } + + /** @return array */ + private function accountTypeCounts(mixed $value): array + { + if (is_string($value)) { + try { + $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + } + if (! is_array($value)) { + return []; + } + + $counts = []; + foreach ($value as $account => $count) { + if (is_string($account) && $account !== '' && is_numeric($count)) { + $counts[$account] = (int) $count; + } + } + + return $counts; + } +} diff --git a/routes/admin.php b/routes/admin.php index 00a71acd..a0afc30d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Business\ProductPrices; use Illuminate\Support\Facades\Route; use Modules\EcommerceFinance\Http\Controllers\AccountBills; use Modules\EcommerceFinance\Http\Controllers\AlipayBills; +use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills; use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations; use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables; use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController; @@ -26,6 +27,12 @@ Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show']) ->name('ecommerce-finance.alipay-bills.show'); +Route::get('ecommerce-finance/account-bills/type/pinduoduo', [PinduoduoAccountBills::class, 'index']) + ->name('ecommerce-finance.pinduoduo-account-bills.index'); +Route::get('ecommerce-finance/account-bills/type/pinduoduo/filter-options', [PinduoduoAccountBills::class, 'filterOptions']) + ->name('ecommerce-finance.pinduoduo-account-bills.filter-options'); +Route::get('ecommerce-finance/account-bills/type/pinduoduo/export', [PinduoduoAccountBills::class, 'export']) + ->name('ecommerce-finance.pinduoduo-account-bills.export'); 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}/filter-options', [AccountBills::class, 'filterOptions']) diff --git a/tests/Feature/Business/PinduoduoReceivingBillsTest.php b/tests/Feature/Business/PinduoduoReceivingBillsTest.php index 591cc614..84b1af98 100644 --- a/tests/Feature/Business/PinduoduoReceivingBillsTest.php +++ b/tests/Feature/Business/PinduoduoReceivingBillsTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Mockery; -use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository; +use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository; use RuntimeException; use Tests\Feature\FeatureTestCase; @@ -20,8 +20,8 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase 'end' => '2026-09-10', 'keyword' => '260909', ]; - $repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class); - $repository->shouldReceive('paginate')->once()->with( + $repository = Mockery::mock(PinduoduoSettlementBillRepository::class); + $repository->shouldReceive('paginateAccountBills')->once()->with( $filters, 'occurred_at', 'desc', 2, 20, )->andReturn([ 'current_page' => 2, @@ -42,7 +42,7 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase ]], 'summary' => ['income' => 91.64, 'expense' => 0.0, 'balance' => 91.64, 'count' => 41], ]); - $this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository); + $this->app->instance(PinduoduoSettlementBillRepository::class, $repository); $this->withExceptionHandling() ->loginAs() @@ -61,19 +61,19 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void { $this->allowPage(); - $repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class); - $repository->shouldReceive('filterOptions')->once()->andReturn([ + $repository = Mockery::mock(PinduoduoSettlementBillRepository::class); + $repository->shouldReceive('accountBillFilterOptions')->once()->andReturn([ 'shops' => ['拼多多-A店', '拼多多-B店'], 'bill_items' => ['交易收入', '技术服务费'], ]); - $repository->shouldReceive('exportRows')->once()->with( + $repository->shouldReceive('exportAccountBills')->once()->with( ['store_name' => '拼多多-A店'], 'record_code', 'asc', )->andReturn([[ 'record_code' => '380425163199', 'store_name' => '拼多多-A店', 'payload' => ['finance_type' => '交易收入'], ]]); - $this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository); + $this->app->instance(PinduoduoSettlementBillRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') @@ -103,25 +103,25 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase public function test_unready_mirror_returns_a_clear_retryable_response(): void { $this->allowPage(); - $repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class); - $repository->shouldReceive('paginate')->once()->andThrow( - new RuntimeException('store_receiving_alipay_bill_mirror_not_ready') + $repository = Mockery::mock(PinduoduoSettlementBillRepository::class); + $repository->shouldReceive('paginateAccountBills')->once()->andThrow( + new RuntimeException('pinduoduo_authoritative_bills_not_ready') ); - $this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository); + $this->app->instance(PinduoduoSettlementBillRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'pinduoduo'])) ->assertStatus(503) - ->assertJsonPath('message', '拼多多账户账单共享业务镜像正在首次同步,完成后将自动显示。'); + ->assertJsonPath('message', '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。'); } public function test_invalid_date_range_is_rejected_before_reading_the_mirror(): void { $this->allowPage(); - $repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class); - $repository->shouldNotReceive('paginate'); - $this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository); + $repository = Mockery::mock(PinduoduoSettlementBillRepository::class); + $repository->shouldNotReceive('paginateAccountBills'); + $this->app->instance(PinduoduoSettlementBillRepository::class, $repository); $this->withExceptionHandling() ->loginAs() diff --git a/tests/Feature/Console/PinduoduoSettlementBillImporterTest.php b/tests/Feature/Console/PinduoduoSettlementBillImporterTest.php new file mode 100644 index 00000000..87b614e1 --- /dev/null +++ b/tests/Feature/Console/PinduoduoSettlementBillImporterTest.php @@ -0,0 +1,348 @@ + */ + private array $files = []; + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.connections.business_mirror' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + $this->createTables(); + } + + protected function tearDown(): void + { + foreach ($this->files as $file) { + @unlink($file); + } + + parent::tearDown(); + } + + public function test_it_imports_current_jushengsuan_headers_keeps_all_accounts_and_is_idempotent(): void + { + $this->insertActiveRow('OLD-IN-PERIOD', '2026-08-15 12:00:00'); + $this->insertActiveRow('KEEP-OUTSIDE-PERIOD', '2026-07-31 23:59:59'); + $path = $this->xlsx([ + $this->row('REC-INCOME', '2026-08-01 00:00:19', '100.00', '0', '100.00'), + $this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '-25.00', '拼多多货款账户', '退款'), + $this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0', '0'), + $this->row('REC-MARKETING', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '拼多多营销账户'), + ]); + + $importer = app(PinduoduoSettlementBillImporter::class); + $result = $importer->import( + $path, + '2026-08-01', + '2026-08-31', + 4, + '100.00', + '28.00', + 100, + ); + + $this->assertSame('succeeded', $result['status']); + $this->assertSame(4, $result['row_count']); + $this->assertSame(0, $result['excluded_account_rows']); + $this->assertSame(1, $result['store_count']); + $this->assertSame(2, $result['bill_item_count']); + $this->assertSame([ + '拼多多货款账户' => 3, + '拼多多营销账户' => 1, + ], $result['account_type_counts']); + $this->assertSame('100.00', $result['income_total']); + $this->assertSame('28.00', $result['expense_total']); + $this->assertSame('72.00', $result['net_total']); + $this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror'); + $this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror'); + $this->assertDatabaseHas('pdd_settlement_bill_rows', [ + 'record_code' => 'REC-MARKETING', + 'account_name' => '拼多多营销账户', + ], 'business_mirror'); + + $repository = app(PinduoduoSettlementBillRepository::class); + $this->assertTrue($repository->ready()); + $this->assertSame([ + 'income_total' => 100.0, + 'expense_total' => 28.0, + 'cnt' => 4, + ], $repository->summarize(null, '2026-08-01', '2026-08-31', null)); + $this->assertSame(1, $repository->paginate(null, '2026-08-01', '2026-08-31', 'income', 1, 25)['total']); + $this->assertSame(2, $repository->paginate(null, '2026-08-01', '2026-08-31', 'expense', 1, 25)['total']); + $zero = $repository->paginate(null, '2026-08-03', '2026-08-03', null, 1, 25); + $this->assertSame(0, $zero['rows'][0]['flowType']); + + $unchanged = $importer->import( + $path, + '2026-08-01', + '2026-08-31', + 4, + '100.00', + '28.00', + 100, + ); + $this->assertSame('unchanged', $unchanged['status']); + $this->assertSame(5, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('本次账期或权威控制数与历史记录不一致'); + $importer->import($path, '2026-08-01', '2026-08-31', 5, '100.00', '28.00', 100); + } + + public function test_control_mismatch_does_not_replace_the_active_period(): void + { + $this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00'); + $path = $this->xlsx([ + $this->row('NEW-ROW', '2026-08-16 12:00:00', '10.00', '0', '10.00'), + ]); + + try { + app(PinduoduoSettlementBillImporter::class)->import( + $path, + '2026-08-01', + '2026-08-31', + 2, + '10.00', + '0.00', + 100, + ); + $this->fail('控制数不一致时必须拒绝激活。'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('权威控制数不一致', $exception->getMessage()); + } + + $this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); + $this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror'); + $this->assertSame('failed', DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('status')); + $this->assertSame(2, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('expected_row_count')); + $this->assertSame(1, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('row_count')); + $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count()); + } + + public function test_duplicate_record_codes_are_rejected_without_replacing_active_rows(): void + { + $this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00'); + $path = $this->xlsx([ + $this->row('DUPLICATE', '2026-08-16 12:00:00', '10.00', '0', '10.00'), + $this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0', '20.00'), + ]); + + $failure = null; + try { + app(PinduoduoSettlementBillImporter::class)->import( + $path, + '2026-08-01', + '2026-08-31', + 2, + '30.00', + '0.00', + 100, + ); + } catch (\Throwable $exception) { + $failure = $exception; + } + + $this->assertNotNull($failure, '重复记录编码必须拒绝导入。'); + $this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror'); + $this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror'); + $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count()); + } + + public function test_out_of_period_and_inconsistent_amount_rows_are_rejected(): void + { + $outOfPeriod = $this->xlsx([ + $this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0', '10.00'), + ]); + $badAmount = $this->xlsx([ + $this->row('BAD-AMOUNT', '2026-08-10 00:00:00', '10.00', '2.00', '9.00'), + ]); + $importer = app(PinduoduoSettlementBillImporter::class); + + foreach ([ + [$outOfPeriod, '发生时间超出指定账期'], + [$badAmount, '收入、支出与金额不一致'], + ] as [$path, $message]) { + try { + $importer->import($path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100); + $this->fail($message . '时必须拒绝导入。'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString($message, $exception->getMessage()); + } + } + + $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count()); + $this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count()); + } + + public function test_blank_account_type_is_rejected(): void + { + $path = $this->xlsx([ + $this->row('BLANK-ACCOUNT', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('缺少账户类型'); + app(PinduoduoSettlementBillImporter::class)->import( + $path, + '2026-08-01', + '2026-08-31', + 1, + '10.00', + '0.00', + 100, + ); + } + + private function createTables(): void + { + $schema = Schema::connection('business_mirror'); + $schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void { + $table->uuid('generation')->primary(); + $table->string('source_filename'); + $table->char('source_sha256', 64)->unique(); + $table->date('period_start'); + $table->date('period_end'); + $table->string('status', 32)->default('running'); + $table->unsignedBigInteger('expected_row_count'); + $table->decimal('expected_income_total', 20, 2); + $table->decimal('expected_expense_total', 20, 2); + $table->unsignedBigInteger('row_count')->default(0); + $table->unsignedBigInteger('excluded_account_rows')->default(0); + $table->unsignedInteger('store_count')->default(0); + $table->unsignedInteger('bill_item_count')->default(0); + $table->json('account_type_counts')->nullable(); + $table->decimal('income_total', 20, 2)->default(0); + $table->decimal('expense_total', 20, 2)->default(0); + $table->decimal('net_total', 20, 2)->default(0); + $table->string('last_error_code', 120)->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + $schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->uuid('generation'); + $this->billColumns($table); + $table->unique(['generation', 'record_code']); + }); + $schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $this->billColumns($table, false); + $table->uuid('source_generation'); + $table->char('source_sha256', 64); + $table->timestamp('imported_at'); + }); + } + + private function billColumns(Blueprint $table, bool $includeRecordCode = true): void + { + if ($includeRecordCode) { + $table->string('record_code', 191); + } + $table->string('store_name')->nullable(); + $table->string('custom_remark')->nullable(); + $table->string('bill_item')->nullable(); + $table->string('merchant_order_no', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->string('business_type')->nullable(); + $table->string('business_description', 1000)->nullable(); + $table->string('account_name')->nullable(); + $table->dateTime('occurred_at'); + $table->decimal('income_amount', 20, 2)->default(0); + $table->decimal('expense_amount', 20, 2)->default(0); + $table->decimal('signed_amount', 20, 2)->default(0); + $table->string('remark', 1000)->nullable(); + $table->dateTime('source_updated_at')->nullable(); + $table->string('source_modified_by')->nullable(); + $table->unsignedBigInteger('source_row_number'); + } + + private function insertActiveRow(string $recordCode, string $occurredAt): void + { + DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([ + 'record_code' => $recordCode, + 'store_name' => '原有店铺', + 'bill_item' => '原有流水', + 'account_name' => PinduoduoSettlementBillImporter::ACCOUNT_NAME, + 'occurred_at' => $occurredAt, + 'income_amount' => 1, + 'expense_amount' => 0, + 'signed_amount' => 1, + 'source_row_number' => 1, + 'source_generation' => 'existing-generation', + 'source_sha256' => str_repeat('a', 64), + 'imported_at' => '2026-09-01 00:00:00', + ]); + } + + /** @return list */ + private function row( + string $recordCode, + string $occurredAt, + string $income, + string $expense, + string $signed, + string $account = PinduoduoSettlementBillImporter::ACCOUNT_NAME, + string $billItem = '技术服务费', + ): array { + return [ + $recordCode, + '拼多多-测试店铺', + '', + $billItem, + 'ORDER-' . $recordCode, + 'ONLINE-' . $recordCode, + $billItem, + '001|' . $billItem, + $account, + $occurredAt, + $income, + $expense, + $signed, + '-', + '2026-09-07 17:07:15', + 'RPA', + ]; + } + + /** @param list> $rows */ + private function xlsx(array $rows): string + { + $temporary = tempnam(sys_get_temp_dir(), 'pdd-authoritative-'); + $path = $temporary . '.xlsx'; + rename($temporary, $path); + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray(array_merge([[ + '记录编码', '店铺', '自定义备注', '账单项目', '商户订单号', '线上单号', + '财务类型', '业务描述', '类型', '发生时间', '收入金额', '支出金额', '金额', + '备注', '修改时间', '修改人', + ]], $rows)); + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + $this->files[] = $path; + + return $path; + } +} diff --git a/tests/Unit/EcommerceFinance/PinduoduoSettlementAccountBillsRepositoryTest.php b/tests/Unit/EcommerceFinance/PinduoduoSettlementAccountBillsRepositoryTest.php new file mode 100644 index 00000000..d1069465 --- /dev/null +++ b/tests/Unit/EcommerceFinance/PinduoduoSettlementAccountBillsRepositoryTest.php @@ -0,0 +1,152 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + $this->createTables(); + } + + public function test_account_bill_page_reads_authoritative_rows_with_filters_and_totals(): void + { + $this->insertSucceededImport(); + $this->insertRow('REC-INCOME', '拼多多一店', '交易收款', '2026-08-01 00:00:00', '100.00', '0.00', '100.00', 'M-100', 'O-100'); + $this->insertRow('REC-EXPENSE', '拼多多一店', '退款', '2026-08-31 23:59:59', '0.00', '20.00', '-20.00', 'M-200', 'O-200'); + $this->insertRow('REC-OTHER', '拼多多二店', '交易收款', '2026-08-15 12:00:00', '50.00', '0.00', '50.00', 'M-300', 'O-300'); + + $result = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([ + 'store_name' => '拼多多一店', + 'start' => '2026-08-01', + 'end' => '2026-08-31', + ], 'occurred_at', 'desc', 1, 10); + + $this->assertSame(2, $result['total']); + $this->assertSame(100.0, $result['summary']['income']); + $this->assertSame(20.0, $result['summary']['expense']); + $this->assertSame(80.0, $result['summary']['balance']); + $this->assertSame('REC-EXPENSE', $result['data'][0]['record_code']); + $this->assertSame('M-200', $result['data'][0]['order_no']); + $this->assertSame('O-200', $result['data'][0]['payload']['online_order_no']); + $this->assertSame('拼多多货款账户', $result['data'][0]['payload']['type']); + $this->assertSame(20.0, $result['data'][0]['amount']); + $this->assertSame(-20.0, $result['data'][0]['balance']); + + $filtered = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([ + 'bill_item' => '退款', + 'keyword' => 'O-200', + ], 'record_code', 'asc', 1, 10); + + $this->assertSame(1, $filtered['total']); + $this->assertSame('REC-EXPENSE', $filtered['data'][0]['record_code']); + + $options = app(PinduoduoSettlementBillRepository::class)->accountBillFilterOptions(); + $this->assertSame(['拼多多一店', '拼多多二店'], $options['shops']); + $this->assertSame(['交易收款', '退款'], $options['bill_items']); + + $export = iterator_to_array(app(PinduoduoSettlementBillRepository::class)->exportAccountBills([ + 'bill_item' => '退款', + ], 'record_code', 'asc')); + $this->assertCount(1, $export); + $this->assertSame('REC-EXPENSE', $export[0]['record_code']); + } + + public function test_account_bill_page_fails_closed_until_a_successful_import_exists(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('pinduoduo_authoritative_bills_not_ready'); + + app(PinduoduoSettlementBillRepository::class)->paginateAccountBills( + [], + 'occurred_at', + 'desc', + 1, + 10, + ); + } + + private function createTables(): void + { + $schema = Schema::connection('business_mirror'); + $schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void { + $table->uuid('generation')->primary(); + $table->string('status', 32); + }); + $schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $table->string('store_name')->nullable(); + $table->string('custom_remark')->nullable(); + $table->string('bill_item')->nullable(); + $table->string('merchant_order_no', 191)->nullable(); + $table->string('online_order_no', 191)->nullable(); + $table->string('business_type')->nullable(); + $table->string('business_description', 1000)->nullable(); + $table->string('account_name')->nullable(); + $table->dateTime('occurred_at'); + $table->decimal('income_amount', 20, 2); + $table->decimal('expense_amount', 20, 2); + $table->decimal('signed_amount', 20, 2); + $table->string('remark', 1000)->nullable(); + $table->dateTime('source_updated_at')->nullable(); + $table->string('source_modified_by')->nullable(); + }); + } + + private function insertSucceededImport(): void + { + DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->insert([ + 'generation' => '11111111-1111-1111-1111-111111111111', + 'status' => 'succeeded', + ]); + } + + private function insertRow( + string $recordCode, + string $storeName, + string $billItem, + string $occurredAt, + string $income, + string $expense, + string $signed, + string $merchantOrderNo, + string $onlineOrderNo, + ): void { + DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([ + 'record_code' => $recordCode, + 'store_name' => $storeName, + 'custom_remark' => '自定义备注', + 'bill_item' => $billItem, + 'merchant_order_no' => $merchantOrderNo, + 'online_order_no' => $onlineOrderNo, + 'business_type' => '货款', + 'business_description' => '业务描述', + 'account_name' => '拼多多货款账户', + 'occurred_at' => $occurredAt, + 'income_amount' => $income, + 'expense_amount' => $expense, + 'signed_amount' => $signed, + 'remark' => '账单备注', + 'source_updated_at' => '2026-09-01 08:00:00', + 'source_modified_by' => '聚胜算', + ]); + } +} -- 2.47.2