- 新增共享镜像表 business_internal_flow_records 与整份替换导入命令 internal-flow:import - 按 Excel 表头展示抬头、标签、预计9月销量、开票、所需进项成本、收票、剩余缺口与开票比例 - 复用公司基础信息的字段查询、表头排序、筛选字段与导出 Excel 交互 - 新增导入与页面测试,覆盖合计行跳过、重导入替换、筛选排序与未导入空状态
327 lines
11 KiB
PHP
327 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|