源表「ID」列存在一个单元格多行放多个 ID 的行(导入时报 Data too long for column external_id)。新增迁移把 external_id 放宽为 text,导入器按 500 字符保留原文,并补用例覆盖多行 ID。
300 lines
10 KiB
PHP
300 lines
10 KiB
PHP
<?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;
|
||
}
|
||
}
|