CaiWuXiTong/app/Business/PlantingDetailImporter.php
qiuxiang 304bb3dc8e 种菜明细表:ID 列放宽为 text 支持多行 ID
源表「ID」列存在一个单元格多行放多个 ID 的行(导入时报 Data too long for column external_id)。新增迁移把 external_id 放宽为 text,导入器按 500 字符保留原文,并补用例覆盖多行 ID。
2026-09-30 09:55:51 +08:00

300 lines
10 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 App\Business;
use App\Models\Business\PlantingDetail;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
/**
* 种菜明细表导入(xlsx / xls / csv)。
*
* 源表头与 planting_details 列一一对应;按表头名匹配,列顺序变化不影响。
* 日期兼容 Excel 序列号与常见字符串;金额按 4 位小数保存。
*/
class PlantingDetailImporter
{
/** 源表头 → 数据库列 */
private const COLUMNS = [
'日期' => 'bill_date',
'订单编号' => 'order_no',
'财务售价' => 'finance_selling_price',
'财务运费' => 'finance_freight',
'财务佣金' => 'finance_commission',
'京喜差价' => 'jingxi_price_diff',
'扣款' => 'deduction',
'财务空包费' => 'finance_empty_package_fee',
'财务打款金额' => 'finance_payment_amount',
'笔数' => 'pieces',
'备注' => 'remark',
'正确店铺' => 'store',
'负责人' => 'owner',
'账户' => 'account',
'类型' => 'type',
'创建人' => 'creator',
'标签' => 'tag',
'所属利润中心' => 'profit_center',
'负责平台' => 'responsible_platform',
'ID' => 'external_id',
];
private const MONEY_COLUMNS = [
'finance_selling_price', 'finance_freight', 'finance_commission',
'jingxi_price_diff', 'deduction', 'finance_empty_package_fee', 'finance_payment_amount',
];
private const CHUNK = 1000;
/**
* @return array{rows:int,inserted:int,skipped:int,selling_price:string,payment_amount:string,date_min:?string,date_max:?string}
*/
public function import(string $path, int $companyId = 1, bool $replace = false, ?int $userId = null, string $source = 'planting-detail-import'): array
{
if (! is_file($path)) {
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
}
$grid = $this->read($path);
if ($grid === []) {
throw new InvalidArgumentException('文件里没有可读的数据行。');
}
$headerIndex = $this->locateHeader($grid);
if ($headerIndex === null) {
throw new InvalidArgumentException('没找到表头:至少要有「日期」「订单编号」「财务打款金额」。');
}
$map = $this->mapHeader($grid[$headerIndex]);
if (! isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
throw new InvalidArgumentException('表头缺少「日期」或「订单编号」或「财务打款金额」。');
}
$now = now();
$rows = [];
$skipped = 0;
$sellingPrice = 0.0;
$paymentAmount = 0.0;
$dateMin = null;
$dateMax = null;
$inserted = 0;
$connection = DB::connection();
if ($replace) {
PlantingDetail::query()->where('company_id', $companyId)->forceDelete();
}
$flush = function () use (&$rows, &$inserted, $connection): void {
if ($rows !== []) {
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
$connection->table('planting_details')->insert($chunk);
$inserted += count($chunk);
}
$rows = [];
}
};
foreach (array_slice($grid, $headerIndex + 1) as $gridRow) {
if (! $this->hasContent($gridRow)) {
continue;
}
$date = $this->toDate($this->cell($gridRow, $map, 'bill_date'));
$orderNo = $this->toText($this->cell($gridRow, $map, 'order_no'));
if ($date === null || $orderNo === null) {
$skipped++;
continue;
}
$record = [
'company_id' => $companyId,
'bill_date' => $date,
'order_no' => mb_substr($orderNo, 0, 100),
'pieces' => (int) ($this->toNumber($this->cell($gridRow, $map, 'pieces')) ?? 0),
'remark' => $this->toText($this->cell($gridRow, $map, 'remark')),
'store' => $this->toText($this->cell($gridRow, $map, 'store')),
'owner' => $this->toText($this->cell($gridRow, $map, 'owner')),
'account' => $this->toText($this->cell($gridRow, $map, 'account')),
'type' => $this->toText($this->cell($gridRow, $map, 'type')),
'creator' => $this->toText($this->cell($gridRow, $map, 'creator')),
'tag' => $this->toText($this->cell($gridRow, $map, 'tag')),
'profit_center' => $this->toText($this->cell($gridRow, $map, 'profit_center')),
'responsible_platform' => $this->toText($this->cell($gridRow, $map, 'responsible_platform')),
// 源表存在一个单元格放多个 ID(换行分隔)的情况,这里保留到 500 字符。
'external_id' => $this->toText($this->cell($gridRow, $map, 'external_id'), 500),
'created_from' => $source,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
foreach (self::MONEY_COLUMNS as $column) {
$value = $this->toNumber($this->cell($gridRow, $map, $column)) ?? 0.0;
$record[$column] = number_format($value, 4, '.', '');
}
$sellingPrice += (float) $record['finance_selling_price'];
$paymentAmount += (float) $record['finance_payment_amount'];
$dateMin = $dateMin === null || $date < $dateMin ? $date : $dateMin;
$dateMax = $dateMax === null || $date > $dateMax ? $date : $dateMax;
$rows[] = $record;
if (count($rows) >= self::CHUNK * 4) {
$flush();
}
}
$flush();
return [
'rows' => $inserted + $skipped,
'inserted' => $inserted,
'skipped' => $skipped,
'selling_price' => number_format($sellingPrice, 4, '.', ''),
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
'date_min' => $dateMin,
'date_max' => $dateMax,
];
}
/** @return array<int,array<int,mixed>> */
private function read(string $path): array
{
$extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension === 'csv') {
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('无法读取文件。');
}
$grid = [];
$first = true;
while (($cells = fgetcsv($handle)) !== false) {
if ($first && isset($cells[0])) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $cells[0]);
$first = false;
}
$grid[] = $cells;
}
fclose($handle);
return $grid;
}
if (! in_array($extension, ['xlsx', 'xls'], true)) {
throw new InvalidArgumentException('只支持 xlsx、xls、csv。');
}
try {
$reader = IOFactory::createReader($extension === 'xlsx' ? 'Xlsx' : 'Xls');
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
} catch (Throwable $exception) {
throw new InvalidArgumentException('读不出这个表格:' . $exception->getMessage());
}
try {
return $spreadsheet->getSheet(0)->toArray(null, true, false, false);
} finally {
$spreadsheet->disconnectWorksheets();
}
}
/** @param array<int,array<int,mixed>> $grid */
private function locateHeader(array $grid): ?int
{
foreach ($grid as $index => $row) {
$map = $this->mapHeader($row);
if (isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
return $index;
}
}
return null;
}
/** @param array<int,mixed> $row @return array<string,int> */
private function mapHeader(array $row): array
{
$map = [];
foreach ($row as $index => $value) {
$label = trim((string) (is_scalar($value) ? $value : ''));
if ($label === '') {
continue;
}
$column = self::COLUMNS[$label] ?? null;
if ($column !== null && ! isset($map[$column])) {
$map[$column] = (int) $index;
}
}
return $map;
}
/** @param array<int,mixed> $row @param array<string,int> $map */
private function cell(array $row, array $map, string $column): mixed
{
return isset($map[$column]) ? ($row[$map[$column]] ?? null) : null;
}
private function hasContent(array $row): bool
{
foreach ($row as $value) {
if ($value !== null && trim((string) (is_scalar($value) ? $value : '')) !== '') {
return true;
}
}
return false;
}
private function toDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 60000) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$text = trim((string) $value);
$timestamp = strtotime($text);
return $timestamp === false ? null : date('Y-m-d', $timestamp);
}
private function toText(mixed $value, int $limit = 191): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function toNumber(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
return is_numeric($text) ? (float) $text : null;
}
}