CaiWuXiTong/modules/EcommerceFinance/Services/PinduoduoPlatformBalanceImporter.php
qiuxiang 55f3e7d10f feat: 拼多多账户按平台余额滚动计算期初期末
以6月平台实际期末余额为基准,按财务系统每月收支滚动计算期初余额;期末余额=期初余额+收入合计-支出合计。
2026-11-06 11:37:23 +08:00

201 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}
}