CaiWuXiTong/modules/EcommerceFinance/Services/InternalFlowWorkbookImporter.php
WuYongJie 8419d4097c feat: 内部流转报表导入工作簿数据并支持查询导出
- 新增共享镜像表 business_internal_flow_records 与整份替换导入命令 internal-flow:import
- 按 Excel 表头展示抬头、标签、预计9月销量、开票、所需进项成本、收票、剩余缺口与开票比例
- 复用公司基础信息的字段查询、表头排序、筛选字段与导出 Excel 交互
- 新增导入与页面测试,覆盖合计行跳过、重导入替换、筛选排序与未导入空状态
2026-10-06 22:08:08 +08:00

327 lines
11 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
/**
* 「一般纳税人内部流转」工作簿导入器。
*
* 源表使用三行合并表头(抬头 / 标签 / 预计9月销量 / 开票 / 所需进项成本 /
* 收票 / 剩余缺口 / 开票比例),导入时按表头标签匹配列,不依赖列位置;
* 合计行与空行不进入明细表。
*/
class InternalFlowWorkbookImporter
{
public const TABLE = 'business_internal_flow_records';
public const SHEET_TITLE = '一般纳税人内部流转';
/** 页面表头契约:与 Excel 表头顺序一致。 */
public const COLUMNS = [
['field' => 'company_name', 'label' => '抬头', 'type' => 'text'],
['field' => 'tag', 'label' => '标签', 'type' => 'text'],
['field' => 'projected_sales', 'label' => '预计9月销量', 'type' => 'amount'],
['field' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'type' => 'amount'],
['field' => 'internal_invoiced', 'label' => '开票-内部开票', 'type' => 'amount'],
['field' => 'required_input_cost', 'label' => '所需进项成本', 'type' => 'amount'],
['field' => 'receipt_from_supplier', 'label' => '收票-供应商', 'type' => 'amount'],
['field' => 'receipt_from_suofeiya', 'label' => '收票-索菲亚', 'type' => 'amount'],
['field' => 'receipt_from_gujia', 'label' => '收票-顾家', 'type' => 'amount'],
['field' => 'internal_transfer_1', 'label' => '收票-内部流转(来源)1', 'type' => 'amount'],
['field' => 'internal_transfer_2', 'label' => '收票-内部流转(来源)2', 'type' => 'amount'],
['field' => 'internal_transfer_3', 'label' => '收票-内部流转(来源)3', 'type' => 'amount'],
['field' => 'receipt_total', 'label' => '收票-合计', 'type' => 'amount'],
['field' => 'remaining_gap', 'label' => '剩余缺口', 'type' => 'amount'],
['field' => 'invoicing_ratio', 'label' => '开票比例', 'type' => 'text'],
];
private const HEADER_ROWS = 3;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{imported:int,skipped:int,source_file:string}
*/
public function import(string $path): array
{
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new RuntimeException('内部流转工作簿不存在或不可读:' . $path);
}
if (! Schema::connection($this->connection->getName())->hasTable(self::TABLE)) {
throw new RuntimeException('内部流转数据表尚未创建,请先执行数据库迁移。');
}
[$rows, $skipped] = $this->readRows($realPath);
if ($rows === []) {
throw new RuntimeException('表格中没有可导入的数据行,请检查“' . self::SHEET_TITLE . '”工作表。');
}
$now = Carbon::now();
$sourceFile = basename($realPath);
$this->connection->transaction(function () use ($rows, $now, $sourceFile): void {
// 该报表是一份整体替换的月度数据集,重新导入时先清空上一版。
$this->connection->table(self::TABLE)->delete();
foreach (array_chunk($rows, 200) as $chunk) {
$this->connection->table(self::TABLE)->insert(array_map(
static fn (array $row): array => $row + [
'source_file' => $sourceFile,
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
$chunk,
));
}
});
return [
'imported' => count($rows),
'skipped' => $skipped,
'source_file' => $sourceFile,
];
}
/**
* @return array{0: list<array<string,mixed>>, 1: int}
*/
private function readRows(string $path): array
{
$spreadsheet = IOFactory::load($path);
$sheet = $this->resolveSheet($spreadsheet->getAllSheets());
$grid = $sheet->toArray(null, true, false, false);
$this->expandMergedCells($grid, $sheet);
$headerRow = $this->resolveHeaderRow($grid);
$columnMap = $this->resolveColumnMap($grid, $headerRow);
$missing = array_values(array_diff(
array_column(self::COLUMNS, 'field'),
array_values($columnMap),
));
if ($missing !== []) {
$labels = implode('、', array_map(
static fn (string $field): string => (string) (collect(self::COLUMNS)->firstWhere('field', $field)['label'] ?? $field),
$missing,
));
throw new RuntimeException('内部流转表头缺少列:' . $labels . '。');
}
$types = array_column(self::COLUMNS, 'type', 'field');
$rows = [];
$skipped = 0;
$sortOrder = 0;
foreach (array_slice($grid, $headerRow + self::HEADER_ROWS) as $line) {
$values = [];
foreach ($columnMap as $columnIndex => $field) {
$values[$field] = $this->normalizeValue($line[$columnIndex] ?? null, $types[$field] ?? 'text');
}
$title = trim((string) ($values['company_name'] ?? ''));
if ($title === '' || $title === '合计') {
if ($title !== '' || collect($values)->filter(static fn ($value): bool => $value !== null && $value !== '')->isNotEmpty()) {
$skipped++;
}
continue;
}
$values['company_name'] = $title;
$values['sort_order'] = ++$sortOrder;
$rows[] = $values;
}
return [$rows, $skipped];
}
/**
* @param list<Worksheet> $sheets
*/
private function resolveSheet(array $sheets): Worksheet
{
foreach ($sheets as $sheet) {
if ($sheet->getTitle() === self::SHEET_TITLE) {
return $sheet;
}
}
throw new RuntimeException('未找到“' . self::SHEET_TITLE . '”工作表。');
}
/**
* @param list<list<mixed>> $grid
*/
private function expandMergedCells(array &$grid, Worksheet $sheet): void
{
foreach ($sheet->getMergeCells() as $range) {
try {
[[$startColumn, $startRow], [$endColumn, $endRow]] = Coordinate::rangeBoundaries($range);
} catch (\Throwable) {
continue;
}
$value = $grid[$startRow - 1][$startColumn - 1] ?? null;
for ($row = $startRow; $row <= $endRow; $row++) {
for ($column = $startColumn; $column <= $endColumn; $column++) {
$grid[$row - 1][$column - 1] = $value;
}
}
}
}
/**
* @param list<list<mixed>> $grid
*/
private function resolveHeaderRow(array $grid): int
{
foreach (array_slice($grid, 0, 10, true) as $index => $line) {
if (collect($line)->contains(fn ($value): bool => $this->normalizeHeader($value) === '抬头')) {
return (int) $index;
}
}
throw new RuntimeException('未找到以“抬头”开头的工作簿表头。');
}
/**
* 按三行表头的标签路径匹配目标字段,允许列位置变化。
*
* @param list<list<mixed>> $grid
* @return array<int,string> 列下标 => 字段名
*/
private function resolveColumnMap(array $grid, int $headerRow): array
{
$map = [];
$columnCount = 0;
foreach (array_slice($grid, $headerRow, self::HEADER_ROWS) as $line) {
$columnCount = max($columnCount, count($line));
}
for ($column = 0; $column < $columnCount; $column++) {
$path = [];
for ($row = $headerRow; $row < $headerRow + self::HEADER_ROWS; $row++) {
$value = $this->normalizeHeader($grid[$row][$column] ?? null);
if ($value !== '') {
$path[] = $value;
}
}
$field = $this->matchField($path);
if ($field !== null) {
$map[$column] = $field;
}
}
return $map;
}
/**
* @param list<string> $path
*/
private function matchField(array $path): ?string
{
if ($path === []) {
return null;
}
$leaf = (string) end($path);
return match (true) {
$leaf === '抬头' => 'company_name',
$leaf === '标签' => 'tag',
preg_match('/^预计\d+月销量$/u', $leaf) === 1 => 'projected_sales',
$leaf === '给买家开票' => 'invoiced_to_buyer',
$leaf === '内部开票' => 'internal_invoiced',
$leaf === '所需进项成本' => 'required_input_cost',
$leaf === '供应商' => 'receipt_from_supplier',
$leaf === '索菲亚' => 'receipt_from_suofeiya',
$leaf === '顾家' => 'receipt_from_gujia',
$leaf === '合计' => 'receipt_total',
$leaf === '剩余缺口' => 'remaining_gap',
$leaf === '开票比例' => 'invoicing_ratio',
in_array($leaf, ['1', '2', '3'], true) && in_array('内部流转(来源)', $path, true) => 'internal_transfer_' . $leaf,
default => null,
};
}
private function normalizeHeader(mixed $value): string
{
if ($value === null || is_bool($value) || is_array($value)) {
return '';
}
$text = trim((string) $value);
$text = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], $text);
return $text;
}
private function normalizeValue(mixed $value, string $type): ?string
{
if ($value === null) {
return null;
}
if ($type === 'text') {
if (is_float($value) && floor($value) === $value) {
return (string) (int) $value;
}
$text = trim((string) $value);
return $text === '' ? null : $text;
}
if (is_int($value) || is_float($value)) {
return $this->decimal($value);
}
$text = trim((string) $value);
if ($text === '' || in_array($text, ['/', '—', '-', 'N/A'], true)) {
return null;
}
$normalized = str_replace([',', ','], '', $text);
return is_numeric($normalized) ? $this->decimal((float) $normalized) : null;
}
private function decimal(float $value): string
{
$formatted = rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.');
return $formatted === '' || $formatted === '-' ? '0' : $formatted;
}
}