'序号',
'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}],前端据此渲染
或链接
$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,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;
}
}