feat: 导入淘系微信权威账单 (#260)

保留现有表头,新增受控幂等导入、页面查询和完整账单校验。
This commit is contained in:
yueyang 2026-09-24 10:29:59 +08:00
parent 2b77118588
commit e7ca3026a6
9 changed files with 2068 additions and 0 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\TaobaoWechatSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportTaobaoWechatSettlementBills extends Command
{
protected $signature = 'taobao-wechat-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(TaobaoWechatSettlementBillImporter $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_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['entry_type_counts'])
->map(fn (int $count, string $type): string => "{$type} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -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('taobao_wechat_bill_imports')) {
$schema->create('taobao_wechat_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_rows')->default(0);
$table->unsignedInteger('store_count')->default(0);
$table->unsignedInteger('bill_item_count')->default(0);
$table->json('entry_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'], 'taobao_wechat_bill_import_period_idx');
});
}
if (! $schema->hasTable('taobao_wechat_bill_staging')) {
$schema->create('taobao_wechat_bill_staging', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('generation');
$this->billColumns($table);
$table->unique(['generation', 'record_code'], 'taobao_wechat_bill_stage_generation_record_uq');
$table->index(['generation', 'occurred_at'], 'taobao_wechat_bill_stage_generation_time_idx');
});
}
if (! $schema->hasTable('taobao_wechat_bill_rows')) {
$schema->create('taobao_wechat_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'], 'taobao_wechat_bill_time_record_idx');
$table->index(['store_name', 'occurred_at'], 'taobao_wechat_bill_store_time_idx');
$table->index(['bill_item', 'occurred_at'], 'taobao_wechat_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('taobao_wechat_bill_rows');
$schema->dropIfExists('taobao_wechat_bill_staging');
$schema->dropIfExists('taobao_wechat_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('taobao_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('pay_serial_no')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('entry_type')->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('jst_pulled_at')->nullable();
$table->string('source_modified_by')->nullable();
$table->unsignedBigInteger('source_row_number');
}
};

View File

@ -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\TaobaoWechatSettlementBillRepository;
use RuntimeException;
class TaobaoWechatAccountBills extends Controller
{
public function index(Request $request, TaobaoWechatSettlementBillRepository $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() !== 'taobao_wechat_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('taobao-wechat'),
], 503);
}
$data['columns'] = AccountBillColumns::for('taobao-wechat');
return response()->json($data);
}
public function filterOptions(TaobaoWechatSettlementBillRepository $repository)
{
$this->authorizeStoreReceivingBills();
try {
return response()->json($repository->accountBillFilterOptions());
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'taobao_wechat_authoritative_bills_not_ready') {
throw $exception;
}
return response()->json([
'message' => '淘系微信完整账单尚未完成首次导入。',
'shops' => [],
'bill_items' => [],
], 503);
}
}
public function export(Request $request, TaobaoWechatSettlementBillRepository $repository)
{
$this->authorizeStoreReceivingBills();
return $this->exportResponse($request, $repository);
}
private function exportResponse(Request $request, TaobaoWechatSettlementBillRepository $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('taobao-wechat');
$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() !== 'taobao_wechat_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);
}, 'taobao-wechat_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, '当前账号没有店铺收款账户账单权限。');
}
}

View File

@ -0,0 +1,308 @@
<?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 TaobaoWechatSettlementBillRepository
{
private const IMPORT_TABLE = 'taobao_wechat_bill_imports';
private const ROW_TABLE = 'taobao_wechat_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->taobao_order_no ?? ''),
'source_record_code' => (string) $row->record_code,
'source_channel' => 'TaobaoWechatSettlementBill',
];
})->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('taobao_wechat_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('taobao_order_no', 'like', $like)
->orWhere('online_order_no', 'like', $like)
->orWhere('pay_serial_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->taobao_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->jst_pulled_at !== null
? (string) $row->jst_pulled_at
: null,
'modifier' => (string) ($row->source_modified_by ?? ''),
'payload' => [
'online_order_no' => (string) ($row->online_order_no ?? ''),
'taobao_order_no' => (string) ($row->taobao_order_no ?? ''),
'pay_serial_no' => (string) ($row->pay_serial_no ?? ''),
'entry_type' => (string) ($row->entry_type ?? ''),
'business_desc' => (string) ($row->business_description ?? ''),
'jst_pulled_at' => $row->jst_pulled_at !== null
? (string) $row->jst_pulled_at
: null,
],
];
}
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;
}
}

