feat(bom): COMPANY_7 BOM에 대체 품목 관리 기능 추가

- bom_detail_substitute 테이블 신규 (varchar PK, FK 미설정 / 코드 조인)
- 백엔드: 단일 행 substitute CRUD + BOM 단위 갯수 맵 API 5종 추가
- 프론트(COMPANY_7): 트리 행에 '대체 N' 뱃지 + 대체 품목 모달
  · 드래그앤드롭으로 우선순위 변경 + 자동 채번/재할당
  · 250ms debounce 실시간 검색, 결과 클릭 시 자동 행 추가
  · inline blur 저장, zebra 행 구분, sticky 헤더 단색 처리
- 트리뷰 액션 버튼 통합: 가상 루트 선택 시 1레벨, 일반 행 선택 시 하위로 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-05-06 17:32:10 +09:00
parent 45d3ccb004
commit 17173be350
4 changed files with 669 additions and 9 deletions

View File

@@ -224,3 +224,93 @@ export async function deleteBomVersion(req: Request, res: Response) {
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 대체품 (Substitute) ─────────────────────────────
export async function getBomDetailSubstitutes(req: Request, res: Response) {
try {
const { detailId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.getBomDetailSubstitutes(detailId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function getBomSubstituteCounts(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const versionId = (req.query.versionId as string) || undefined;
const rows = await bomService.getBomSubstituteCounts(bomId, companyCode, versionId);
const map: Record<string, number> = {};
for (const r of rows as any[]) {
map[r.bom_detail_id] = Number(r.count);
}
res.json({ success: true, data: map });
} catch (error: any) {
logger.error("대체품 갯수 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomDetailSubstitute(req: Request, res: Response) {
try {
const { detailId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const writer = (req as any).user?.userName || (req as any).user?.userId || "";
const { substitute_item_id, priority, ratio, remark, status } = req.body;
if (!substitute_item_id) {
res.status(400).json({ success: false, message: "substitute_item_id는 필수입니다" });
return;
}
const data = await bomService.createBomDetailSubstitute({
bom_detail_id: detailId,
substitute_item_id,
priority,
ratio,
remark,
status,
company_code: companyCode,
writer,
});
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateBomDetailSubstitute(req: Request, res: Response) {
try {
const { id } = req.params;
const data = await bomService.updateBomDetailSubstitute(id, req.body);
if (!data) {
res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomDetailSubstitute(req: Request, res: Response) {
try {
const { id } = req.params;
const result = await bomService.deleteBomDetailSubstitute(id);
if (!result) {
res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("대체품 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -30,4 +30,11 @@ router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
// 대체품 (Substitute)
router.get("/:bomId/substitute-counts", bomController.getBomSubstituteCounts);
router.get("/details/:detailId/substitutes", bomController.getBomDetailSubstitutes);
router.post("/details/:detailId/substitutes", bomController.createBomDetailSubstitute);
router.put("/substitutes/:id", bomController.updateBomDetailSubstitute);
router.delete("/substitutes/:id", bomController.deleteBomDetailSubstitute);
export default router;

View File

@@ -845,3 +845,117 @@ export async function deleteBomVersion(
return deleteVersion.rows.length > 0;
});
}
// ─── 대체품 (Substitute) ─────────────────────────────
export async function getBomDetailSubstitutes(detailId: string, companyCode: string) {
const sql = `
SELECT s.*,
i.item_name AS substitute_item_name,
i.item_number AS substitute_item_number,
i.unit AS substitute_unit,
i.inventory_unit AS substitute_inventory_unit,
i.size AS substitute_size,
i.material AS substitute_material,
i.division AS substitute_division
FROM bom_detail_substitute s
LEFT JOIN item_info i ON s.substitute_item_id = i.id
WHERE s.bom_detail_id = $1
${companyCode === "*" ? "" : "AND s.company_code = $2"}
ORDER BY
CASE WHEN s.priority ~ '^[0-9]+$' THEN s.priority::int ELSE 9999 END ASC,
s.created_date ASC
`;
const params = companyCode === "*" ? [detailId] : [detailId, companyCode];
return query(sql, params);
}
export async function getBomSubstituteCounts(
bomId: string,
companyCode: string,
versionId?: string,
) {
const params: any[] = [bomId];
let sql = `
SELECT s.bom_detail_id, COUNT(*)::int AS count
FROM bom_detail_substitute s
JOIN bom_detail d ON d.id = s.bom_detail_id
WHERE d.bom_id = $1
`;
if (companyCode !== "*") {
params.push(companyCode);
sql += ` AND s.company_code = $${params.length}`;
}
if (versionId) {
params.push(versionId);
sql += ` AND d.version_id = $${params.length}`;
}
sql += ` GROUP BY s.bom_detail_id`;
return query(sql, params);
}
export async function createBomDetailSubstitute(data: {
bom_detail_id: string;
substitute_item_id: string;
priority?: string;
ratio?: string;
remark?: string;
status?: string;
company_code: string;
writer?: string;
}) {
const sql = `
INSERT INTO bom_detail_substitute
(bom_detail_id, substitute_item_id, priority, ratio, remark, status, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
return queryOne(sql, [
data.bom_detail_id,
data.substitute_item_id,
data.priority ?? null,
data.ratio ?? "1",
data.remark ?? null,
data.status ?? "active",
data.company_code,
data.writer ?? null,
]);
}
export async function updateBomDetailSubstitute(
id: string,
data: {
substitute_item_id?: string;
priority?: string;
ratio?: string;
remark?: string;
status?: string;
},
) {
const fields: string[] = [];
const values: any[] = [];
let idx = 1;
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
fields.push(`${key} = $${idx++}`);
values.push(value);
}
}
if (fields.length === 0) return null;
fields.push(`updated_date = CURRENT_TIMESTAMP`);
values.push(id);
const sql = `
UPDATE bom_detail_substitute
SET ${fields.join(", ")}
WHERE id = $${idx}
RETURNING *
`;
return queryOne(sql, values);
}
export async function deleteBomDetailSubstitute(id: string) {
const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`;
return queryOne(sql, [id]);
}