CaiWuXiTong/modules/EcommerceFinance/Services/VipshopMpOpeningBalanceImporter.php
qiuxiang 378cf52fa7 feat: 增加唯品会MP账户月度期初余额
从唯品会后台账单余额表导入店铺月末余额,7月按期初加收入减支出滚动,后续月份自动承接上月期末;新增余额快照表、导入命令和回归测试。
2026-11-06 17:57:53 +08:00

161 lines
6.2 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 PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class VipshopMpOpeningBalanceImporter
{
public const TABLE = 'vipshop_mp_opening_balance_snapshots';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param list<string> $paths
* @param array<string,string> $storeMap
* @return array{status:string,period_end:string,store_count:int,balance_total:string,row_count:int}
*/
public function import(
array $paths,
string $periodEnd,
array $storeMap,
bool $dryRun = false,
): array {
$periodEnd = $this->periodEnd($periodEnd);
if (! Schema::connection($this->connection->getName())->hasTable(self::TABLE)) {
throw new RuntimeException('唯品会MP期初余额表尚未完成迁移。');
}
$balances = [];
$sourceFiles = [];
$sourceHashes = [];
$rowCount = 0;
foreach ($paths as $path) {
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new InvalidArgumentException('唯品会MP余额文件不存在或不可读。');
}
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new InvalidArgumentException('唯品会MP余额只支持 XLSX 文件。');
}
$sourceName = basename($realPath);
$sourceFiles[] = $sourceName;
$sourceHashes[] = hash_file('sha256', $realPath);
$spreadsheet = IOFactory::load($realPath);
try {
$sheet = $spreadsheet->getActiveSheet();
$matrix = $sheet->toArray(null, true, true, false);
} finally {
$spreadsheet->disconnectWorksheets();
}
if ($matrix === []) {
continue;
}
$headers = array_map(
static fn (mixed $value): string => trim((string) $value),
array_shift($matrix),
);
$indexes = array_flip($headers);
foreach (['店铺名称', '待结算待付款(F2=F-F1)'] as $required) {
if (! array_key_exists($required, $indexes)) {
throw new InvalidArgumentException("{$sourceName} 缺少字段:{$required}");
}
}
foreach ($matrix as $offset => $values) {
if (! is_array($values) || ! array_filter($values, static fn (mixed $value): bool => $value !== null && $value !== '')) {
continue;
}
$rawStore = trim((string) ($values[$indexes['店铺名称']] ?? ''));
$store = trim((string) ($storeMap[$rawStore] ?? ''));
if ($store === '') {
throw new InvalidArgumentException(
"{$sourceName} 第 " . ($offset + 2) . " 行的店铺未配置财务系统名称:{$rawStore}",
);
}
$balance = $this->decimal(
$values[$indexes['待结算待付款(F2=F-F1)']] ?? null,
$offset + 2,
);
$balances[$store] = bcadd($balances[$store] ?? '0.000000', $balance, 6);
$rowCount++;
}
}
if ($balances === []) {
throw new InvalidArgumentException('唯品会MP余额文件中没有可导入的数据。');
}
if (! $dryRun) {
$now = now();
$sourceFilenames = mb_substr(implode(',', array_unique($sourceFiles)), 0, 255);
$sourceSha = hash('sha256', implode("\0", $sourceHashes));
$this->connection->transaction(function () use ($balances, $periodEnd, $sourceFilenames, $sourceSha, $now): void {
$this->connection->table(self::TABLE)->where('period_end', $periodEnd)->delete();
$this->connection->table(self::TABLE)->insert(array_map(
static fn (string $store, string $balance): array => [
'period_end' => $periodEnd,
'store_name' => mb_substr($store, 0, 255),
'balance' => $balance,
'source_filenames' => $sourceFilenames,
'source_sha256' => $sourceSha,
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
array_keys($balances),
array_values($balances),
));
});
}
$total = '0.000000';
foreach ($balances as $balance) {
$total = bcadd($total, $balance, 6);
}
return [
'status' => $dryRun ? 'validated' : 'succeeded',
'period_end' => $periodEnd,
'store_count' => count($balances),
'balance_total' => $total,
'row_count' => $rowCount,
];
}
private 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('唯品会MP余额截止日期必须是有效月末,格式 YYYY-MM-DD。');
}
return $value;
}
private function decimal(mixed $value, int $rowNumber): string
{
$raw = trim(str_replace([',', ',', '¥', '¥'], '', (string) $value));
if ($raw === '' || ! is_numeric($raw)) {
throw new InvalidArgumentException("第 {$rowNumber} 行的待结算待付款不是有效金额。");
}
return number_format((float) $raw, 6, '.', '');
}
}