View File

@ -0,0 +1,710 @@
<?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 TaobaoWechatSettlementBillImporter
{
private const IMPORT_TABLE = 'taobao_wechat_bill_imports';
private const STAGING_TABLE = 'taobao_wechat_bill_staging';
private const ROW_TABLE = 'taobao_wechat_bill_rows';
private const HEADER_ALIASES = [
'record_code' => ['记录编码'],
'store_name' => ['店铺', '原始店铺'],
'custom_remark' => ['自定义备注'],
'bill_item' => ['账单项目'],
'taobao_order_no' => ['淘宝订单编号'],
'online_order_no' => ['线上单号'],
'pay_serial_no' => ['支付流水号'],
'business_description' => ['业务描述'],
'entry_type' => ['入账类型'],
'occurred_at' => ['入账时间'],
'income_amount' => ['收入金额'],
'expense_amount' => ['支出金额'],
'signed_amount' => ['金额'],
'remark' => ['备注'],
'jst_pulled_at' => ['JST拉取时间'],
'source_modified_by' => ['修改人'],
];
private const OPTIONAL_HEADERS = ['signed_amount'];
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_rows:int,store_count:int,bill_item_count:int,entry_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_rows' => 0,
'store_count' => 0,
'bill_item_count' => 0,
'entry_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_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'entry_type_counts' => $stats['entry_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_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'entry_type_counts' => json_encode($stats['entry_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_rows:int,store_count:int,bill_item_count:int,entry_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 = [];
$entryTypeCounts = [];
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, '记录编码');
$entryType = $this->required($values, 'entry_type', $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);
$calculated = bcsub($income, $expense, 2);
$signed = $calculated;
if (array_key_exists('signed_amount', $headerMap)) {
$signed = $this->money($values['signed_amount'], $rowNumber, '金额', false, true);
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,
'taobao_order_no' => $this->nullable($values['taobao_order_no'], 191),
'online_order_no' => $this->nullable($values['online_order_no'], 191),
'pay_serial_no' => $this->nullable($values['pay_serial_no']),
'business_description' => $this->nullable($values['business_description'], 1000),
'entry_type' => mb_substr($entryType, 0, 255),
'occurred_at' => $occurredAt,
'income_amount' => $income,
'expense_amount' => $expense,
'signed_amount' => $signed,
'remark' => $this->nullable($values['remark'], 1000),
'jst_pulled_at' => $this->optionalDateTime($values['jst_pulled_at'], $rowNumber, 'JST拉取时间'),
'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;
}
$entryTypeCounts[$entryType] = ($entryTypeCounts[$entryType] ?? 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_rows' => $excluded,
'store_count' => count($stores),
'bill_item_count' => count($billItems),
'entry_type_counts' => $entryTypeCounts,
'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', 'taobao_order_no', 'online_order_no',
'pay_serial_no', 'business_description', 'entry_type', 'occurred_at', 'income_amount',
'expense_amount', 'signed_amount', 'remark', 'jst_pulled_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,
'taobao_order_no' => $row->taobao_order_no,
'online_order_no' => $row->online_order_no,
'pay_serial_no' => $row->pay_serial_no,
'business_description' => $row->business_description,
'entry_type' => $row->entry_type,
'occurred_at' => $row->occurred_at,
'income_amount' => $row->income_amount,
'expense_amount' => $row->expense_amount,
'signed_amount' => $row->signed_amount,
'remark' => $row->remark,
'jst_pulled_at' => $row->jst_pulled_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_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'entry_type_counts' => json_encode($stats['entry_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)
&& ! in_array($field, self::OPTIONAL_HEADERS, true)) {
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_rows' => (int) ($row['excluded_rows'] ?? 0),
'store_count' => (int) ($row['store_count'] ?? 0),
'bill_item_count' => (int) ($row['bill_item_count'] ?? 0),
'entry_type_counts' => $this->entryTypeCounts($row['entry_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 entryTypeCounts(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;
}
}

View File

@ -14,6 +14,7 @@ use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
use Modules\EcommerceFinance\Http\Controllers\DewuAccountBills;
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
use Modules\EcommerceFinance\Http\Controllers\TaobaoWechatAccountBills;
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController;
@ -42,6 +43,12 @@ Route::get('ecommerce-finance/account-bills/type/dewu/filter-options', [DewuAcco
->name('ecommerce-finance.dewu-account-bills.filter-options');
Route::get('ecommerce-finance/account-bills/type/dewu/export', [DewuAccountBills::class, 'export'])
->name('ecommerce-finance.dewu-account-bills.export');
Route::get('ecommerce-finance/account-bills/type/taobao-wechat', [TaobaoWechatAccountBills::class, 'index'])
->name('ecommerce-finance.taobao-wechat-account-bills.index');
Route::get('ecommerce-finance/account-bills/type/taobao-wechat/filter-options', [TaobaoWechatAccountBills::class, 'filterOptions'])
->name('ecommerce-finance.taobao-wechat-account-bills.filter-options');
Route::get('ecommerce-finance/account-bills/type/taobao-wechat/export', [TaobaoWechatAccountBills::class, 'export'])
->name('ecommerce-finance.taobao-wechat-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'])

View File

@ -0,0 +1,141 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
class TaobaoWechatReceivingBillsTest extends FeatureTestCase
{
public function test_endpoint_reads_authoritative_taobao_wechat_rows_with_all_filters(): void
{
$this->allowPage();
$filters = [
'store_name' => '淘系微信-测试店',
'bill_item' => '交易收入',
'start' => '2026-08-12',
'end' => '2026-09-10',
'keyword' => '260909',
];
$repository = Mockery::mock(TaobaoWechatSettlementBillRepository::class);
$repository->shouldReceive('paginateAccountBills')->once()->with(
$filters, 'occurred_at', 'desc', 2, 20,
)->andReturn([
'current_page' => 2,
'last_page' => 3,
'per_page' => 20,
'total' => 41,
'data' => [[
'id' => '14424200',
'record_code' => '380425163199',
'store_name' => '淘系微信-测试店',
'income' => 91.64,
'expense' => 0.0,
'payload' => [
'taobao_order_no' => '260909-047741735284017',
'entry_type' => '收入',
],
]],
'summary' => ['income' => 91.64, 'expense' => 0.0, 'balance' => 91.64, 'count' => 41],
]);
$this->app->instance(TaobaoWechatSettlementBillRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()
->getJson(route('ecommerce-finance.account-bills.index', [
'bill_type' => 'taobao-wechat',
...$filters,
'page' => 2,
'limit' => 20,
]))
->assertOk()
->assertJsonPath('data.0.payload.entry_type', '收入')
->assertJsonPath('columns.title', '淘系微信账户')
->assertJsonPath('summary.count', 41);
}
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(TaobaoWechatSettlementBillRepository::class);
$repository->shouldReceive('accountBillFilterOptions')->once()->andReturn([
'shops' => ['淘系微信-A店', '淘系微信-B店'],
'bill_items' => ['交易收入', '技术服务费'],
]);
$repository->shouldReceive('exportAccountBills')->once()->with(
['store_name' => '淘系微信-A店'], 'record_code', 'asc',
)->andReturn([[
'record_code' => '380425163199',
'store_name' => '淘系微信-A店',
'payload' => ['entry_type' => '收入'],
]]);
$this->app->instance(TaobaoWechatSettlementBillRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.filter-options', ['bill_type' => 'taobao-wechat']))
->assertOk()
->assertExactJson([
'shops' => ['淘系微信-A店', '淘系微信-B店'],
'bill_items' => ['交易收入', '技术服务费'],
]);
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
'bill_type' => 'taobao-wechat',
'store_name' => '淘系微信-A店',
'sort' => 'record_code',
'dir' => 'asc',
'columns' => 'payload.entry_type,record_code,store_name',
]));
$response->assertOk()
->assertHeader('content-type', 'text/csv; charset=UTF-8')
->assertDownload('taobao-wechat_account_bills.csv');
$csv = $response->streamedContent();
$this->assertStringContainsString('入账类型,记录编码,店铺', $csv);
$this->assertStringContainsString('收入,"=""380425163199""",淘系微信-A店', $csv);
}
public function test_unready_mirror_returns_a_clear_retryable_response(): void
{
$this->allowPage();
$repository = Mockery::mock(TaobaoWechatSettlementBillRepository::class);
$repository->shouldReceive('paginateAccountBills')->once()->andThrow(
new RuntimeException('taobao_wechat_authoritative_bills_not_ready')
);
$this->app->instance(TaobaoWechatSettlementBillRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'taobao-wechat']))
->assertStatus(503)
->assertJsonPath('message', '淘系微信完整账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。');
}
public function test_invalid_date_range_is_rejected_before_reading_the_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(TaobaoWechatSettlementBillRepository::class);
$repository->shouldNotReceive('paginateAccountBills');
$this->app->instance(TaobaoWechatSettlementBillRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()
->getJson(route('ecommerce-finance.account-bills.index', [
'bill_type' => 'taobao-wechat',
'start' => '2026-09-10',
'end' => '2026-08-12',
]))
->assertStatus(422);
}
private function allowPage(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
}

View File

@ -0,0 +1,388 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Services\TaobaoWechatSettlementBillImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use Tests\TestCase;
class TaobaoWechatSettlementBillImporterTest 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_entry_types_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-OTHER', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '其他'),
], false);
$importer = app(TaobaoWechatSettlementBillImporter::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_rows']);
$this->assertSame(1, $result['store_count']);
$this->assertSame(2, $result['bill_item_count']);
$this->assertSame([
'收入' => 2,
'支出' => 1,
'其他' => 1,
], $result['entry_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('taobao_wechat_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror');
$this->assertDatabaseHas('taobao_wechat_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror');
$this->assertDatabaseHas('taobao_wechat_bill_rows', [
'record_code' => 'REC-OTHER',
'entry_type' => '其他',
'signed_amount' => -3,
], 'business_mirror');
$this->assertDatabaseHas('taobao_wechat_bill_rows', [
'record_code' => 'REC-INCOME',
'signed_amount' => 100,
], 'business_mirror');
$repository = app(TaobaoWechatSettlementBillRepository::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('taobao_wechat_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_local_authoritative_export_matches_known_controls_when_configured(): void
{
$path = getenv('TAOBAO_WECHAT_AUTHORITATIVE_FILE');
if (! is_string($path) || $path === '') {
$this->markTestSkipped('未配置本地淘系微信权威账单文件。');
}
$result = app(TaobaoWechatSettlementBillImporter::class)->import(
$path,
'2026-08-01',
'2026-09-22',
30301,
'1635044.56',
'1771430.54',
1000,
);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(30301, $result['row_count']);
$this->assertSame(21, $result['store_count']);
$this->assertSame(27, $result['bill_item_count']);
$this->assertCount(7, $result['entry_type_counts']);
$this->assertSame('-136385.98', $result['net_total']);
}
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(TaobaoWechatSettlementBillImporter::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('taobao_wechat_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
$this->assertDatabaseMissing('taobao_wechat_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror');
$this->assertSame('failed', DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('status'));
$this->assertSame(2, DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('expected_row_count'));
$this->assertSame(1, DB::connection('business_mirror')->table('taobao_wechat_bill_imports')->value('row_count'));
$this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_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(TaobaoWechatSettlementBillImporter::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('taobao_wechat_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
$this->assertDatabaseMissing('taobao_wechat_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror');
$this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_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(TaobaoWechatSettlementBillImporter::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('taobao_wechat_bill_rows')->count());
$this->assertSame(0, DB::connection('business_mirror')->table('taobao_wechat_bill_staging')->count());
}
public function test_blank_entry_type_is_rejected(): void
{
$path = $this->xlsx([
$this->row('BLANK-ENTRY-TYPE', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''),
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('缺少入账类型');
app(TaobaoWechatSettlementBillImporter::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('taobao_wechat_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_rows')->default(0);
$table->unsignedInteger('store_count')->default(0);
$table->unsignedInteger('bill_item_count')->default(0);
$table->json('entry_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('taobao_wechat_bill_staging', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('generation');
$this->billColumns($table);
$table->unique(['generation', 'record_code']);
});
$schema->create('taobao_wechat_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('taobao_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('pay_serial_no')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('entry_type')->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('jst_pulled_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('taobao_wechat_bill_rows')->insert([
'record_code' => $recordCode,
'store_name' => '原有店铺',
'bill_item' => '原有流水',
'entry_type' => '收入',
'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 $entryType = '收入',
string $billItem = '技术服务费',
): array {
return [
$recordCode,
'淘系微信-测试店铺',
'',
$billItem,
'ONLINE-' . $recordCode,
'TB-' . $recordCode,
'PAY-' . $recordCode,
$occurredAt,
$entryType,
$income,
$expense,
$signed,
'业务描述',
'-',
'2026-09-07 17:07:15',
'RPA',
];
}
/** @param list<list<string>> $rows */
private function xlsx(array $rows, bool $includeSignedAmount = true): string
{
$temporary = tempnam(sys_get_temp_dir(), 'taobao-wechat-authoritative-');
$path = $temporary . '.xlsx';
rename($temporary, $path);
$spreadsheet = new Spreadsheet();
$headers = [
'记录编码', '店铺', '自定义备注', '账单项目', '线上单号', '淘宝订单编号',
'支付流水号', '入账时间', '入账类型', '收入金额', '支出金额', '金额',
'业务描述', '备注', 'JST拉取时间', '修改人',
];
if (! $includeSignedAmount) {
array_splice($headers, 11, 1);
$rows = array_map(static function (array $row): array {
array_splice($row, 11, 1);
return $row;
}, $rows);
}
$spreadsheet->getActiveSheet()->fromArray(array_merge([$headers], $rows));
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
$this->files[] = $path;
return $path;
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use RuntimeException;
use Tests\TestCase;
class TaobaoWechatSettlementAccountBillsRepositoryTest 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(TaobaoWechatSettlementBillRepository::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']['entry_type']);
$this->assertSame('M-200', $result['data'][0]['payload']['taobao_order_no']);
$this->assertSame('2026-09-01 08:00:00', $result['data'][0]['payload']['jst_pulled_at']);
$this->assertSame(20.0, $result['data'][0]['amount']);
$this->assertSame(-20.0, $result['data'][0]['balance']);
$filtered = app(TaobaoWechatSettlementBillRepository::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(TaobaoWechatSettlementBillRepository::class)->accountBillFilterOptions();
$this->assertSame(['淘系微信一店', '淘系微信二店'], $options['shops']);
$this->assertSame(['交易收款', '退款'], $options['bill_items']);
$export = iterator_to_array(app(TaobaoWechatSettlementBillRepository::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('taobao_wechat_authoritative_bills_not_ready');
app(TaobaoWechatSettlementBillRepository::class)->paginateAccountBills(
[],
'occurred_at',
'desc',
1,
10,
);
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('taobao_wechat_bill_imports', function (Blueprint $table): void {
$table->uuid('generation')->primary();
$table->string('status', 32);
});
$schema->create('taobao_wechat_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('taobao_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('pay_serial_no')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('entry_type')->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('jst_pulled_at')->nullable();
$table->string('source_modified_by')->nullable();
});
}
private function insertSucceededImport(): void
{
DB::connection('business_mirror')->table('taobao_wechat_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 $taobaoOrderNo,
string $onlineOrderNo,
): void {
DB::connection('business_mirror')->table('taobao_wechat_bill_rows')->insert([
'record_code' => $recordCode,
'store_name' => $storeName,
'custom_remark' => '自定义备注',
'bill_item' => $billItem,
'taobao_order_no' => $taobaoOrderNo,
'online_order_no' => $onlineOrderNo,
'pay_serial_no' => 'PAY-' . $recordCode,
'business_description' => '业务描述',
'entry_type' => $billItem,
'occurred_at' => $occurredAt,
'income_amount' => $income,
'expense_amount' => $expense,
'signed_amount' => $signed,
'remark' => '账单备注',
'jst_pulled_at' => '2026-09-01 08:00:00',
'source_modified_by' => '聚胜算',
]);
}
}