'序号', '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; } }