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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user