Implement outsource purchase management functionality
- Added routes for outsource purchase management, including CRUD operations and additional features such as auto-processes and release requests. - Created the `outsourcePurchaseController` to handle business logic for managing outsource purchase orders. - Introduced the `outsourcePurchaseService` for service layer operations related to outsource purchases. - Updated `app.ts` to include the new routes for outsource purchase management. (TASK:ERP-019)
This commit is contained in:
@@ -171,6 +171,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고
|
||||
import outsourcePurchaseRoutes from "./routes/outsourcePurchaseRoutes"; // 외주발주관리 (TASK:ERP-019)
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
@@ -402,6 +403,7 @@ app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고
|
||||
app.use("/api/outsource-purchase", outsourcePurchaseRoutes); // 외주발주관리 (TASK:ERP-019)
|
||||
app.use("/api/quotes", quoteRoutes); // 견적관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
|
||||
205
backend-node/src/controllers/outsourcePurchaseController.ts
Normal file
205
backend-node/src/controllers/outsourcePurchaseController.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 외주발주관리 컨트롤러 (TASK:ERP-019)
|
||||
*
|
||||
* 라우트:
|
||||
* GET /outsource-purchase 목록
|
||||
* GET /outsource-purchase/auto-processes 공정 자동표기 헬퍼
|
||||
* GET /outsource-purchase/:id 상세
|
||||
* POST /outsource-purchase 등록
|
||||
* PUT /outsource-purchase/:id 수정
|
||||
* DELETE /outsource-purchase/:id 삭제
|
||||
* POST /outsource-purchase/release-request 사급자재 출고요청
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as svc from "../services/outsourcePurchaseService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function ok(res: Response, data: any, message = "성공") {
|
||||
return res.json({ success: true, data, message });
|
||||
}
|
||||
function fail(res: Response, status: number, message: string, error?: any) {
|
||||
if (error) logger.error(message, { error: error?.message });
|
||||
return res.status(status).json({ success: false, message });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 목록
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function listOutsourceOrders(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const opts: svc.ListFilter = {
|
||||
keyword: req.query.keyword as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
source_type: req.query.source_type as string | undefined,
|
||||
date_from: req.query.date_from as string | undefined,
|
||||
date_to: req.query.date_to as string | undefined,
|
||||
page: req.query.page ? parseInt(req.query.page as string, 10) : 1,
|
||||
size:
|
||||
req.query.size !== undefined
|
||||
? parseInt(req.query.size as string, 10)
|
||||
: 50,
|
||||
sort: req.query.sort as string | undefined,
|
||||
};
|
||||
const data = await svc.listOrders(companyCode, opts);
|
||||
return ok(res, data);
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "외주발주 목록 조회 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 상세
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function getOutsourceOrderDetail(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const id = req.params.id;
|
||||
if (!id) return fail(res, 400, "id 파라미터가 필요합니다");
|
||||
const data = await svc.getOrderDetail(companyCode, id);
|
||||
if (!data) return fail(res, 404, "외주발주를 찾을 수 없습니다");
|
||||
return ok(res, data);
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "외주발주 상세 조회 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 등록
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function createOutsourceOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId || "system";
|
||||
const payload = req.body as svc.OPOInput;
|
||||
if (!payload || !payload.source_type) {
|
||||
return fail(res, 400, "source_type은 필수입니다");
|
||||
}
|
||||
const data = await svc.createOrder(companyCode, userId, payload);
|
||||
return ok(res, data, "외주발주 등록 완료");
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "외주발주 등록 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 수정
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function updateOutsourceOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId || "system";
|
||||
const id = req.params.id;
|
||||
if (!id) return fail(res, 400, "id 파라미터가 필요합니다");
|
||||
const data = await svc.updateOrder(
|
||||
companyCode,
|
||||
userId,
|
||||
id,
|
||||
req.body as svc.OPOInput
|
||||
);
|
||||
return ok(res, data, "외주발주 수정 완료");
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "외주발주 수정 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 삭제
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function deleteOutsourceOrder(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const id = req.params.id;
|
||||
if (!id) return fail(res, 400, "id 파라미터가 필요합니다");
|
||||
const data = await svc.deleteOrder(companyCode, id);
|
||||
return ok(res, data, "외주발주 삭제 완료");
|
||||
} catch (e: any) {
|
||||
return fail(res, 400, e?.message || "외주발주 삭제 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 사급자재 출고요청
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function requestRelease(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId || "system";
|
||||
const payload = req.body as svc.ReleaseRequestPayload;
|
||||
if (
|
||||
!payload ||
|
||||
!Array.isArray(payload.material_ids) ||
|
||||
payload.material_ids.length === 0
|
||||
) {
|
||||
return fail(res, 400, "material_ids 배열이 필요합니다");
|
||||
}
|
||||
const data = await svc.requestRelease(companyCode, userId, payload);
|
||||
return ok(res, data, "사급자재 출고요청 완료");
|
||||
} catch (e: any) {
|
||||
return fail(res, 400, e?.message || "사급자재 출고요청 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 공정 자동표기 (헬퍼)
|
||||
// (TASK:ERP-019 재구현 — work_order_id 우선, item_code는 폴백)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function autoProcesses(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const itemCode = req.query.item_code as string | undefined;
|
||||
const workOrderId = req.query.work_order_id as string | undefined;
|
||||
if (!workOrderId && !itemCode) {
|
||||
return fail(res, 400, "work_order_id 또는 item_code 중 하나는 필수입니다");
|
||||
}
|
||||
const data = await svc.autoSelectProcesses(companyCode, workOrderId, itemCode);
|
||||
return ok(res, data);
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "공정 자동표기 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 외주발주 가능 작업지시 목록
|
||||
// (TASK:ERP-019 재구현 — 좌측 리스트 필터)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function listOutsourceableWorkOrders(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const opts: svc.OutsourceableListFilter = {
|
||||
keyword: req.query.keyword as string | undefined,
|
||||
page: req.query.page ? parseInt(req.query.page as string, 10) : 1,
|
||||
size: req.query.size !== undefined ? parseInt(req.query.size as string, 10) : 50,
|
||||
};
|
||||
const data = await svc.listOutsourceableWorkOrders(companyCode, opts);
|
||||
return ok(res, data);
|
||||
} catch (e: any) {
|
||||
return fail(res, 500, e?.message || "외주발주 가능 작업지시 조회 실패", e);
|
||||
}
|
||||
}
|
||||
@@ -434,22 +434,20 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세의 외주업체 매핑을 먼저 제거
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id IN (
|
||||
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
|
||||
)`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
// 기존 상세 — seq_no 기준으로 ID 보존(UPSERT). 작업지시(work_order_process)가 routing_detail_id를 참조하므로 ID 재발급 금지.
|
||||
const existingRes = await client.query(
|
||||
`SELECT id, seq_no FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
const existingBySeq = new Map<string, string>();
|
||||
for (const row of existingRes.rows) {
|
||||
existingBySeq.set(String(row.seq_no), row.id);
|
||||
}
|
||||
|
||||
const incomingSeqs = new Set<string>();
|
||||
for (const d of details) {
|
||||
incomingSeqs.add(String(d.seq_no));
|
||||
|
||||
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
|
||||
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
||||
: [];
|
||||
@@ -463,28 +461,70 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
);
|
||||
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
|
||||
} else if (d.outsource_supplier) {
|
||||
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
|
||||
legacyCode = d.outsource_supplier;
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, execution_type, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer]
|
||||
);
|
||||
const newDetailId = insertRes.rows[0].id;
|
||||
const existingId = existingBySeq.get(String(d.seq_no));
|
||||
let detailId: string;
|
||||
if (existingId) {
|
||||
await client.query(
|
||||
`UPDATE item_routing_detail
|
||||
SET process_code=$1, is_required=$2, is_fixed_order=$3, work_type=$4,
|
||||
standard_time=$5, outsource_supplier=$6, execution_type=$7,
|
||||
writer=$8, updated_date=NOW()
|
||||
WHERE id=$9 AND company_code=$10`,
|
||||
[
|
||||
d.process_code,
|
||||
d.is_required || "Y",
|
||||
d.is_fixed_order || "Y",
|
||||
d.work_type || "내부",
|
||||
d.standard_time || "0",
|
||||
legacyCode,
|
||||
d.execution_type || null,
|
||||
writer,
|
||||
existingId,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
detailId = existingId;
|
||||
} else {
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, execution_type, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer]
|
||||
);
|
||||
detailId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
// 외주업체 매핑은 detail 단위로 항상 재구성 (detail ID는 보존되므로 wop 참조엔 영향 없음)
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor WHERE routing_detail_id=$1 AND company_code=$2`,
|
||||
[detailId, companyCode]
|
||||
);
|
||||
for (let i = 0; i < supplierIds.length; i++) {
|
||||
await client.query(
|
||||
// 본서버 id 컬럼이 uuid 타입, 개발서버는 varchar — ::text 캐스팅하면 본서버에서 타입 불일치 오류 발생
|
||||
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
|
||||
[companyCode, newDetailId, supplierIds[i], i]
|
||||
[companyCode, detailId, supplierIds[i], i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// payload에서 빠진(삭제된) seq_no는 detail + subcontractor cascade 삭제
|
||||
for (const [seq, oldId] of existingBySeq.entries()) {
|
||||
if (incomingSeqs.has(seq)) continue;
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor WHERE routing_detail_id=$1 AND company_code=$2`,
|
||||
[oldId, companyCode]
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE id=$1 AND company_code=$2`,
|
||||
[oldId, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
|
||||
30
backend-node/src/routes/outsourcePurchaseRoutes.ts
Normal file
30
backend-node/src/routes/outsourcePurchaseRoutes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 외주발주관리 라우트 (TASK:ERP-019)
|
||||
*
|
||||
* 마운트: /api/outsource-purchase
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/outsourcePurchaseController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 헬퍼 (정적 경로 — :id 라우트보다 위에)
|
||||
router.get("/auto-processes", ctrl.autoProcesses);
|
||||
|
||||
// 외주발주 가능 작업지시 목록 (좌측 리스트 필터, TASK:ERP-019 재구현)
|
||||
router.get("/outsourceable-work-orders", ctrl.listOutsourceableWorkOrders);
|
||||
|
||||
// 사급자재 출고요청
|
||||
router.post("/release-request", ctrl.requestRelease);
|
||||
|
||||
// 메인 CRUD
|
||||
router.get("/", ctrl.listOutsourceOrders);
|
||||
router.get("/:id", ctrl.getOutsourceOrderDetail);
|
||||
router.post("/", ctrl.createOutsourceOrder);
|
||||
router.put("/:id", ctrl.updateOutsourceOrder);
|
||||
router.delete("/:id", ctrl.deleteOutsourceOrder);
|
||||
|
||||
export default router;
|
||||
1009
backend-node/src/services/outsourcePurchaseService.ts
Normal file
1009
backend-node/src/services/outsourcePurchaseService.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user