OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
888
app/Domain/Bom/Services/Bom.php
Normal file
888
app/Domain/Bom/Services/Bom.php
Normal file
@@ -0,0 +1,888 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
269
app/Domain/Bom/Services/Excel.php
Normal file
269
app/Domain/Bom/Services/Excel.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
/**
|
||||
* 轻量 Excel/CSV 解析器,不依赖 PhpSpreadsheet。
|
||||
*
|
||||
* 支持:
|
||||
* - .xlsx(解压 sharedStrings.xml + sheet1.xml,按列字母映射单元格)
|
||||
* - .csv(fgetcsv,自动探测 BOM)
|
||||
* - .xls 老格式不支持,报错提示另存为 .xlsx 或 .csv
|
||||
*
|
||||
* 返回:首行为表头,后续行为数据行。
|
||||
*/
|
||||
class Excel
|
||||
{
|
||||
/**
|
||||
* @return array{header:array<int,string>,rows:array<int,array<int,string>>}
|
||||
*/
|
||||
public function parse(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($ext) {
|
||||
'xlsx' => $this->parseXlsx($path),
|
||||
'csv' => $this->parseCsv($path),
|
||||
default => throw new \InvalidArgumentException('仅支持 .xlsx 或 .csv 文件'),
|
||||
};
|
||||
}
|
||||
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \InvalidArgumentException('无法打开 CSV 文件');
|
||||
}
|
||||
|
||||
// 去掉 UTF-8 BOM
|
||||
$first = fgets($handle);
|
||||
if ($first === false) {
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
if (str_starts_with($first, "\xEF\xBB\xBF")) {
|
||||
$first = substr($first, 3);
|
||||
}
|
||||
|
||||
$header = str_getcsv($first);
|
||||
$rows = [];
|
||||
while (($line = fgetcsv($handle)) !== false) {
|
||||
// 跳过空行
|
||||
if (count($line) === 1 && trim($line[0]) === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new \InvalidArgumentException('无法打开 .xlsx 文件');
|
||||
}
|
||||
|
||||
// sharedStrings
|
||||
$shared = [];
|
||||
$ss = $zip->getFromName('xl/sharedStrings.xml');
|
||||
if ($ss !== false) {
|
||||
$xml = new \SimpleXMLElement($ss);
|
||||
foreach ($xml->si as $si) {
|
||||
$text = '';
|
||||
foreach ($si->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$shared[] = $text;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一个工作表
|
||||
$sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
|
||||
if ($sheet === false) {
|
||||
$zip->close();
|
||||
throw new \InvalidArgumentException('未找到工作表');
|
||||
}
|
||||
|
||||
$xml = new \SimpleXMLElement($sheet);
|
||||
$rows = [];
|
||||
$header = [];
|
||||
|
||||
foreach ($xml->sheetData->row as $rowEl) {
|
||||
$cells = [];
|
||||
foreach ($rowEl->c as $c) {
|
||||
$ref = (string) $c['r']; // 如 "A1", "B2"
|
||||
$col = $this->colIndex($ref);
|
||||
$type = (string) $c['t'];
|
||||
|
||||
if ($type === 's') {
|
||||
// 共享字符串:v 是 sharedStrings 索引
|
||||
$v = (string) ($c->v ?? '');
|
||||
$cells[$col] = $shared[(int) $v] ?? '';
|
||||
} elseif ($type === 'inlineStr') {
|
||||
// 内联字符串(现代 Excel/WPS 常用):值在 <is><t> 里,富文本可能有多个 <t>
|
||||
$text = '';
|
||||
foreach ($c->is->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$cells[$col] = $text;
|
||||
} else {
|
||||
// 数字/布尔/公式结果等
|
||||
$cells[$col] = (string) ($c->v ?? '');
|
||||
}
|
||||
}
|
||||
$line = [];
|
||||
$max = empty($cells) ? 0 : max(array_keys($cells));
|
||||
for ($i = 0; $i <= $max; $i++) {
|
||||
$line[$i] = $cells[$i] ?? '';
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
|
||||
$header = array_shift($rows);
|
||||
|
||||
// 规范化表头:去空白
|
||||
$header = array_map(fn ($h) => trim((string) $h), $header);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/** "A"->0, "B"->1, "AA"->26 ... */
|
||||
private function colIndex(string $ref): int
|
||||
{
|
||||
$letters = preg_replace('/\d/', '', $ref);
|
||||
$idx = 0;
|
||||
foreach (str_split($letters) as $ch) {
|
||||
$idx = $idx * 26 + (ord($ch) - ord('A') + 1);
|
||||
}
|
||||
|
||||
return $idx - 1;
|
||||
}
|
||||
|
||||
// ---- 导出(生成真正的 .xlsx,不依赖 PhpSpreadsheet) ----
|
||||
|
||||
/**
|
||||
* 生成 .xlsx 文件,返回临时文件路径。
|
||||
*
|
||||
* @param array<int,string> $header 表头
|
||||
* @param array<int,array<int,string>> $rows 数据行
|
||||
*/
|
||||
public function exportXlsx(string $sheetName, array $header, array $rows): string
|
||||
{
|
||||
$sheetName = mb_substr(preg_replace('/[\\/?*\[\]:]/', ' ', $sheetName) ?: 'Sheet1', 0, 31) ?: 'Sheet1';
|
||||
|
||||
$rowsXml = '';
|
||||
$rowNum = 1;
|
||||
|
||||
$rowsXml .= $this->buildRow($rowNum++, $header);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$cells = [];
|
||||
foreach ($row as $i => $v) {
|
||||
$cells[$i] = is_string($v) ? $v : (string) $v;
|
||||
}
|
||||
$rowsXml .= $this->buildRow($rowNum++, $cells);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
@unlink($tmp);
|
||||
$path = $tmp.'.xlsx';
|
||||
if ($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
.'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
.'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
.'</Types>');
|
||||
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/workbook.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
.'<sheets><sheet name="'.$this->xml($sheetName).'" sheetId="1" r:id="rId1"/></sheets>'
|
||||
.'</workbook>');
|
||||
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
|
||||
.$rowsXml
|
||||
.'</sheetData></worksheet>');
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 .csv 文件,返回临时文件路径(带 UTF-8 BOM,Excel 打开不乱码)。
|
||||
*/
|
||||
public function exportCsv(array $header, array $rows): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
$path = $tmp.'.csv';
|
||||
$fh = fopen($path, 'w');
|
||||
if ($fh === false) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
fwrite($fh, "\xEF\xBB\xBF");
|
||||
fputcsv($fh, $header);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fh, array_map(fn ($v) => (string) $v, $row));
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function buildRow(int $rowNum, array $cells): string
|
||||
{
|
||||
$xml = '<row r="'.$rowNum.'">';
|
||||
$col = 0;
|
||||
foreach ($cells as $v) {
|
||||
$ref = $this->colLetter($col).$rowNum;
|
||||
$xml .= '<c r="'.$ref.'" t="inlineStr"><is><t>'.$this->xml((string) $v).'</t></is></c>';
|
||||
$col++;
|
||||
}
|
||||
$xml .= '</row>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function colLetter(int $idx): string
|
||||
{
|
||||
$s = '';
|
||||
$idx++;
|
||||
while ($idx > 0) {
|
||||
$idx--;
|
||||
$s = chr(ord('A') + ($idx % 26)).$s;
|
||||
$idx = intdiv($idx, 26);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function xml(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
152
app/Domain/Bom/Services/MasterRef.php
Normal file
152
app/Domain/Bom/Services/MasterRef.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||||
use Leantime\Domain\Bom\Repositories\Bom as BomRepository;
|
||||
use Leantime\Domain\Bom\Repositories\MasterRef as MasterRefRepository;
|
||||
|
||||
/**
|
||||
* 项目 ↔ 全局主数据(BOM/工艺文件/工具清单)引用服务。
|
||||
*
|
||||
* 单向引用:项目引用主数据后追加的项目级列/值只存引用层,绝不写回全局主数据。
|
||||
*/
|
||||
class MasterRef extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MasterRefRepository $repo,
|
||||
protected BomRepository $bomRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 项目已引用的主数据列表。
|
||||
*/
|
||||
public function getRefs(int $projectId): array
|
||||
{
|
||||
return $this->repo->getRefsByProject($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目引用一个主数据(幂等:已引用则返回已有 refId)。
|
||||
*/
|
||||
public function link(int $projectId, int $masterId): int
|
||||
{
|
||||
$this->authorize(BomPermissions::EDIT, $projectId);
|
||||
|
||||
$master = $this->bomRepo->getBom($masterId);
|
||||
if ($master === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$existing = $this->repo->findRef($projectId, $masterId);
|
||||
if ($existing !== false) {
|
||||
return (int) $existing['id'];
|
||||
}
|
||||
|
||||
return $this->repo->createRef([
|
||||
'projectId' => $projectId,
|
||||
'masterId' => $masterId,
|
||||
'createdOn' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unlink(int $refId): bool
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->deleteRef($refId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级引用详情:主数据明细 + 项目级追加列 + 项目级追加值。
|
||||
*/
|
||||
public function getRefDetail(int $refId): array|false
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::VIEW, (int) $ref['projectId']);
|
||||
|
||||
$columns = $this->repo->getColumns($refId);
|
||||
$values = $this->repo->getValues($refId);
|
||||
|
||||
// 值按 itemId -> [columnId -> value] 组织
|
||||
$valueMap = [];
|
||||
foreach ($values as $v) {
|
||||
$valueMap[(int) $v['itemId']][(int) $v['columnId']] = (string) $v['value'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ref' => $ref,
|
||||
'columns' => $columns,
|
||||
'values' => $valueMap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级追加列。
|
||||
*/
|
||||
public function addColumn(int $refId, string $key, string $label): int
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return 0;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
$key = trim(str_replace([' ', '/', '\\'], '_', $key));
|
||||
if ($key === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($this->repo->getColumns($refId) as $col) {
|
||||
if ($col['key'] === $key) {
|
||||
return (int) $col['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$maxSort = 0;
|
||||
foreach ($this->repo->getColumns($refId) as $col) {
|
||||
$maxSort = max($maxSort, (int) $col['sortOrder']);
|
||||
}
|
||||
|
||||
return $this->repo->addColumn([
|
||||
'refId' => $refId,
|
||||
'key' => $key,
|
||||
'label' => trim($label) ?: $key,
|
||||
'sortOrder' => $maxSort + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $columnId): bool
|
||||
{
|
||||
// 按 columnId 找 refId 以完成鉴权
|
||||
$ref = $this->repo->getRefByColumnId($columnId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->deleteColumn($columnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目级追加值(不影响全局主数据)。
|
||||
*/
|
||||
public function saveValue(int $refId, int $columnId, int $itemId, string $value): bool
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->setValue($refId, $columnId, $itemId, $value);
|
||||
}
|
||||
}
|
||||
179
app/Domain/Bom/Services/Teable.php
Normal file
179
app/Domain/Bom/Services/Teable.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Teable OpenAPI 客户端。
|
||||
*
|
||||
* 把 Teable 作为外部数据库:实时拉取表的字段元数据 + 全量记录。
|
||||
* GET {base}/api/table/{tableId}/field 字段元数据
|
||||
* GET {base}/api/table/{tableId}/record 记录(take/skip 分页)
|
||||
* 统一 fieldKeyType=name,字段名(中文亦可)直接作为 JSON key。
|
||||
*/
|
||||
class Teable
|
||||
{
|
||||
private const DEFAULT_BASE = 'https://table.universal-onebot.com';
|
||||
|
||||
/**
|
||||
* @return array{success:bool,fields:array,records:array,message?:string}
|
||||
*/
|
||||
public function fetchTable(array $source): array
|
||||
{
|
||||
$base = rtrim(trim($source['baseUrl'] ?? '') ?: self::DEFAULT_BASE, '/');
|
||||
$tableId = $source['tableId'] ?? '';
|
||||
$token = $source['token'] ?? '';
|
||||
|
||||
if ($tableId === '' || $token === '') {
|
||||
return ['success' => false, 'message' => '缺少 tableId 或 token', 'fields' => [], 'records' => []];
|
||||
}
|
||||
|
||||
try {
|
||||
$fieldResp = Http::withToken($token)
|
||||
->timeout(60)
|
||||
->get("$base/api/table/$tableId/field");
|
||||
|
||||
if (! $fieldResp->successful()) {
|
||||
return ['success' => false, 'message' => "字段接口 HTTP {$fieldResp->status()}", 'fields' => [], 'records' => []];
|
||||
}
|
||||
|
||||
$fields = $fieldResp->json() ?? [];
|
||||
|
||||
$records = [];
|
||||
$skip = 0;
|
||||
$take = 1000;
|
||||
|
||||
while (true) {
|
||||
$recResp = Http::withToken($token)
|
||||
->timeout(60)
|
||||
->get("$base/api/table/$tableId/record", [
|
||||
'fieldKeyType' => 'name',
|
||||
'take' => $take,
|
||||
'skip' => $skip,
|
||||
]);
|
||||
|
||||
if (! $recResp->successful()) {
|
||||
return ['success' => false, 'message' => "记录接口 HTTP {$recResp->status()}", 'fields' => $fields, 'records' => []];
|
||||
}
|
||||
|
||||
$body = $recResp->json() ?? [];
|
||||
$page = $body['records'] ?? [];
|
||||
$records = array_merge($records, $page);
|
||||
|
||||
if (count($page) < $take) {
|
||||
break;
|
||||
}
|
||||
$skip += $take;
|
||||
}
|
||||
|
||||
return ['success' => true, 'fields' => $fields, 'records' => $records];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage(), 'fields' => [], 'records' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户粘贴的整段 API 文档 / curl 命令中解析出连接信息。
|
||||
*
|
||||
* 容错策略:先清洗零宽字符/markdown 符号,再按 URL → Token → 表名顺序多模式兜底,
|
||||
* 解析不完整时返回 missing 清单与已找到的部分,供前端手动补齐。
|
||||
*
|
||||
* @return array{success:bool,baseUrl:string,tableId:string,token:string,name:string,missing:array,message?:string}
|
||||
*/
|
||||
public function parseConfig(string $text): array
|
||||
{
|
||||
// 粘贴文本可能带零宽字符(U+200B 等)或 markdown 装饰,先做无害化清洗
|
||||
$clean = (string) preg_replace('/[\x{200B}-\x{200D}\x{2060}\x{FEFF}\x{00AD}]/u', '', (string) $text);
|
||||
$clean = str_replace(["\r\n", "\r"], "\n", $clean);
|
||||
$clean = str_replace(['`', '**'], ['', ''], $clean);
|
||||
|
||||
$base = '';
|
||||
$tableId = '';
|
||||
$token = '';
|
||||
$name = '';
|
||||
|
||||
// 1) 表名:# Table: xxx(兼容中英文冒号)
|
||||
if (preg_match('/^#+\s*Table\s*[::]\s*(.+)$/mi', $clean, $m)) {
|
||||
$name = trim($m[1]);
|
||||
}
|
||||
|
||||
// 2) 收集所有 URL,优先取含 /api/table/ 或 /table/ 的完整地址
|
||||
$urls = [];
|
||||
if (preg_match_all('#https?://[^\s"\'<>()\[\]]+#', $clean, $mm)) {
|
||||
$urls = $mm[0];
|
||||
}
|
||||
foreach ($urls as $u) {
|
||||
if ($tableId === '' && preg_match('#/(?:api/)?table/([A-Za-z0-9_-]+)#', $u, $m)) {
|
||||
$tableId = $m[1];
|
||||
if (preg_match('#^(https?://[^/\s]+)#', $u, $bm)) {
|
||||
$base = $bm[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Token:Bearer 前缀 → Token:/API Key: 行 → 任意位置 teable_ 开头串
|
||||
$tokPatterns = [
|
||||
'#(?:Bearer|bearer)\s+([A-Za-z0-9_.+/=~%-]+)#',
|
||||
'#(?:Token|token|API\s*Key|api\s*key)\s*[::=]\s*([A-Za-z0-9_.+/=~%-]+)#',
|
||||
'#(teable_[A-Za-z0-9_.+/=~%-]+)#',
|
||||
];
|
||||
foreach ($tokPatterns as $p) {
|
||||
if (preg_match($p, $clean, $m)) {
|
||||
$token = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 表 ID 兜底:任意 tbl 开头长串
|
||||
if ($tableId === '' && preg_match('#\btbl[A-Za-z0-9_-]{8,}#i', $clean, $m)) {
|
||||
$tableId = $m[0];
|
||||
}
|
||||
|
||||
// 5) Base URL 兜底:Base URL 行(值可在下一行)→ 任意 URL 的 host
|
||||
if ($base === '') {
|
||||
if (preg_match('#(?:Base\s*URL|baseUrl|基础\s*URL)\s*[::]\s*(https?://[^\s"\'<>()\[\]]+)#i', $clean, $m)
|
||||
|| preg_match('#(?:Base\s*URL|baseUrl|基础\s*URL)\s*[::]?\s*\n\s*(https?://[^\s"\'<>()\[\]]+)#i', $clean, $m)) {
|
||||
$base = rtrim($m[1], '/');
|
||||
} elseif (! empty($urls)) {
|
||||
if (preg_match('#^(https?://[^/\s]+)#', $urls[0], $bm)) {
|
||||
$base = $bm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
if ($base === '') {
|
||||
$missing[] = 'Base URL';
|
||||
}
|
||||
if ($tableId === '') {
|
||||
$missing[] = 'Table ID';
|
||||
}
|
||||
if ($token === '') {
|
||||
$missing[] = 'Token';
|
||||
}
|
||||
|
||||
if (! empty($missing)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '缺少:'.implode('、', $missing).'。请确认粘贴内容包含表地址(https://…/api/table/tbl…)和 Token(teable_…),或直接在下方输入框手动补齐',
|
||||
'baseUrl' => $base,
|
||||
'tableId' => $tableId,
|
||||
'token' => $token,
|
||||
'name' => $name,
|
||||
'missing' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '解析成功',
|
||||
'baseUrl' => $base,
|
||||
'tableId' => $tableId,
|
||||
'token' => $token,
|
||||
'name' => $name,
|
||||
'missing' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user