Files
Leantime/app/Domain/Bom/Services/Bom.php

889 lines
30 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 Leantime\Domain\Bom\Services;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Files\FileManager;
use Leantime\Domain\Bom\Permissions\BomPermissions;
use Leantime\Domain\Bom\Repositories\Bom as BomRepository;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class Bom extends BaseService
{
/** BOM 明细的固定列Excel 导入/表头展示的基础列) */
public const FIXED_COLUMNS = [
'seq' => '序号',
'partNo' => '零件编号',
'partName' => '零件名称',
'partDrawingNo' => '零件图号',
'material' => '材质',
'spec' => '零件规格',
'qtyPerUnit' => '单件用量',
'unit' => '单位',
'process' => '工序',
'remark' => '备注',
];
/** Teable/Excel 常见中文表头 -> 固定列 key 的别名映射(导入时自动对齐) */
private const FIELD_ALIASES = [
'零件代号' => 'partNo',
'零件编号' => 'partNo',
'零件名称' => 'partName',
'描述' => 'partName',
'零件图号' => 'partDrawingNo',
'材质' => 'material',
'材料' => 'material',
'零件规格' => 'spec',
'规格' => 'spec',
'单件用量' => 'qtyPerUnit',
'数量' => 'qtyPerUnit',
'单位' => 'unit',
'单位数量' => 'unit',
'工序' => 'process',
'备注' => 'remark',
];
public function __construct(
protected BomRepository $repo,
protected Teable $teable,
protected Excel $excel,
protected FileManager $fileManager,
) {}
/**
* 鉴权主数据是全局资源projectId=0时按公司全局角色评估
* 否则(历史项目私有数据 projectId>0按项目角色评估。
*/
private function authorizeMaster(string $permission, ?int $projectId): void
{
$isGlobal = $projectId === null || $projectId <= 0;
$this->authorize(
$permission,
$isGlobal ? null : $projectId,
$isGlobal ? true : null,
);
}
// ---- BOM 头 ----
public function getBoms(int $projectId): array
{
return $this->repo->getBoms($projectId);
}
/**
* 按类型列出全局主数据bom/process/tooling
*/
public function getMasters(string $type, int $projectId = 0): array
{
return $this->repo->getMasters($type, $projectId);
}
public function getBom(int $id): array|false
{
return $this->repo->getBom($id);
}
/**
* 读取 BOM 完整详情鉴权后含动态列、明细、Teable 源。
*/
public function getBomDetail(int $id): array|false
{
$bom = $this->repo->getBom($id);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
$items = $this->repo->getItems($id);
// 展开 extra JSON 到行数据;同时把附件/数组等复杂值格式化为友好文本(原始 JSON 保留在 _raw_$key
foreach ($items as &$item) {
$extra = json_decode($item['extra'] ?? '', true);
if (is_array($extra)) {
$item = array_merge($item, $extra);
}
unset($item['extra']);
}
unset($item);
$columns = $this->repo->getColumns($id);
// 计算每列是否有数据(用于"隐藏空列"与列设置面板)
$filledKeys = [];
foreach ($items as $item) {
foreach ($item as $k => $v) {
if (is_string($v) && trim($v) !== '') {
$filledKeys[$k] = true;
}
}
}
foreach ($columns as &$col) {
$col['hasData'] = ! empty($filledKeys[$col['key']]);
// 规范化显示BOM_表结构 -> BOM 表结构(存储 key 因空格被替换为 _
$col['label'] = $col['label'] ?: $col['key'];
}
unset($col);
$sources = $this->repo->getSources($id);
$baseUrl = '';
foreach ($sources as $s) {
if (! empty($s['baseUrl'])) {
$baseUrl = rtrim((string) $s['baseUrl'], '/');
break;
}
}
// 附件字段:把 Teable 附件 JSON 转成可访问 URL 列表,并标记图片/文件列
$imageColumns = [];
$fileColumns = [];
foreach ($items as &$item) {
foreach ($item as $k => $v) {
if (! is_string($v) || $v === '' || $v[0] !== '[') {
continue;
}
$parsed = $this->parseAttachmentValue($v, $baseUrl);
if ($parsed === null) {
continue;
}
// 附件列值 → JSON 数组 [{url,name,image}],前端据此渲染 <img> 或链接
$item[$k] = json_encode($parsed['items'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($parsed['image']) {
$imageColumns[$k] = true;
} else {
$fileColumns[$k] = true;
}
}
}
unset($item);
return [
'bom' => $bom,
'columns' => $columns,
'items' => $items,
'sources' => $sources,
'hiddenColumns' => $this->getHiddenColumns($id),
'filledKeys' => array_keys($filledKeys),
'imageColumns' => array_keys($imageColumns),
'fileColumns' => array_keys($fileColumns),
];
}
// ---- 列显隐 ----
public function getHiddenColumns(int $bomId): array
{
$bom = $this->repo->getBom($bomId);
$arr = $bom === false ? [] : json_decode($bom['hiddenColumns'] ?? '', true);
return is_array($arr) ? array_values(array_unique(array_map('strval', $arr))) : [];
}
public function setHiddenColumns(int $bomId, array $keys): bool
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$keys = array_values(array_unique(array_map('strval', $keys)));
return $this->repo->updateHiddenColumns($bomId, json_encode($keys, JSON_UNESCAPED_UNICODE));
}
public function createBom(array $values): int
{
// 主数据是全局资源,强制 projectId=0与具体项目无关跨项目共享
$this->authorizeMaster(BomPermissions::CREATE, 0);
$row = [
'type' => (string) ($values['type'] ?? 'bom'),
'bomNo' => trim((string) ($values['bomNo'] ?? '')),
'productName' => trim((string) ($values['productName'] ?? '')),
'specification' => trim((string) ($values['specification'] ?? '')),
'drawingNo' => trim((string) ($values['drawingNo'] ?? '')),
'version' => trim((string) ($values['version'] ?? '')),
'status' => (string) ($values['status'] ?? '0'),
'projectId' => 0,
'remark' => trim((string) ($values['remark'] ?? '')),
'createdOn' => now(),
'modifiedOn' => now(),
];
return $this->repo->createBom($row);
}
public function updateBom(int $id, array $values): bool
{
$bom = $this->repo->getBom($id);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$row = [];
foreach (['bomNo', 'productName', 'specification', 'drawingNo', 'version', 'status', 'remark'] as $f) {
if (array_key_exists($f, $values)) {
$row[$f] = trim((string) $values[$f]);
}
}
$row['modifiedOn'] = now();
return $this->repo->updateBom($id, $row);
}
public function deleteBom(int $id): bool
{
$bom = $this->repo->getBom($id);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::DELETE, (int) $bom['projectId']);
$this->repo->deleteItemsByBom($id);
$this->repo->deleteColumnsByBom($id);
// 删除源
foreach ($this->repo->getSources($id) as $s) {
$this->repo->deleteSource((int) $s['id']);
}
return $this->repo->deleteBom($id);
}
// ---- 动态列 ----
public function getColumns(int $bomId): array
{
return $this->repo->getColumns($bomId);
}
public function addColumn(int $bomId, string $key, string $label): int
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return 0;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$key = $this->normalizeKey($key);
if ($key === '' || $this->isFixedColumn($key)) {
return 0;
}
// 已存在则返回
foreach ($this->repo->getColumns($bomId) as $col) {
if ($col['key'] === $key) {
return (int) $col['id'];
}
}
$maxSort = 0;
foreach ($this->repo->getColumns($bomId) as $col) {
$maxSort = max($maxSort, (int) $col['sortOrder']);
}
return $this->repo->addColumn([
'bomId' => $bomId,
'key' => $key,
'label' => trim($label) ?: $key,
'sortOrder' => $maxSort + 1,
]);
}
public function deleteColumn(int $columnId): bool
{
$col = $this->repo->getColumn($columnId);
if ($col === false) {
return false;
}
$bom = $this->repo->getBom((int) $col['bomId']);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
return $this->repo->deleteColumn($columnId);
}
// ---- 明细 ----
public function getItems(int $bomId): array
{
return $this->repo->getItems($bomId);
}
/**
* 新增/更新明细行。$values 可含任意固定列 + 动态列key 为准)。
*/
public function saveItem(int $bomId, array $values, ?int $itemId = null): int
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return 0;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$columns = $this->repo->getColumns($bomId);
$dynamicKeys = array_column($columns, 'key');
$extra = [];
foreach ($values as $k => $v) {
if ($this->isFixedColumn($k)) {
continue;
}
if (in_array($k, $dynamicKeys, true)) {
$extra[$k] = (string) $v;
}
}
$row = [
'bomId' => $bomId,
'seq' => (int) ($values['seq'] ?? 0),
'partNo' => (string) ($values['partNo'] ?? ''),
'partName' => (string) ($values['partName'] ?? ''),
'partDrawingNo' => (string) ($values['partDrawingNo'] ?? ''),
'material' => (string) ($values['material'] ?? ''),
'spec' => (string) ($values['spec'] ?? ''),
'qtyPerUnit' => (string) ($values['qtyPerUnit'] ?? ''),
'unit' => (string) ($values['unit'] ?? ''),
'process' => (string) ($values['process'] ?? ''),
'remark' => (string) ($values['remark'] ?? ''),
'extra' => empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_UNICODE),
];
if ($itemId !== null) {
$this->repo->updateItem($itemId, $row);
return $itemId;
}
return $this->repo->createItem($row);
}
public function deleteItem(int $itemId): bool
{
$item = $this->repo->getItem($itemId);
if ($item !== false) {
$bom = $this->repo->getBom((int) $item['bomId']);
if ($bom !== false) {
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
}
}
return $this->repo->deleteItem($itemId);
}
/**
* 上传 BOM 明细的图片/附件(保存到 public 磁盘,返回可直连的 URL
* 附件作为独立文件存到 public/userfiles/bom/,返回给前端塞进单元格或浮动图片。
*
* @return array{url:string,name:string,mime:string,size:int,image:bool}|string 成功返回元数据数组,失败返回错误文案
*/
public function uploadAttachment(int $bomId, array $file): array|string
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return 'BOM 不存在';
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
if (! isset($file['file']) || ! is_array($file['file'])) {
return '未收到文件';
}
// 大小上限(沿用全局文件上传上限)
if (isset($file['file']['size']) && $file['file']['size'] > FileManager::getMaximumFileUploadSize()) {
return '文件超过大小限制';
}
$tmp = $file['file']['tmp_name'] ?? '';
if ($tmp === '' || ! is_file($tmp)) {
return '上传文件无效';
}
$origName = basename((string) ($file['file']['name'] ?? 'file'));
$mime = (string) ($file['file']['type'] ?? '');
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
// 仅允许常见图片/文档/压缩包阻断可执行文件svg 由 FileManager 统一拦截防 XSS这里不放行
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'txt',
'zip', 'rar', '7z', 'step', 'stp', 'dwg', 'dxf', 'igs', 'iges'];
if ($ext === '' || ! in_array($ext, $allowed, true)) {
return '不支持的文件类型';
}
// 用 FileManager 的 UploadedFile 校验/清理流程,落到 public 磁盘(可 web 直连)
$uploaded = new UploadedFile(
$tmp,
$origName,
$mime,
$file['file']['error'] ?? 0,
true
);
$meta = $this->fileManager->upload($uploaded, 'public');
if ($meta === false || ! is_array($meta)) {
return '文件保存失败';
}
$fileName = (string) ($meta['fileName'] ?? $meta['newPath'] ?? '');
$url = $this->fileManager->getFileUrl($fileName, 'public');
if ($url === false) {
$base = defined('BASE_URL') ? BASE_URL : '';
$url = $base.'/userfiles/'.$fileName;
}
$isImage = str_starts_with($mime, 'image/');
return [
'url' => $url,
'name' => (string) ($meta['realName'] ?? $origName),
'mime' => $mime,
'size' => (int) ($file['file']['size'] ?? 0),
'image' => $isImage,
];
}
// ---- Teable ----
public function getSources(int $bomId): array
{
return $this->repo->getSources($bomId);
}
public function addSource(int $bomId, array $values): int
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return 0;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
return $this->repo->addSource([
'bomId' => $bomId,
'name' => trim((string) ($values['name'] ?? '')),
'baseUrl' => trim((string) ($values['baseUrl'] ?? '')),
'tableId' => trim((string) ($values['tableId'] ?? '')),
'token' => (string) ($values['token'] ?? ''),
'remark' => trim((string) ($values['remark'] ?? '')),
'createdOn' => now(),
]);
}
public function deleteSource(int $sourceId): bool
{
$s = $this->repo->getSource($sourceId);
if ($s === false) {
return false;
}
$bom = $this->repo->getBom((int) $s['bomId']);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
return $this->repo->deleteSource($sourceId);
}
/**
* 从 Teable 拉取最新数据并导入为 BOM 明细。
*
* 动态列策略:将 Teable 字段名映射为动态列(跳过无法作为 key 的字段),
* 记录值写入明细的 extra JSON。
*/
public function importFromTeable(int $bomId): array
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return ['success' => false, 'message' => 'BOM 不存在'];
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$sources = $this->repo->getSources($bomId);
if (empty($sources)) {
return ['success' => false, 'message' => '尚未配置 Teable 数据源'];
}
$imported = 0;
$errors = [];
foreach ($sources as $src) {
$res = $this->importSource($bomId, $src);
$imported += $res['imported'];
$errors = array_merge($errors, $res['errors']);
}
if ($imported === 0 && ! empty($errors)) {
return ['success' => false, 'message' => implode('', $errors)];
}
return ['success' => true, 'imported' => $imported, 'errors' => $errors];
}
/**
* 解析粘贴的 Teable API 文档 / curl 文本(不落库,用于前端预览)。
*/
public function parseTeableText(string $text): array
{
return $this->teable->parseConfig($text);
}
/**
* 粘贴整段 Teable API 文档直接导入:解析连接信息 -> 保存数据源 -> 拉取并导入。
*/
public function importFromTeableText(int $bomId, string $text): array
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return ['success' => false, 'message' => 'BOM 不存在'];
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$parsed = $this->teable->parseConfig($text);
if (empty($parsed['success'])) {
return ['success' => false, 'message' => $parsed['message'] ?? '解析失败'];
}
// 保存为数据源(同 tableId 已存在则不重复保存)
$exists = false;
foreach ($this->repo->getSources($bomId) as $s) {
if (($s['tableId'] ?? '') === $parsed['tableId']) {
$exists = true;
break;
}
}
if (! $exists) {
$this->addSource($bomId, [
'name' => $parsed['name'] ?: $parsed['tableId'],
'baseUrl' => $parsed['baseUrl'],
'tableId' => $parsed['tableId'],
'token' => $parsed['token'],
'remark' => 'API 粘贴导入',
]);
}
$res = $this->importSource($bomId, [
'name' => $parsed['name'] ?: $parsed['tableId'],
'baseUrl' => $parsed['baseUrl'],
'tableId' => $parsed['tableId'],
'token' => $parsed['token'],
]);
return [
'success' => true,
'imported' => $res['imported'],
'errors' => $res['errors'],
'source' => [
'name' => $parsed['name'],
'baseUrl' => $parsed['baseUrl'],
'tableId' => $parsed['tableId'],
],
];
}
/**
* 拉取单个 Teable 源并导入为明细行importFromTeable / importFromTeableText 共用)。
*
* @return array{imported:int,errors:array}
*/
private function importSource(int $bomId, array $src): array
{
$data = $this->teable->fetchTable($src);
if (empty($data['success'])) {
return ['imported' => 0, 'errors' => [($src['name'] ?: $src['tableId']).': '.($data['message'] ?? '拉取失败')]];
}
$fields = $data['fields'] ?? [];
$fixedByLabel = array_flip(self::FIXED_COLUMNS);
// 字段名 -> 目标 key优先别名映射到固定列其次跳过固定列其余建动态列
$fieldMap = []; // Teable 字段名 -> 存储 key
foreach ($fields as $field) {
$name = $field['name'] ?? ($field['fieldName'] ?? '');
if (! is_string($name) || $name === '') {
continue;
}
if (isset(self::FIELD_ALIASES[$name])) {
$fieldMap[$name] = self::FIELD_ALIASES[$name];
} elseif (isset(self::FIXED_COLUMNS[$name]) || isset($fixedByLabel[$name])) {
$fieldMap[$name] = isset(self::FIXED_COLUMNS[$name]) ? $name : $fixedByLabel[$name];
} else {
$this->addColumn($bomId, $name, $name);
$fieldMap[$name] = $this->normalizeKey($name);
}
}
$imported = 0;
foreach ($data['records'] ?? [] as $record) {
$fieldsArr = is_array($record) && isset($record['fields']) && is_array($record['fields'])
? $record['fields']
: $record;
$row = [
'seq' => 0,
'partNo' => '',
'partName' => '',
'partDrawingNo' => '',
'material' => '',
'spec' => '',
'qtyPerUnit' => '',
'unit' => '',
'process' => '',
'remark' => '',
];
foreach ($fieldsArr as $k => $v) {
$key = $fieldMap[$k] ?? (isset(self::FIELD_ALIASES[$k])
? self::FIELD_ALIASES[$k]
: (isset(self::FIXED_COLUMNS[$k]) || isset($fixedByLabel[$k])
? (isset(self::FIXED_COLUMNS[$k]) ? $k : $fixedByLabel[$k])
: $this->normalizeKey($k)));
$val = $this->stringifyTeableValue($v);
$row[$key] = $val;
}
$this->saveItem($bomId, $row);
$imported++;
}
return ['imported' => $imported, 'errors' => []];
}
/**
* Teable 多维值 -> 字符串:
* - 附件数组(含 presignedUrl/path/mimetype保留完整 JSON供读取时解析出图片 URL
* - 普通数组:提取 name/title 用逗号连接
* - 标量:直接转字符串
*/
private function stringifyTeableValue(mixed $v): string
{
if (is_array($v)) {
$isAttachment = false;
foreach ($v as $el) {
if (is_array($el) && (isset($el['presignedUrl']) || isset($el['path']) || isset($el['mimetype']))) {
$isAttachment = true;
break;
}
}
if ($isAttachment) {
return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$parts = [];
foreach ($v as $el) {
if (is_array($el)) {
$parts[] = (string) ($el['name'] ?? $el['title'] ?? ($el['id'] ?? ''));
} else {
$parts[] = (string) $el;
}
}
return implode(', ', $parts);
}
return (string) $v;
}
/**
* 解析 Teable 附件 JSON 字符串 -> 可访问 URL 列表。
*
* @return array{items:array<array{url:string,name:string,image:bool}>,image:bool}|null
*/
private function parseAttachmentValue(string $raw, string $baseUrl): ?array
{
if ($raw === '' || $raw[0] !== '[') {
return null;
}
$arr = json_decode($raw, true);
if (! is_array($arr)) {
return null;
}
$items = [];
$allImage = true;
foreach ($arr as $el) {
if (! is_array($el)) {
continue;
}
// 兼容两种格式:
// 1) Teable 导入mimetype / presignedUrl / path / smThumbnailUrl / lgThumbnailUrl
// 2) 前端「插入图片/附件」写入url / name / image(bool)
$mime = (string) ($el['mimetype'] ?? $el['mime'] ?? '');
if (array_key_exists('image', $el)) {
$isImg = (bool) $el['image'];
} else {
$isImg = str_starts_with($mime, 'image/');
}
if (! $isImg) {
$allImage = false;
}
$rel = (string) ($el['url'] ?? $el['smThumbnailUrl'] ?? $el['lgThumbnailUrl'] ?? $el['presignedUrl'] ?? $el['path'] ?? '');
if ($rel === '') {
continue;
}
$url = preg_match('#^https?://#', $rel) ? $rel : $baseUrl.$rel;
$items[] = [
'url' => $url,
'name' => (string) ($el['name'] ?? ''),
'image' => $isImg,
];
}
if (empty($items)) {
return null;
}
return ['items' => $items, 'image' => $allImage];
}
/**
* 从 Excel/CSV 导入 BOM 明细。
*/
public function importFromExcel(int $bomId, string $path): array
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return ['success' => false, 'message' => 'BOM 不存在'];
}
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
$parsed = $this->excel->parse($path);
$header = $parsed['header'];
if (empty($header)) {
return ['success' => false, 'message' => 'Excel 为空或缺少表头'];
}
// 表头 -> 固定列 key 映射(支持中文表头或英文 key 表头)
$map = [];
$fixedByLabel = array_flip(self::FIXED_COLUMNS);
foreach ($header as $i => $h) {
$h = trim((string) $h);
if ($h === '') {
continue;
}
$key = $h;
if (isset(self::FIXED_COLUMNS[$h])) {
$key = $h;
} elseif (isset($fixedByLabel[$h])) {
$key = $fixedByLabel[$h];
} else {
// 非固定列 → 动态列key 规范化,与 addColumn 存储一致)
$this->addColumn($bomId, $h, $h);
$key = $this->normalizeKey($h);
}
$map[$i] = $key;
}
$imported = 0;
foreach ($parsed['rows'] as $rowArr) {
$row = ['seq' => 0];
foreach ($map as $i => $key) {
$val = $rowArr[$i] ?? '';
$row[$key] = is_string($val) ? trim($val) : (string) $val;
}
$this->saveItem($bomId, $row);
$imported++;
}
return ['success' => true, 'imported' => $imported];
}
// ---- 导出 ----
/**
* 导出 BOM 明细(返回 [path, filename, contentType])。
* @return array{path:string,filename:string,contentType:string}|false
*/
public function export(int $bomId, string $format = 'xlsx'): array|false
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
$detail = $this->getBomDetail($bomId);
if ($detail === false) {
return false;
}
$hidden = $this->getHiddenColumns($bomId);
// 组装表头:固定列(跳过隐藏)+ 动态列(跳过隐藏)
$header = [];
$keys = [];
foreach (self::FIXED_COLUMNS as $key => $label) {
if (in_array($key, $hidden, true)) {
continue;
}
$header[] = $label;
$keys[] = $key;
}
foreach ($detail['columns'] as $col) {
if (in_array($col['key'], $hidden, true)) {
continue;
}
$header[] = $col['label'] ?: $col['key'];
$keys[] = $col['key'];
}
$rows = [];
foreach ($detail['items'] as $item) {
$line = [];
foreach ($keys as $k) {
$line[] = (string) ($item[$k] ?? '');
}
$rows[] = $line;
}
$name = ($bom['bomNo'] ?: ($bom['productName'] ?: 'BOM')).'-'.date('Ymd-His');
$format = strtolower($format);
if ($format === 'csv') {
$path = $this->excel->exportCsv($header, $rows);
return ['path' => $path, 'filename' => $name.'.csv', 'contentType' => 'text/csv; charset=UTF-8'];
}
$path = $this->excel->exportXlsx($name, $header, $rows);
return ['path' => $path, 'filename' => $name.'.xlsx', 'contentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
}
/**
* 导出 BOM 导入模板(仅表头:固定列 + 现有动态列,不含数据行)。
* @return array{path:string,filename:string,contentType:string}|false
*/
public function exportTemplate(int $bomId): array|false
{
$bom = $this->repo->getBom($bomId);
if ($bom === false) {
return false;
}
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
$header = array_values(self::FIXED_COLUMNS);
foreach ($this->repo->getColumns($bomId) as $col) {
$header[] = $col['label'] ?: $col['key'];
}
$path = $this->excel->exportXlsx('BOM导入模板', $header, []);
return ['path' => $path, 'filename' => 'BOM导入模板.xlsx', 'contentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
}
// ---- helpers ----
private function isFixedColumn(string $key): bool
{
return array_key_exists($key, self::FIXED_COLUMNS);
}
private function normalizeKey(string $key): string
{
// 允许中文字段名作为 key仅清理首尾空白和危险字符
$key = trim($key);
$key = str_replace([' ', '/', '\\'], '_', $key);
return $key;
}
}