Files
Leantime/app/Domain/Bom/Services/Bom.php
wangruiguo d647428529
Some checks failed
Security Audit / Composer Security Audit (push) Has been cancelled
Security Audit / NPM Security Audit (push) Has been cancelled
Security Audit / CodeQL Analysis (push) Has been cancelled
OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
2026-09-03 18:49:20 +08:00

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;
}
}