导入阿里巴巴账户余额快照 #159
46
app/Console/Commands/ImportAlibabaAccountBalances.php
Normal file
46
app/Console/Commands/ImportAlibabaAccountBalances.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportAlibabaAccountBalances extends Command
|
||||
{
|
||||
protected $signature = 'business:import-alibaba-account-balances
|
||||
{path : 阿里巴巴账户余额 Excel 文件路径}
|
||||
{--source-name= : 原始文件名,用于导入来源追溯}
|
||||
{--dry-run : 只校验和统计,不写入共享业务库}';
|
||||
|
||||
protected $description = '校验并导入阿里巴巴账户月末余额快照';
|
||||
|
||||
public function handle(AlibabaAccountBalanceWorkbookImporter $importer): int
|
||||
{
|
||||
$path = (string) $this->argument('path');
|
||||
|
||||
try {
|
||||
$sourceName = trim((string) $this->option('source-name'));
|
||||
$result = $importer->import(
|
||||
$path,
|
||||
$sourceName !== '' ? $sourceName : basename($path),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$action = $this->option('dry-run') ? '校验完成' : '导入完成';
|
||||
$this->info(sprintf(
|
||||
'%s:共 %d 条,新增 %d 条,更新 %d 条。',
|
||||
$action,
|
||||
$result['total'],
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\View\BillOffsetPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
@ -1310,7 +1311,7 @@ class Pages extends Controller
|
||||
'pending' => false,
|
||||
];
|
||||
|
||||
if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) {
|
||||
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
@ -1325,11 +1326,14 @@ class Pages extends Controller
|
||||
'month' => $month !== '' ? $month : null,
|
||||
];
|
||||
$isTaobao = $tab === 'taobao-alipay';
|
||||
$repository = $isTaobao
|
||||
? app(TmallTaobaoSpreadBillRepository::class)
|
||||
: app(PinduoduoSpreadBillRepository::class);
|
||||
$isAlibaba = $tab === 'taobao-wechat';
|
||||
$repository = match ($tab) {
|
||||
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
|
||||
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
|
||||
default => app(PinduoduoSpreadBillRepository::class),
|
||||
};
|
||||
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
|
||||
$cache = $this->upstreamCaches->forSource('jiansun');
|
||||
$cache = $isAlibaba ? null : $this->upstreamCaches->forSource('jiansun');
|
||||
$usingMirror = $repository->usesBusinessMirror();
|
||||
|
||||
try {
|
||||
@ -1339,7 +1343,7 @@ class Pages extends Controller
|
||||
$page,
|
||||
$pageSize,
|
||||
);
|
||||
$result = $usingMirror
|
||||
$result = ($usingMirror || $isAlibaba)
|
||||
? $resolver()
|
||||
: $cache->remember(
|
||||
$scope,
|
||||
@ -1354,13 +1358,14 @@ class Pages extends Controller
|
||||
$result = ['rows' => [], 'total' => 0];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。';
|
||||
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : '拼多多');
|
||||
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
|
||||
$result = ['rows' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
try {
|
||||
$resolver = fn () => $repository->storeOptions();
|
||||
$storeOptions = $usingMirror
|
||||
$storeOptions = ($usingMirror || $isAlibaba)
|
||||
? $resolver()
|
||||
: $cache->remember(
|
||||
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
|
||||
@ -1406,7 +1411,7 @@ class Pages extends Controller
|
||||
}
|
||||
|
||||
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
|
||||
abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404);
|
||||
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true), 404);
|
||||
|
||||
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
|
||||
$month = trim((string) $request->query('month', ''));
|
||||
@ -1416,9 +1421,12 @@ class Pages extends Controller
|
||||
'month' => $month !== '' ? $month : null,
|
||||
];
|
||||
$isTaobao = $tab === 'taobao-alipay';
|
||||
$repository = $isTaobao
|
||||
? app(TmallTaobaoSpreadBillRepository::class)
|
||||
: app(PinduoduoSpreadBillRepository::class);
|
||||
$isAlibaba = $tab === 'taobao-wechat';
|
||||
$repository = match ($tab) {
|
||||
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
|
||||
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
|
||||
default => app(PinduoduoSpreadBillRepository::class),
|
||||
};
|
||||
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
|
||||
$redirectParameters = array_filter([
|
||||
'section' => $section,
|
||||
@ -1430,7 +1438,7 @@ class Pages extends Controller
|
||||
|
||||
try {
|
||||
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
|
||||
$result = $repository->usesBusinessMirror()
|
||||
$result = ($repository->usesBusinessMirror() || $isAlibaba)
|
||||
? $resolver()
|
||||
: $this->upstreamCaches->forSource('jiansun')->remember(
|
||||
$scope,
|
||||
@ -1448,6 +1456,8 @@ class Pages extends Controller
|
||||
}
|
||||
$rows = $result['rows'] ?? [];
|
||||
|
||||
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : 'pinduoduo');
|
||||
|
||||
return response()->streamDownload(function () use ($rows): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
@ -1465,7 +1475,7 @@ class Pages extends Controller
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
|
||||
}, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if ($schema->hasTable('alibaba_account_balance_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->create('alibaba_account_balance_snapshots', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('balance_date');
|
||||
$table->string('store_name', 255);
|
||||
$table->decimal('opening_balance', 20, 6)->nullable();
|
||||
$table->decimal('income_total', 20, 6)->nullable();
|
||||
$table->decimal('expense_total', 20, 6)->nullable();
|
||||
$table->decimal('closing_balance', 20, 6)->nullable();
|
||||
$table->decimal('system_closing_balance', 20, 6)->nullable();
|
||||
$table->decimal('balance_difference', 20, 6)->nullable();
|
||||
$table->text('difference_reason')->nullable();
|
||||
$table->string('verification_status', 64)->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->string('source_file_name', 255);
|
||||
$table->char('source_sha256', 64);
|
||||
$table->dateTime('imported_at', 6);
|
||||
$table->timestamps(6);
|
||||
|
||||
$table->unique(['balance_date', 'store_name'], 'alibaba_balance_date_store_unique');
|
||||
$table->index(['store_name', 'balance_date'], 'alibaba_balance_store_date_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())->dropIfExists('alibaba_account_balance_snapshots');
|
||||
}
|
||||
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AlibabaAccountBalanceRepository
|
||||
{
|
||||
public const TABLE = 'alibaba_account_balance_snapshots';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->connection = $database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror')
|
||||
);
|
||||
}
|
||||
|
||||
public function usesBusinessMirror(): bool
|
||||
{
|
||||
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function storeOptions(): array
|
||||
{
|
||||
if (! $this->usesBusinessMirror()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->connection->table(self::TABLE)
|
||||
->whereNotNull('store_name')
|
||||
->where('store_name', '<>', '')
|
||||
->distinct()
|
||||
->orderBy('store_name')
|
||||
->pluck('store_name')
|
||||
->map(static fn (mixed $value): string => (string) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
|
||||
public function paginateMonthlyBalances(
|
||||
?string $storeName = null,
|
||||
?string $month = null,
|
||||
int $page = 1,
|
||||
int $pageSize = 25,
|
||||
): array {
|
||||
if (! $this->usesBusinessMirror()) {
|
||||
return ['rows' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$pageSize = min(max($pageSize, 1), 5000);
|
||||
$query = $this->connection->table(self::TABLE);
|
||||
$this->applyFilters($query, $storeName, $month);
|
||||
$total = (clone $query)->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return ['rows' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->orderByDesc('balance_date')
|
||||
->orderBy('store_name')
|
||||
->forPage($page, $pageSize)
|
||||
->get([
|
||||
'balance_date', 'store_name', 'opening_balance', 'income_total',
|
||||
'expense_total', 'closing_balance', 'system_closing_balance',
|
||||
'balance_difference', 'difference_reason', 'verification_status', 'notes',
|
||||
])
|
||||
->map(static fn (object $row): array => [
|
||||
'date' => (string) $row->balance_date,
|
||||
'store_name' => (string) $row->store_name,
|
||||
'opening_balance' => $row->opening_balance === null ? null : (float) $row->opening_balance,
|
||||
'income_total' => $row->income_total === null ? null : (float) $row->income_total,
|
||||
'expense_total' => $row->expense_total === null ? null : (float) $row->expense_total,
|
||||
'closing_balance' => $row->closing_balance === null ? null : (float) $row->closing_balance,
|
||||
'system_closing_balance' => $row->system_closing_balance === null ? null : (float) $row->system_closing_balance,
|
||||
'balance_difference' => $row->balance_difference === null ? null : (float) $row->balance_difference,
|
||||
'difference_reason' => (string) ($row->difference_reason ?? ''),
|
||||
'verification_status' => (string) ($row->verification_status ?? ''),
|
||||
'notes' => (string) ($row->notes ?? ''),
|
||||
])
|
||||
->all();
|
||||
|
||||
return ['rows' => $rows, 'total' => $total];
|
||||
}
|
||||
|
||||
private function applyFilters(Builder $query, ?string $storeName, ?string $month): void
|
||||
{
|
||||
if ($storeName !== null && $storeName !== '') {
|
||||
$query->where('store_name', $storeName);
|
||||
}
|
||||
|
||||
if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
$query->where('balance_date', '>=', $month . '-01')
|
||||
->where('balance_date', '<', date('Y-m-d', strtotime($month . '-01 +1 month')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use RuntimeException;
|
||||
|
||||
class AlibabaAccountBalanceWorkbookImporter
|
||||
{
|
||||
private const HEADERS = [
|
||||
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
|
||||
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->connection = $database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror')
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int,skipped:int,total:int} */
|
||||
public function import(string $path, ?string $sourceFileName = null, bool $dryRun = false): array
|
||||
{
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
throw new InvalidArgumentException('余额文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$rows = $this->readRows($path);
|
||||
if ($rows === []) {
|
||||
throw new InvalidArgumentException('余额文件中没有可导入的数据行。');
|
||||
}
|
||||
|
||||
if (! Schema::connection($this->connection->getName())->hasTable(AlibabaAccountBalanceRepository::TABLE)) {
|
||||
throw new RuntimeException('阿里巴巴余额快照表尚未创建,请先执行数据库迁移。');
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$sourceFileName = mb_substr(basename($sourceFileName ?: $path), 0, 255);
|
||||
$sourceSha256 = hash_file('sha256', $path);
|
||||
$now = now();
|
||||
|
||||
$this->connection->transaction(function () use (
|
||||
$rows, $sourceFileName, $sourceSha256, $now, $dryRun, &$created, &$updated
|
||||
): void {
|
||||
foreach ($rows as $row) {
|
||||
$identity = [
|
||||
'balance_date' => $row['balance_date'],
|
||||
'store_name' => $row['store_name'],
|
||||
];
|
||||
$exists = $this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||||
->where($identity)
|
||||
->exists();
|
||||
$exists ? $updated++ : $created++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values = $row + [
|
||||
'source_file_name' => $sourceFileName,
|
||||
'source_sha256' => $sourceSha256,
|
||||
'imported_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||||
->where($identity)
|
||||
->update($values);
|
||||
} else {
|
||||
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||||
->insert($values + ['created_at' => $now]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'updated' => $updated, 'skipped' => 0, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function readRows(string $path): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$worksheet = $spreadsheet->getSheetByName('阿里巴巴账户') ?: $spreadsheet->getActiveSheet();
|
||||
$headerRow = $this->findHeaderRow($worksheet);
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
|
||||
for ($rowNumber = $headerRow + 1; $rowNumber <= $worksheet->getHighestDataRow(); $rowNumber++) {
|
||||
$values = [];
|
||||
foreach (range(1, count(self::HEADERS)) as $column) {
|
||||
$values[] = $worksheet->getCell([$column, $rowNumber])->getCalculatedValue();
|
||||
}
|
||||
|
||||
if (collect($values)->every(static fn (mixed $value): bool => $value === null || trim((string) $value) === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->date($values[0] ?? null, $rowNumber);
|
||||
$storeName = trim((string) ($values[1] ?? ''));
|
||||
if ($storeName === '') {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”不能为空。");
|
||||
}
|
||||
if (mb_strlen($storeName) > 255) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”超过 255 个字符。");
|
||||
}
|
||||
|
||||
$key = $date . "\0" . $storeName;
|
||||
if (isset($seen[$key])) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和店铺名称重复。");
|
||||
}
|
||||
$seen[$key] = $rowNumber;
|
||||
|
||||
$rows[] = [
|
||||
'balance_date' => $date,
|
||||
'store_name' => $storeName,
|
||||
'opening_balance' => $this->number($values[2] ?? null, $rowNumber, '期初余额'),
|
||||
'income_total' => $this->number($values[3] ?? null, $rowNumber, '本期收入'),
|
||||
'expense_total' => $this->number($values[4] ?? null, $rowNumber, '本期支出'),
|
||||
'closing_balance' => $this->number($values[5] ?? null, $rowNumber, '期末余额'),
|
||||
'system_closing_balance' => $this->number($values[6] ?? null, $rowNumber, '系统期末余额'),
|
||||
'balance_difference' => $this->number($values[7] ?? null, $rowNumber, '余额差异'),
|
||||
'difference_reason' => $this->text($values[8] ?? null),
|
||||
'verification_status' => $this->text($values[9] ?? null, 64, $rowNumber, '核对状态'),
|
||||
'notes' => $this->text($values[10] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function findHeaderRow(Worksheet $worksheet): int
|
||||
{
|
||||
$maximum = min($worksheet->getHighestDataRow(), 20);
|
||||
for ($row = 1; $row <= $maximum; $row++) {
|
||||
$headers = [];
|
||||
foreach (range(1, count(self::HEADERS)) as $column) {
|
||||
$headers[] = trim((string) $worksheet->getCell([$column, $row])->getValue());
|
||||
}
|
||||
|
||||
if ($headers === self::HEADERS) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('未找到阿里巴巴账户余额的 11 列标准表头。');
|
||||
}
|
||||
|
||||
private function date(mixed $value, int $rowNumber): string
|
||||
{
|
||||
try {
|
||||
$date = is_numeric($value)
|
||||
? CarbonImmutable::instance(ExcelDate::excelToDateTimeObject((float) $value))
|
||||
: CarbonImmutable::parse((string) $value);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。");
|
||||
}
|
||||
|
||||
if (! $date->isLastOfMonth()) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”必须是月末日期。");
|
||||
}
|
||||
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function number(mixed $value, int $rowNumber, string $label): ?float
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
|
||||
if (! is_numeric($normalized)) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效金额。");
|
||||
}
|
||||
|
||||
return round((float) $normalized, 6);
|
||||
}
|
||||
|
||||
private function text(mixed $value, ?int $limit = null, ?int $rowNumber = null, ?string $label = null): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($limit !== null && mb_strlen($text) > $limit) {
|
||||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”超过 {$limit} 个字符。");
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
|
||||
];
|
||||
$balances = $balances ?? collect();
|
||||
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'pinduoduo'], true);
|
||||
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true);
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
|
||||
@ -5,6 +5,7 @@ namespace Tests\Feature\Business;
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
|
||||
@ -124,6 +125,84 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
|
||||
->assertDontSeeText('该页面暂未配置数据表');
|
||||
}
|
||||
|
||||
public function testAlibabaAccountBalanceShowsImportedSnapshotAndFilters(): void
|
||||
{
|
||||
$this->allowMarketingAccountBalancePage();
|
||||
$repository = Mockery::mock(AlibabaAccountBalanceRepository::class);
|
||||
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginateMonthlyBalances')
|
||||
->once()
|
||||
->with('阿里测试店', '2026-06', 1, 25)
|
||||
->andReturn(['rows' => [[
|
||||
'date' => '2026-06-30',
|
||||
'store_name' => '阿里测试店',
|
||||
'opening_balance' => null,
|
||||
'income_total' => null,
|
||||
'expense_total' => null,
|
||||
'closing_balance' => 4142.96,
|
||||
'system_closing_balance' => null,
|
||||
'balance_difference' => null,
|
||||
'difference_reason' => '',
|
||||
'verification_status' => '',
|
||||
'notes' => '',
|
||||
]], 'total' => 1]);
|
||||
$repository->shouldReceive('storeOptions')->once()->andReturn(['阿里测试店']);
|
||||
$this->app->instance(AlibabaAccountBalanceRepository::class, $repository);
|
||||
|
||||
$parameters = [
|
||||
'section' => 'account-balance-data',
|
||||
'page' => 'marketing-account-balance',
|
||||
'tab' => 'taobao-wechat',
|
||||
'store_name' => '阿里测试店',
|
||||
'month' => '2026-06',
|
||||
];
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', $parameters))
|
||||
->assertOk()
|
||||
->assertSeeText('阿里测试店')
|
||||
->assertSeeText('4,142.96')
|
||||
->assertSeeText('期末余额')
|
||||
->assertSee(e(route('business.pages.export', $parameters)), false)
|
||||
->assertDontSeeText('该平台余额数据表尚未配置');
|
||||
}
|
||||
|
||||
public function testAlibabaAccountBalanceExportsCurrentFilters(): void
|
||||
{
|
||||
$this->allowMarketingAccountBalancePage();
|
||||
$repository = Mockery::mock(AlibabaAccountBalanceRepository::class);
|
||||
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginateMonthlyBalances')
|
||||
->once()
|
||||
->with('阿里测试店', '2026-06', 1, 5000)
|
||||
->andReturn(['rows' => [[
|
||||
'date' => '2026-06-30',
|
||||
'store_name' => '阿里测试店',
|
||||
'opening_balance' => null,
|
||||
'income_total' => null,
|
||||
'expense_total' => null,
|
||||
'closing_balance' => 4142.96,
|
||||
'system_closing_balance' => null,
|
||||
'balance_difference' => null,
|
||||
'difference_reason' => '',
|
||||
'verification_status' => '',
|
||||
'notes' => '',
|
||||
]], 'total' => 1]);
|
||||
$this->app->instance(AlibabaAccountBalanceRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.pages.export', [
|
||||
'section' => 'account-balance-data',
|
||||
'page' => 'marketing-account-balance',
|
||||
'tab' => 'taobao-wechat',
|
||||
'store_name' => '阿里测试店',
|
||||
'month' => '2026-06',
|
||||
]));
|
||||
|
||||
$response->assertOk()->assertDownload('alibaba_account_balances_2026-06.csv');
|
||||
$this->assertStringContainsString('阿里测试店', $response->streamedContent());
|
||||
$this->assertStringContainsString('4142.96', $response->streamedContent());
|
||||
}
|
||||
|
||||
public function testMarketingAccountBillsOnlyRenamesTheRequestedTabs(): void
|
||||
{
|
||||
$this->allowMarketingAccountBillsPage();
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||||
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AlibabaAccountBalanceRepositoryTest extends TestCase
|
||||
{
|
||||
private string $workbookPath;
|
||||
|
||||
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->createTable();
|
||||
$this->workbookPath = sys_get_temp_dir() . '/alibaba-account-balance-' . uniqid('', true) . '.xlsx';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->workbookPath) && is_file($this->workbookPath)) {
|
||||
unlink($this->workbookPath);
|
||||
}
|
||||
|
||||
DB::purge('business_mirror');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testItImportsUpdatesFiltersAndPaginatesBalanceSnapshots(): void
|
||||
{
|
||||
$this->writeWorkbook([
|
||||
['2026-06-30', '阿里店甲', null, null, null, 4142.96, null, null, null, null, null],
|
||||
['2026-06-30', '阿里店乙', 100, 50, 20, 130, 130, 0, '', '已核对', '测试'],
|
||||
]);
|
||||
|
||||
$importer = app(AlibabaAccountBalanceWorkbookImporter::class);
|
||||
$first = $importer->import($this->workbookPath, '阿里巴巴余额.xlsx');
|
||||
$second = $importer->import($this->workbookPath, '阿里巴巴余额.xlsx');
|
||||
|
||||
$this->assertSame(['created' => 2, 'updated' => 0, 'skipped' => 0, 'total' => 2], $first);
|
||||
$this->assertSame(['created' => 0, 'updated' => 2, 'skipped' => 0, 'total' => 2], $second);
|
||||
$this->assertSame(2, DB::connection('business_mirror')->table(AlibabaAccountBalanceRepository::TABLE)->count());
|
||||
|
||||
$repository = app(AlibabaAccountBalanceRepository::class);
|
||||
$result = $repository->paginateMonthlyBalances('阿里店甲', '2026-06', 1, 25);
|
||||
|
||||
$this->assertTrue($repository->usesBusinessMirror());
|
||||
$this->assertSame(['阿里店乙', '阿里店甲'], $repository->storeOptions());
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('2026-06-30', $result['rows'][0]['date']);
|
||||
$this->assertSame('阿里店甲', $result['rows'][0]['store_name']);
|
||||
$this->assertSame(4142.96, $result['rows'][0]['closing_balance']);
|
||||
$this->assertNull($result['rows'][0]['opening_balance']);
|
||||
}
|
||||
|
||||
public function testItRejectsNonMonthEndDatesBeforeWriting(): void
|
||||
{
|
||||
$this->writeWorkbook([
|
||||
['2026-06-29', '阿里店甲', null, null, null, 10, null, null, null, null, null],
|
||||
]);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('必须是月末日期');
|
||||
|
||||
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
|
||||
}
|
||||
|
||||
/** @param array<int,array<int,mixed>> $rows */
|
||||
private function writeWorkbook(array $rows): void
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('阿里巴巴账户');
|
||||
$sheet->fromArray([[
|
||||
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
|
||||
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
|
||||
], ...$rows]);
|
||||
(new Xlsx($spreadsheet))->save($this->workbookPath);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
private function createTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create(AlibabaAccountBalanceRepository::TABLE, function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('balance_date');
|
||||
$table->string('store_name');
|
||||
$table->decimal('opening_balance', 20, 6)->nullable();
|
||||
$table->decimal('income_total', 20, 6)->nullable();
|
||||
$table->decimal('expense_total', 20, 6)->nullable();
|
||||
$table->decimal('closing_balance', 20, 6)->nullable();
|
||||
$table->decimal('system_closing_balance', 20, 6)->nullable();
|
||||
$table->decimal('balance_difference', 20, 6)->nullable();
|
||||
$table->text('difference_reason')->nullable();
|
||||
$table->string('verification_status')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->string('source_file_name');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->dateTime('imported_at');
|
||||
$table->timestamps();
|
||||
$table->unique(['balance_date', 'store_name']);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user