201 lines
7.5 KiB
PHP
201 lines
7.5 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use DateTimeImmutable;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\DatabaseManager;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use InvalidArgumentException;
|
||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use RuntimeException;
|
||
|
||
class PinduoduoPlatformBalanceImporter
|
||
{
|
||
public const TABLE = 'pdd_platform_balance_snapshots';
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(DatabaseManager $databases)
|
||
{
|
||
$this->connection = $databases->connection(
|
||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @return array{period_end:string,total:int,balance_total:string,replaced:int,source_filename:string}
|
||
*/
|
||
public function import(string $path, string $periodEnd): array
|
||
{
|
||
$realPath = realpath($path);
|
||
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
|
||
throw new InvalidArgumentException('拼多多平台余额文件不存在或不可读取。');
|
||
}
|
||
|
||
$period = $this->periodEnd($periodEnd);
|
||
if (! Schema::connection($this->connection->getName())->hasTable(self::TABLE)) {
|
||
throw new RuntimeException('拼多多平台余额表尚未完成迁移。');
|
||
}
|
||
|
||
$rows = $this->normalizeRows($this->matrix($realPath));
|
||
if ($rows === []) {
|
||
throw new InvalidArgumentException('拼多多平台余额文件中没有可导入的店铺数据。');
|
||
}
|
||
|
||
$sourceName = mb_substr(basename($realPath), 0, 255);
|
||
$sourceSha = hash_file('sha256', $realPath);
|
||
$now = now();
|
||
$replaced = $this->connection->table(self::TABLE)
|
||
->where('period_end', $period)
|
||
->count();
|
||
|
||
$this->connection->transaction(function () use ($rows, $period, $sourceName, $sourceSha, $now): void {
|
||
$this->connection->table(self::TABLE)->where('period_end', $period)->delete();
|
||
foreach (array_chunk($rows, 500) as $chunk) {
|
||
$this->connection->table(self::TABLE)->insert(array_map(
|
||
static fn (array $row): array => $row + [
|
||
'period_end' => $period,
|
||
'source_filename' => $sourceName,
|
||
'source_sha256' => $sourceSha,
|
||
'imported_at' => $now,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
],
|
||
$chunk,
|
||
));
|
||
}
|
||
});
|
||
|
||
$balanceTotal = '0.00';
|
||
foreach ($rows as $row) {
|
||
$balanceTotal = bcadd($balanceTotal, $row['balance'], 2);
|
||
}
|
||
|
||
return [
|
||
'period_end' => $period,
|
||
'total' => count($rows),
|
||
'balance_total' => $balanceTotal,
|
||
'replaced' => $replaced,
|
||
'source_filename' => $sourceName,
|
||
];
|
||
}
|
||
|
||
public function periodEnd(string $value): string
|
||
{
|
||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
|
||
if ($date === false || $date->format('Y-m-d') !== $value || $date->format('Y-m-t') !== $value) {
|
||
throw new InvalidArgumentException('平台余额截止日期必须是有效月末,格式 YYYY-MM-DD。');
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/** @return list<array{store_name:string,normalized_store_name:string,account_name:?string,balance:string}> */
|
||
private function normalizeRows(array $matrix): array
|
||
{
|
||
$header = null;
|
||
foreach (array_slice($matrix, 0, 20, true) as $rowNumber => $row) {
|
||
$labels = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||
if (in_array('店铺', $labels, true)
|
||
&& in_array('平台实际期末余额', $labels, true)) {
|
||
$header = ['row' => $rowNumber, 'labels' => $labels];
|
||
break;
|
||
}
|
||
}
|
||
if ($header === null) {
|
||
throw new InvalidArgumentException('文件中未找到“店铺”和“平台实际期末余额”表头。');
|
||
}
|
||
|
||
$indexes = array_flip($header['labels']);
|
||
$storeIndex = $indexes['店铺'];
|
||
$balanceIndex = $indexes['平台实际期末余额'];
|
||
$platformIndex = $indexes['平台'] ?? null;
|
||
$accountIndex = $indexes['来源'] ?? null;
|
||
$rows = [];
|
||
|
||
foreach ($matrix as $rowNumber => $row) {
|
||
if ($rowNumber <= $header['row']) {
|
||
continue;
|
||
}
|
||
$storeName = trim((string) ($row[$storeIndex] ?? ''));
|
||
if ($storeName === '') {
|
||
continue;
|
||
}
|
||
if ($platformIndex !== null && trim((string) ($row[$platformIndex] ?? '')) !== '拼多多') {
|
||
continue;
|
||
}
|
||
|
||
$balance = $this->amount($row[$balanceIndex] ?? null, $storeName);
|
||
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
|
||
if ($normalized === '') {
|
||
throw new InvalidArgumentException('存在无法识别的拼多多店铺名称。');
|
||
}
|
||
if (isset($rows[$normalized]) && bccomp($rows[$normalized]['balance'], $balance, 2) !== 0) {
|
||
throw new InvalidArgumentException("店铺 {$storeName} 存在冲突的平台期末余额。");
|
||
}
|
||
$rows[$normalized] = [
|
||
'store_name' => mb_substr($storeName, 0, 255),
|
||
'normalized_store_name' => mb_substr($normalized, 0, 255),
|
||
'account_name' => $accountIndex === null
|
||
? null
|
||
: mb_substr(trim((string) ($row[$accountIndex] ?? '')), 0, 255),
|
||
'balance' => $balance,
|
||
];
|
||
}
|
||
|
||
return array_values($rows);
|
||
}
|
||
|
||
private function amount(mixed $value, string $storeName): string
|
||
{
|
||
$raw = trim(str_replace([',', ',', '¥', '¥'], '', (string) $value));
|
||
if ($raw === '' || ! is_numeric($raw)) {
|
||
throw new InvalidArgumentException("店铺 {$storeName} 的平台实际期末余额无效。");
|
||
}
|
||
|
||
return number_format((float) $raw, 2, '.', '');
|
||
}
|
||
|
||
/** @return list<list<mixed>> */
|
||
private function matrix(string $path): array
|
||
{
|
||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||
if ($extension === 'csv') {
|
||
$handle = fopen($path, 'rb');
|
||
if ($handle === false) {
|
||
throw new InvalidArgumentException('无法读取拼多多平台余额 CSV。');
|
||
}
|
||
$rows = [];
|
||
try {
|
||
$first = fgets($handle);
|
||
if ($first !== false && str_starts_with($first, "\xEF\xBB\xBF")) {
|
||
rewind($handle);
|
||
fread($handle, 3);
|
||
} else {
|
||
rewind($handle);
|
||
}
|
||
while (($row = fgetcsv($handle)) !== false) {
|
||
$rows[] = $row;
|
||
}
|
||
} finally {
|
||
fclose($handle);
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
if (! in_array($extension, ['xlsx', 'xls'], true)) {
|
||
throw new InvalidArgumentException('拼多多平台余额仅支持 XLSX、XLS 或 CSV 文件。');
|
||
}
|
||
|
||
$spreadsheet = IOFactory::load($path);
|
||
try {
|
||
return $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||
} finally {
|
||
$spreadsheet->disconnectWorksheets();
|
||
}
|
||
}
|
||
}
|