接入拼多多完整账单并保留营销账户 #238
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportPinduoduoSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'pinduoduo-bills:import-authoritative
|
||||
{path : 聚胜算导出的拼多多完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
|
||||
|
||||
public function handle(PinduoduoSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Http\Controllers;
|
||||
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class PinduoduoAccountBills extends Controller
|
||||
{
|
||||
public function index(Request $request, PinduoduoSettlementBillRepository $repository)
|
||||
{
|
||||
$this->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<string,array<int,string>> */
|
||||
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<string,mixed> $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, '当前账号没有店铺收款账户账单权限。');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class PinduoduoSettlementBillRepository
|
||||
{
|
||||
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
|
||||
private const ROW_TABLE = 'pdd_settlement_bill_rows';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->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<string>,bill_items:list<string>} */
|
||||
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<array<string,mixed>>,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<string,mixed> */
|
||||
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<int,array<string,mixed>> */
|
||||
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<string,mixed> $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<string,mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,709 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
class PinduoduoSettlementBillImporter
|
||||
{
|
||||
public const ACCOUNT_NAME = '拼多多货款账户';
|
||||
|
||||
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
|
||||
private const STAGING_TABLE = 'pdd_settlement_bill_staging';
|
||||
private const ROW_TABLE = 'pdd_settlement_bill_rows';
|
||||
|
||||
private const HEADER_ALIASES = [
|
||||
'record_code' => ['记录编码'],
|
||||
'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<string,int>,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<string,int>,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<int,string> */
|
||||
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<int,string> $cells @return array<string,int> */
|
||||
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<string> */
|
||||
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<string,int> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ use App\Http\Controllers\Business\SupplierPayableActions;
|
||||
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;
|
||||
@ -27,6 +28,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'])
|
||||
|
||||
@ -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()
|
||||
|
||||
348
tests/Feature/Console/PinduoduoSettlementBillImporterTest.php
Normal file
348
tests/Feature/Console/PinduoduoSettlementBillImporterTest.php
Normal file
@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PinduoduoSettlementBillImporterTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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<string> */
|
||||
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<list<string>> $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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PinduoduoSettlementAccountBillsRepositoryTest extends TestCase
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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' => '聚胜算',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user