CaiWuXiTong/modules/EcommerceFinance/Services/DouyinOpeningBalanceImporter.php
qiuxiang f3ad2b7a40 feat: 增加抖音账户月度期初余额
以抖音6表2026-06-30期末余额为7月基准,后续月份按期初加收入减支出滚动;新增基准导入命令、快照表、页面统计卡和回归测试。
2026-11-06 16:25:01 +08:00

195 lines
7.3 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 DouyinOpeningBalanceImporter
{
public const TABLE = 'douyin_opening_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,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;
$rows = [];
foreach ($matrix as $rowNumber => $row) {
if ($rowNumber <= $header['row']) {
continue;
}
$platform = $platformIndex === null ? '抖音' : trim((string) ($row[$platformIndex] ?? ''));
if (! in_array($platform, ['抖音', '头条放心购'], true)) {
continue;
}
$storeName = trim((string) ($row[$storeIndex] ?? ''));
if ($storeName === '') {
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),
'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();
}
}
}